refactor(3): VideoDownloader.Browser library - tabs/find/search-engine/zoom/bookmark-history stores extracted from App (66 GREEN)
This commit is contained in:
parent
6df9f81b4a
commit
e8ec1350b3
20 changed files with 63 additions and 31 deletions
5
VideoDownloader.Browser/BrowserModels.cs
Normal file
5
VideoDownloader.Browser/BrowserModels.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
namespace VideoDownloader.Browser;
|
||||
|
||||
public record Bookmark(string Title, string Url);
|
||||
|
||||
public record HistoryEntry(string Title, string Url, DateTime VisitedAt);
|
||||
67
VideoDownloader.Browser/BrowserStore.cs
Normal file
67
VideoDownloader.Browser/BrowserStore.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace VideoDownloader.Browser;
|
||||
|
||||
/// <summary>즐겨찾기/히스토리 JSON 영속화 (%AppData%/VideoDownloader).
|
||||
/// #5 저장/로드 실패를 조용 삼킴하지 않고 browserstore.log 에 기록하며,
|
||||
/// 손상 파일은 백업 후 빈 목록을 반환(다음 저장이 원본을 덮어쓰는 데이터 손실 방지).</summary>
|
||||
public static class BrowserStore
|
||||
{
|
||||
private static string Dir => VideoDownloader.Core.Platform.CorePaths.DataDir;
|
||||
private static string BookmarksPath => Path.Combine(Dir, "bookmarks.json");
|
||||
private static string HistoryPath => Path.Combine(Dir, "history.json");
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
|
||||
|
||||
public static List<Bookmark> LoadBookmarks()
|
||||
{
|
||||
try { if (File.Exists(BookmarksPath)) return JsonSerializer.Deserialize<List<Bookmark>>(File.ReadAllText(BookmarksPath)) ?? new(); }
|
||||
catch (Exception e) { LogLoadFail(BookmarksPath, e); }
|
||||
return new();
|
||||
}
|
||||
|
||||
public static void SaveBookmarks(List<Bookmark> bookmarks)
|
||||
{
|
||||
try { Directory.CreateDirectory(Dir); File.WriteAllText(BookmarksPath, JsonSerializer.Serialize(bookmarks, JsonOpts)); }
|
||||
catch (Exception e) { LogFail("저장", BookmarksPath, e); } // 디스크 가득/권한/잠금 → UI 스레드 전파 대신 로그
|
||||
}
|
||||
|
||||
public static List<HistoryEntry> LoadHistory()
|
||||
{
|
||||
try { if (File.Exists(HistoryPath)) return JsonSerializer.Deserialize<List<HistoryEntry>>(File.ReadAllText(HistoryPath)) ?? new(); }
|
||||
catch (Exception e) { LogLoadFail(HistoryPath, e); }
|
||||
return new();
|
||||
}
|
||||
|
||||
public static void SaveHistory(List<HistoryEntry> history)
|
||||
{
|
||||
try { Directory.CreateDirectory(Dir); File.WriteAllText(HistoryPath, JsonSerializer.Serialize(history, JsonOpts)); }
|
||||
catch (Exception e) { LogFail("저장", HistoryPath, e); }
|
||||
}
|
||||
|
||||
/// <summary>로드 실패(손상/권한/잠금) 시 원본을 .corrupt 백업한 뒤 로그.
|
||||
/// 빈 목록을 반환하더라도 원본이 남아 다음 저장이 사용자 데이터를 덮어쓰지 않게 한다.</summary>
|
||||
private static void LogLoadFail(string path, Exception e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Move(path, $"{path}.corrupt.{DateTime.Now:yyyyMMddHHmmss}.bak", overwrite: true);
|
||||
}
|
||||
catch { /* best-effort 백업 — 실패해도 계속 진행 */ }
|
||||
LogFail("로드", path, e);
|
||||
}
|
||||
|
||||
private static void LogFail(string op, string path, Exception e)
|
||||
=> Log($"{op} 실패({Path.GetFileName(path)}): {e.GetType().Name}: {e.Message}");
|
||||
|
||||
private static void Log(string msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
File.AppendAllText(Path.Combine(Dir, "browserstore.log"),
|
||||
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {msg}{Environment.NewLine}");
|
||||
}
|
||||
catch { /* 로그마저 실패하면 조용히 */ }
|
||||
}
|
||||
}
|
||||
126
VideoDownloader.Browser/FindSession.cs
Normal file
126
VideoDownloader.Browser/FindSession.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
using Microsoft.Web.WebView2.Core;
|
||||
|
||||
namespace VideoDownloader.Browser;
|
||||
|
||||
/// <summary>네이티브 Find API 의 최소 추상화(단위 테스트용 fake 교체 가능).</summary>
|
||||
public interface IFindApi
|
||||
{
|
||||
Task StartAsync(string term, bool caseSensitive, bool matchWord, bool highlightAll);
|
||||
void FindNext();
|
||||
void FindPrevious();
|
||||
void Stop();
|
||||
event EventHandler<int>? MatchCountChanged;
|
||||
event EventHandler<int>? ActiveMatchIndexChanged;
|
||||
}
|
||||
|
||||
/// <summary>WebView2 네이티브 Find(CoreWebView2.Find) 를 IFindApi 로 감싼 어댑터.
|
||||
/// 옵션 객체는 public ctor 가 없어 CoreWebView2Environment.CreateFindOptions() 로 생성.</summary>
|
||||
public sealed class WebView2FindApi : IFindApi
|
||||
{
|
||||
private readonly CoreWebView2 _cv;
|
||||
private readonly CoreWebView2Environment _env;
|
||||
|
||||
public WebView2FindApi(CoreWebView2 cv, CoreWebView2Environment env)
|
||||
{
|
||||
_cv = cv;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
public Task StartAsync(string term, bool caseSensitive, bool matchWord, bool highlightAll)
|
||||
{
|
||||
var o = _env.CreateFindOptions();
|
||||
o.FindTerm = term;
|
||||
o.IsCaseSensitive = caseSensitive;
|
||||
o.ShouldMatchWord = matchWord;
|
||||
o.ShouldHighlightAllMatches = highlightAll;
|
||||
o.SuppressDefaultFindDialog = true; // 자체 WPF 검색 바 사용
|
||||
return _cv.Find.StartAsync(o);
|
||||
}
|
||||
|
||||
public void FindNext() => _cv.Find.FindNext();
|
||||
public void FindPrevious() => _cv.Find.FindPrevious();
|
||||
public void Stop() => _cv.Find.Stop();
|
||||
|
||||
// 이벤트 인자가 object 로 전달되므로 매치 수는 Find 프로퍼티에서 직접 읽는다.
|
||||
public event EventHandler<int>? MatchCountChanged
|
||||
{
|
||||
add => _cv.Find.MatchCountChanged += (s, _) => value?.Invoke(s, _cv.Find.MatchCount);
|
||||
remove { }
|
||||
}
|
||||
|
||||
public event EventHandler<int>? ActiveMatchIndexChanged
|
||||
{
|
||||
add => _cv.Find.ActiveMatchIndexChanged += (s, _) => value?.Invoke(s, _cv.Find.ActiveMatchIndex);
|
||||
remove { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이지 내 검색 세션 상태 머신(T1-3). 용어·매치 수·활성 인덱스를 유지하고
|
||||
/// UI(검색 바)가 표시할 "3 / 17" 텍스트를 제공한다.
|
||||
/// </summary>
|
||||
public sealed class FindSession : IDisposable
|
||||
{
|
||||
private readonly IFindApi _api;
|
||||
|
||||
public string Term { get; private set; } = string.Empty;
|
||||
public int MatchCount { get; private set; }
|
||||
public int ActiveMatchIndex { get; private set; }
|
||||
public bool IsCaseSensitive { get; private set; }
|
||||
public bool MatchWholeWord { get; private set; }
|
||||
|
||||
public event Action? StateChanged;
|
||||
|
||||
public FindSession(IFindApi api)
|
||||
{
|
||||
_api = api;
|
||||
_api.MatchCountChanged += (_, n) => { MatchCount = n; StateChanged?.Invoke(); };
|
||||
_api.ActiveMatchIndexChanged += (_, n) => { ActiveMatchIndex = n; StateChanged?.Invoke(); };
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (MatchCount <= 0) return "결과 없음";
|
||||
var oneBased = ActiveMatchIndex >= 0 ? ActiveMatchIndex + 1 : 0;
|
||||
return $"{oneBased} / {MatchCount}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartAsync(string term)
|
||||
{
|
||||
Term = term ?? "";
|
||||
if (string.IsNullOrWhiteSpace(term))
|
||||
{
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
MatchCount = 0;
|
||||
ActiveMatchIndex = -1;
|
||||
await _api.StartAsync(Term, IsCaseSensitive, MatchWholeWord, highlightAll: true);
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void SetOptions(bool caseSensitive, bool matchWholeWord)
|
||||
{
|
||||
IsCaseSensitive = caseSensitive;
|
||||
MatchWholeWord = matchWholeWord;
|
||||
if (!string.IsNullOrWhiteSpace(Term))
|
||||
_ = StartAsync(Term); // 옵션 변경은 재시작 필요(네이티브 제약)
|
||||
}
|
||||
|
||||
public void Next() => _api.FindNext();
|
||||
public void Previous() => _api.FindPrevious();
|
||||
|
||||
public void Close()
|
||||
{
|
||||
_api.Stop();
|
||||
Term = string.Empty;
|
||||
MatchCount = 0;
|
||||
ActiveMatchIndex = -1;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose() => Close();
|
||||
}
|
||||
34
VideoDownloader.Browser/SearchEngine.cs
Normal file
34
VideoDownloader.Browser/SearchEngine.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
namespace VideoDownloader.Browser;
|
||||
|
||||
/// <summary>검색엔진 정의. QueryTemplate 의 {0} 이 URL 인코딩된 검색어로 치환된다.</summary>
|
||||
public sealed record SearchEngine(string Name, string Keyword, string QueryTemplate);
|
||||
|
||||
/// <summary>기본 프리셋 엔진. Keyword 는 주소창에서 `<키워드> <검색어>` 형태로 즉시 전환에 쓰인다.</summary>
|
||||
public static class SearchEngines
|
||||
{
|
||||
public static readonly IReadOnlyList<SearchEngine> Presets = new[]
|
||||
{
|
||||
new SearchEngine("Google", "g", "https://www.google.com/search?q={0}"),
|
||||
new SearchEngine("Bing", "b", "https://www.bing.com/search?q={0}"),
|
||||
new SearchEngine("DuckDuckGo", "d", "https://duckduckgo.com/?q={0}"),
|
||||
new SearchEngine("Naver", "n", "https://search.naver.com/search.naver?query={0}"),
|
||||
new SearchEngine("YouTube", "yt", "https://www.youtube.com/results?search_query={0}"),
|
||||
};
|
||||
|
||||
public static SearchEngine? FindByName(string? name, IEnumerable<SearchEngine>? extra = null)
|
||||
{
|
||||
var pool = extra != null ? Presets.Concat(extra) : Presets;
|
||||
return pool.FirstOrDefault(e =>
|
||||
string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public static SearchEngine? FindByKeyword(string? keyword, IEnumerable<SearchEngine>? extra = null)
|
||||
{
|
||||
var pool = extra != null ? Presets.Concat(extra) : Presets;
|
||||
return pool.FirstOrDefault(e =>
|
||||
string.Equals(e.Keyword, keyword, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>주소창 입력에 대한 네비게이션 의도. IsUrl=false 면 Target 은 검색 결과 URL.</summary>
|
||||
public sealed record NavigationIntent(bool IsUrl, string Target, string? EngineName = null);
|
||||
80
VideoDownloader.Browser/SearchEngineResolver.cs
Normal file
80
VideoDownloader.Browser/SearchEngineResolver.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace VideoDownloader.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// 주소창 입력을 URL 또는 검색 의도로 판별한다(T1-4).
|
||||
/// 판별 순서: ① 명시적 스킴 ② localhost/IPv4 ③ 도메인 형태 ④ 키워드 접두(`g `, `yt `) ⑤ 기본 엔진 검색.
|
||||
/// </summary>
|
||||
public sealed partial class SearchEngineResolver
|
||||
{
|
||||
private readonly SearchEngine _default;
|
||||
private readonly List<SearchEngine> _custom;
|
||||
|
||||
public SearchEngineResolver(string defaultEngineName, IEnumerable<SearchEngine>? customEngines = null)
|
||||
{
|
||||
_default = SearchEngines.FindByName(defaultEngineName, customEngines)
|
||||
?? SearchEngines.Presets[0];
|
||||
_custom = customEngines?.ToList() ?? new();
|
||||
}
|
||||
|
||||
public SearchEngine DefaultEngine => _default;
|
||||
|
||||
/// <summary>입력이 비었으면 null, URL 이면 URL 의도, 아니면 검색 결과 URL 의도.</summary>
|
||||
public NavigationIntent? Resolve(string? input)
|
||||
{
|
||||
input = input?.Trim();
|
||||
if (string.IsNullOrEmpty(input)) return null;
|
||||
|
||||
if (HasScheme(input))
|
||||
return new NavigationIntent(true, input);
|
||||
|
||||
if (IsLocalHost(input) || IsIpv4(input))
|
||||
return new NavigationIntent(true, "https://" + input);
|
||||
|
||||
if (LooksLikeDomain(input))
|
||||
return new NavigationIntent(true, "https://" + input);
|
||||
|
||||
// 키워드 전환: "g 고양이" → Google 검색. 미등록 키워드는 검색어의 일부.
|
||||
if (MatchKeyword(input) is { } kw)
|
||||
return new NavigationIntent(false, Format(kw.engine, kw.query), kw.engine.Name);
|
||||
|
||||
return new NavigationIntent(false, Format(_default, input), _default.Name);
|
||||
}
|
||||
|
||||
private static string Format(SearchEngine engine, string query)
|
||||
=> string.Format(engine.QueryTemplate, Uri.EscapeDataString(query).Replace("%20", "+"));
|
||||
|
||||
private (SearchEngine engine, string query)? MatchKeyword(string input)
|
||||
{
|
||||
var m = KeywordPrefix().Match(input);
|
||||
if (!m.Success) return null;
|
||||
var engine = SearchEngines.FindByKeyword(m.Groups[1].Value, _custom);
|
||||
return engine == null ? null : (engine, m.Groups[2].Value);
|
||||
}
|
||||
|
||||
private static bool HasScheme(string s) =>
|
||||
s.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("file://", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("about:", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("data:", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("blob:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsLocalHost(string s) =>
|
||||
s.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("localhost:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsIpv4(string s) =>
|
||||
s.Length > 7 && s[0] >= '0' && s[0] <= '9' && Regex.IsMatch(s, @"^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#].*)?$");
|
||||
|
||||
private static bool LooksLikeDomain(string s) =>
|
||||
DomainPattern().IsMatch(s);
|
||||
|
||||
// 라벨.라벨[.라벨...] 형태(최소 점 1개) + 선택적 포트/경로. 공백 포함 시 불일치.
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)+(:\d+)?([/?#].*)?$")]
|
||||
private static partial Regex DomainPattern();
|
||||
|
||||
[GeneratedRegex(@"^(\S+)\s+(.+)$", RegexOptions.Singleline)]
|
||||
private static partial Regex KeywordPrefix();
|
||||
}
|
||||
106
VideoDownloader.Browser/SiteZoomStore.cs
Normal file
106
VideoDownloader.Browser/SiteZoomStore.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VideoDownloader.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// 사이트별 줌 배율 영속 저장소(T1-7). WebView2 는 사이트별 줌 영속을 보장하지 않아
|
||||
/// 앱이 도메인→배율 사전을 SSOT 로 관리한다. LRU 300개, 저장 즉시 원자적 파일 기록.
|
||||
/// </summary>
|
||||
public sealed class SiteZoomStore
|
||||
{
|
||||
private sealed record Entry(double Zoom, long LastUsedTicks);
|
||||
|
||||
private const int MaxEntries = 300;
|
||||
private const double Min = 0.1, Max = 5.0;
|
||||
|
||||
private readonly string _path;
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<string, Entry> _map = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public SiteZoomStore(string path)
|
||||
{
|
||||
_path = path;
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var loaded = JsonSerializer.Deserialize<Dictionary<string, Entry>>(File.ReadAllText(path));
|
||||
if (loaded != null)
|
||||
foreach (var (k, v) in loaded) _map[k] = v;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 손상된 JSON → 빈 상태로 시작(기존 BrowserStore 정책과 동일)
|
||||
_map.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public int Count { get { lock (_lock) return _map.Count; } }
|
||||
|
||||
/// <summary>URL 의 호스트에 저장된 배율. 없으면 1.0. 읽기는 LRU 를 갱신한다.</summary>
|
||||
public double Get(string? url)
|
||||
{
|
||||
var host = NormalizeHost(url);
|
||||
if (host == null) return 1.0;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_map.TryGetValue(host, out var e))
|
||||
{
|
||||
_map[host] = e with { LastUsedTicks = DateTime.UtcNow.Ticks };
|
||||
return e.Zoom;
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
/// <summary>배율 저장(0.1~5.0 클램프). 초과분은 가장 오래된 사용 순으로 제거 후 즉시 영속.</summary>
|
||||
public void Set(string? url, double zoom)
|
||||
{
|
||||
var host = NormalizeHost(url);
|
||||
if (host == null) return;
|
||||
zoom = Math.Clamp(zoom, Min, Max);
|
||||
lock (_lock)
|
||||
{
|
||||
_map[host] = new Entry(zoom, DateTime.UtcNow.Ticks);
|
||||
if (_map.Count > MaxEntries)
|
||||
foreach (var k in _map.OrderBy(kv => kv.Value.LastUsedTicks).Take(_map.Count - MaxEntries).Select(kv => kv.Key).ToList())
|
||||
_map.Remove(k);
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_map.Clear();
|
||||
try { if (File.Exists(_path)) File.Delete(_path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
var tmp = _path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(_map));
|
||||
File.Move(tmp, _path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 저장 실패는 메모리 상태는 유지(조용한 내구성 저하)
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeHost(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return null;
|
||||
var host = uri.Host;
|
||||
return host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
|
||||
}
|
||||
}
|
||||
66
VideoDownloader.Browser/Tabs/TabItem.cs
Normal file
66
VideoDownloader.Browser/Tabs/TabItem.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Web.WebView2.Wpf;
|
||||
|
||||
namespace VideoDownloader.Browser.Tabs;
|
||||
|
||||
/// <summary>탭 하나의 상태. View(WebView2 컨트롤)는 앱 레이어에서 주입한다(단위 테스트에선 null).</summary>
|
||||
public sealed class TabItem : INotifyPropertyChanged
|
||||
{
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>탭에 연결된 WebView2 컨트롤. 닫힌 탭 재오픈 시 재생성될 수 있음.</summary>
|
||||
public WebView2? View { get; set; }
|
||||
|
||||
/// <summary>CoreWebView2 (초기화 전 null)</summary>
|
||||
public Microsoft.Web.WebView2.Core.CoreWebView2? Core => View?.CoreWebView2;
|
||||
private string _title = "새 탭";
|
||||
public string Title { get => _title; set => Set(ref _title, value); }
|
||||
|
||||
private string _url = "";
|
||||
public string Url { get => _url; set => Set(ref _url, value); }
|
||||
|
||||
private bool _isPinned;
|
||||
public bool IsPinned { get => _isPinned; set => Set(ref _isPinned, value); }
|
||||
|
||||
private bool _isActive;
|
||||
public bool IsActive { get => _isActive; set => Set(ref _isActive, value); }
|
||||
|
||||
private bool _isAudioPlaying;
|
||||
public bool IsAudioPlaying { get => _isAudioPlaying; set => Set(ref _isAudioPlaying, value); }
|
||||
|
||||
private bool _isMuted;
|
||||
public bool IsMuted { get => _isMuted; set => Set(ref _isMuted, value); }
|
||||
|
||||
private bool _isSuspended;
|
||||
public bool IsSuspended { get => _isSuspended; set => Set(ref _isSuspended, value); }
|
||||
|
||||
private bool _isLoading;
|
||||
public bool IsLoading { get => _isLoading; set => Set(ref _isLoading, value); }
|
||||
|
||||
/// <summary>마지막 활성 시각(탭 절전 판정용)</summary>
|
||||
public DateTime LastActivated { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>닫힌 시점의 인덱스(닫힌 탭 복원용). 열려 있는 동안은 무의미.</summary>
|
||||
internal int ClosedIndex { get; set; } = -1;
|
||||
|
||||
// ---- 탭별 일시 상태(호스트 앱이 사용) ----
|
||||
/// <summary>SourceChanged → 타이틀 확정 사이의 히스토리 기록 대기 URL.</summary>
|
||||
public string? PendingHistoryUrl { get; set; }
|
||||
/// <summary>소셜 카드 제목 갱신 대기 URL.</summary>
|
||||
public string? PendingSocialUrl { get; set; }
|
||||
/// <summary>탭별 페이지 내 검색 세션 (CoreWebView2 바인딩).</summary>
|
||||
public FindSession? FindSession { get; set; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>세션 스냅샷(T1-10): 탭 URL 순서 + 활성 인덱스 + 핀 여부.</summary>
|
||||
public sealed record SessionSnapshot(List<string> Urls, int ActiveIndex, List<bool> Pinned);
|
||||
164
VideoDownloader.Browser/Tabs/TabManager.cs
Normal file
164
VideoDownloader.Browser/Tabs/TabManager.cs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace VideoDownloader.Browser.Tabs;
|
||||
|
||||
/// <summary>
|
||||
/// 탭 관리 순수 로직(Phase A). WebView2 생성은 주입된 팩토리/어태처가 담당해
|
||||
/// 단위 테스트에서 UI 없이 검증 가능. 닫힌 탭 스택(25) · 핀 좌측 정렬 · 세션 캡처/복원 포함.
|
||||
/// </summary>
|
||||
public sealed class TabManager
|
||||
{
|
||||
private const int MaxClosed = 25;
|
||||
|
||||
private readonly Func<TabItem> _createTab;
|
||||
private readonly Action<TabItem>? _attachView;
|
||||
private readonly List<(TabItem Tab, int Index)> _closed = new();
|
||||
|
||||
public ObservableCollection<TabItem> Tabs { get; } = new();
|
||||
public TabItem? ActiveTab { get; private set; }
|
||||
|
||||
public event Action<TabItem>? ActiveChanged;
|
||||
public event Action<TabItem>? TabAdded;
|
||||
public event Action<TabItem>? TabRemoved;
|
||||
|
||||
/// <summary>닫힌 탭(LIFO — 최신 우선). Ctrl+Shift+T 복원 대상.</summary>
|
||||
public IReadOnlyList<TabItem> ClosedStack => _closed.Select(e => e.Tab).ToList();
|
||||
|
||||
public TabManager(Func<TabItem> createTab, Action<TabItem>? attachView = null)
|
||||
{
|
||||
_createTab = createTab;
|
||||
_attachView = attachView;
|
||||
}
|
||||
|
||||
public TabItem NewTab(string? url = null, bool activate = true)
|
||||
{
|
||||
var tab = _createTab();
|
||||
if (!string.IsNullOrEmpty(url)) tab.Url = url;
|
||||
_attachView?.Invoke(tab);
|
||||
Tabs.Add(tab); // 신규 탭은 핀 여부와 무관하게 끝에(핀 상태로 만들면 SetPinned 이동)
|
||||
TabAdded?.Invoke(tab);
|
||||
if (activate) Activate(tab);
|
||||
else tab.IsActive = false;
|
||||
return tab;
|
||||
}
|
||||
|
||||
public void Activate(TabItem tab)
|
||||
{
|
||||
if (!Tabs.Contains(tab)) return;
|
||||
foreach (var t in Tabs) t.IsActive = ReferenceEquals(t, tab);
|
||||
ActiveTab = tab;
|
||||
tab.LastActivated = DateTime.UtcNow;
|
||||
ActiveChanged?.Invoke(tab);
|
||||
}
|
||||
|
||||
/// <summary>탭 닫기. 활성 탭이면 오른쪽 이웃(없으면 왼쪽) 활성화. 마지막 탭이면 새 빈 탭으로 대체.</summary>
|
||||
public bool CloseTab(TabItem tab)
|
||||
{
|
||||
var index = Tabs.IndexOf(tab);
|
||||
if (index < 0) return false;
|
||||
|
||||
_closed.Insert(0, (tab, index));
|
||||
while (_closed.Count > MaxClosed) _closed.RemoveAt(_closed.Count - 1);
|
||||
tab.ClosedIndex = index;
|
||||
|
||||
var wasActive = ReferenceEquals(ActiveTab, tab);
|
||||
Tabs.RemoveAt(index);
|
||||
TabRemoved?.Invoke(tab);
|
||||
|
||||
if (Tabs.Count == 0)
|
||||
{
|
||||
NewTab(); // 창 유지 — 마지막 탭은 새 빈 탭으로 대체
|
||||
}
|
||||
else if (wasActive)
|
||||
{
|
||||
var next = Tabs[Math.Min(index, Tabs.Count - 1)]; // 오른쪽 우선, 끝이면 왼쪽
|
||||
Activate(next);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Ctrl+Shift+T: 닫힌 탭을 닫힌 위치에 복원.</summary>
|
||||
public TabItem? ReopenClosedTab()
|
||||
{
|
||||
if (_closed.Count == 0) return null;
|
||||
var (tab, index) = _closed[0];
|
||||
_closed.RemoveAt(0);
|
||||
|
||||
if (tab.View == null) _attachView?.Invoke(tab); // 뷰가 정리된 경우 재생성
|
||||
Tabs.Insert(Math.Min(index, Tabs.Count), tab);
|
||||
TabAdded?.Invoke(tab);
|
||||
Activate(tab);
|
||||
return tab;
|
||||
}
|
||||
|
||||
public void ActivateNext()
|
||||
{
|
||||
if (ActiveTab == null || Tabs.Count < 2) return;
|
||||
var i = Tabs.IndexOf(ActiveTab);
|
||||
Activate(Tabs[(i + 1) % Tabs.Count]);
|
||||
}
|
||||
|
||||
public void ActivatePrevious()
|
||||
{
|
||||
if (ActiveTab == null || Tabs.Count < 2) return;
|
||||
var i = Tabs.IndexOf(ActiveTab);
|
||||
Activate(Tabs[(i - 1 + Tabs.Count) % Tabs.Count]);
|
||||
}
|
||||
|
||||
public void ActivateAt(int index)
|
||||
{
|
||||
if (index >= 0 && index < Tabs.Count) Activate(Tabs[index]);
|
||||
}
|
||||
|
||||
public void ActivateLast() => ActivateAt(Tabs.Count - 1);
|
||||
|
||||
public void Move(TabItem tab, int newIndex)
|
||||
{
|
||||
var old = Tabs.IndexOf(tab);
|
||||
if (old < 0) return;
|
||||
newIndex = Math.Clamp(newIndex, 0, Tabs.Count - 1);
|
||||
if (newIndex == old) return;
|
||||
Tabs.Move(old, newIndex);
|
||||
}
|
||||
|
||||
/// <summary>핀 설정. 핀 탭은 좌측 끝에 정렬(Chrome 동작).</summary>
|
||||
public void SetPinned(TabItem tab, bool pinned)
|
||||
{
|
||||
if (!Tabs.Contains(tab)) return;
|
||||
tab.IsPinned = pinned;
|
||||
if (pinned)
|
||||
{
|
||||
var target = Tabs.Count(t => t.IsPinned) - 1; // 자신 포함 핀 탭 수 - 1
|
||||
Move(tab, Math.Max(0, target));
|
||||
}
|
||||
else
|
||||
{
|
||||
var target = Tabs.Count(t => t.IsPinned); // 핀 탭 블록 바로 뒤
|
||||
Move(tab, target);
|
||||
}
|
||||
}
|
||||
|
||||
public SessionSnapshot CaptureSession()
|
||||
{
|
||||
var active = ActiveTab == null ? 0 : Math.Max(0, Tabs.IndexOf(ActiveTab));
|
||||
return new SessionSnapshot(
|
||||
Tabs.Select(t => t.Url).ToList(),
|
||||
active,
|
||||
Tabs.Select(t => t.IsPinned).ToList());
|
||||
}
|
||||
|
||||
public void RestoreSession(SessionSnapshot snap)
|
||||
{
|
||||
foreach (var t in Tabs.ToList())
|
||||
{
|
||||
Tabs.Remove(t);
|
||||
TabRemoved?.Invoke(t);
|
||||
}
|
||||
for (var i = 0; i < snap.Urls.Count; i++)
|
||||
{
|
||||
var tab = NewTab(snap.Urls[i], activate: false);
|
||||
if (i < snap.Pinned.Count && snap.Pinned[i]) tab.IsPinned = true; // 순서 유지(핀 우선 저장됨)
|
||||
}
|
||||
ActivateAt(Math.Clamp(snap.ActiveIndex, 0, Math.Max(0, Tabs.Count - 1)));
|
||||
}
|
||||
}
|
||||
38
VideoDownloader.Browser/Tabs/TabSessionStore.cs
Normal file
38
VideoDownloader.Browser/Tabs/TabSessionStore.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace VideoDownloader.Browser.Tabs;
|
||||
|
||||
/// <summary>세션 JSON 저장소(T1-10). 종료 시 탭 URL 저장, 시작 시 복원.</summary>
|
||||
public static class TabSessionStore
|
||||
{
|
||||
public static void Save(string path, SessionSnapshot snap)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
var tmp = path + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(snap));
|
||||
File.Move(tmp, path, overwrite: true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public static SessionSnapshot? Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
var snap = JsonSerializer.Deserialize<SessionSnapshot>(File.ReadAllText(path));
|
||||
if (snap == null || snap.Urls == null || snap.Urls.Count == 0) return null;
|
||||
var pinned = snap.Pinned ?? new List<bool>();
|
||||
while (pinned.Count < snap.Urls.Count) pinned.Add(false);
|
||||
return snap with { Pinned = pinned };
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
VideoDownloader.Browser/VideoDownloader.Browser.csproj
Normal file
21
VideoDownloader.Browser/VideoDownloader.Browser.csproj
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<RootNamespace>VideoDownloader.Browser</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<!-- net8.0-windows WPF 빌드 시 SDK implicit usings 보강 -->
|
||||
<ItemGroup>
|
||||
<Using Include="System.IO" />
|
||||
<Using Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Loading…
Add table
Add a link
Reference in a new issue