205 lines
7.1 KiB
C#
205 lines
7.1 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)));
|
|
}
|
|
}
|
|
|
|
// ── T2-11 탭 그룹 확장 ──
|
|
public static class TabManagerGroupExtensions
|
|
{
|
|
/// <summary>그룹 지정(null 이면 해제). 같은 그룹 탭은 인접하도록 이동.</summary>
|
|
public static void SetGroup(this TabManager m, TabItem tab, string? name, string? color)
|
|
{
|
|
if (!m.Tabs.Contains(tab)) return;
|
|
tab.GroupName = name;
|
|
tab.GroupColor = color;
|
|
if (name == null) return;
|
|
// 같은 그룹 마지막 탭 뒤로 이동
|
|
var lastIdx = -1;
|
|
for (var i = 0; i < m.Tabs.Count; i++)
|
|
if (string.Equals(m.Tabs[i].GroupName, name, StringComparison.Ordinal) && !ReferenceEquals(m.Tabs[i], tab))
|
|
lastIdx = i;
|
|
if (lastIdx >= 0) m.Move(tab, lastIdx + 1);
|
|
}
|
|
|
|
/// <summary>그룹 전체 닫기. 반환: 닫은 탭 수.</summary>
|
|
public static int CloseGroup(this TabManager m, string groupName)
|
|
{
|
|
var group = m.Tabs.Where(t => string.Equals(t.GroupName, groupName, StringComparison.Ordinal)).ToList();
|
|
foreach (var t in group) m.CloseTab(t);
|
|
return group.Count;
|
|
}
|
|
}
|
|
|
|
/// <summary>워크스페이스 스냅샷(T3-1, Zen Workspaces 패턴).</summary>
|
|
public sealed record WorkspaceSnapshot(string Name, List<string> Urls);
|
|
|
|
public static class TabManagerWorkspaceExtensions
|
|
{
|
|
/// <summary>현재 탭 세트를 워크스페이스로 캡처(현재 상태는 유지).</summary>
|
|
public static WorkspaceSnapshot CaptureWorkspace(this TabManager m, string name)
|
|
=> new(name, m.Tabs.Select(t => t.Url).ToList());
|
|
|
|
/// <summary>워크스페이스로 전환: 기존 탭 제거 후 스냅샷 복원.</summary>
|
|
public static void SwitchWorkspace(this TabManager m, WorkspaceSnapshot ws)
|
|
=> m.RestoreSession(new SessionSnapshot(ws.Urls, 0, Enumerable.Repeat(false, ws.Urls.Count).ToList()));
|
|
}
|