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
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue