164 lines
5.3 KiB
C#
164 lines
5.3 KiB
C#
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)));
|
|
}
|
|
}
|