using System.Collections.ObjectModel; namespace VideoDownloader.Browser.Tabs; /// /// 탭 관리 순수 로직(Phase A). WebView2 생성은 주입된 팩토리/어태처가 담당해 /// 단위 테스트에서 UI 없이 검증 가능. 닫힌 탭 스택(25) · 핀 좌측 정렬 · 세션 캡처/복원 포함. /// public sealed class TabManager { private const int MaxClosed = 25; private readonly Func _createTab; private readonly Action? _attachView; private readonly List<(TabItem Tab, int Index)> _closed = new(); public ObservableCollection Tabs { get; } = new(); public TabItem? ActiveTab { get; private set; } public event Action? ActiveChanged; public event Action? TabAdded; public event Action? TabRemoved; /// 닫힌 탭(LIFO — 최신 우선). Ctrl+Shift+T 복원 대상. public IReadOnlyList ClosedStack => _closed.Select(e => e.Tab).ToList(); public TabManager(Func createTab, Action? 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); } /// 탭 닫기. 활성 탭이면 오른쪽 이웃(없으면 왼쪽) 활성화. 마지막 탭이면 새 빈 탭으로 대체. 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; } /// Ctrl+Shift+T: 닫힌 탭을 닫힌 위치에 복원. 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); } /// 핀 설정. 핀 탭은 좌측 끝에 정렬(Chrome 동작). 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 { /// 그룹 지정(null 이면 해제). 같은 그룹 탭은 인접하도록 이동. 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); } /// 그룹 전체 닫기. 반환: 닫은 탭 수. 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; } } /// 워크스페이스 스냅샷(T3-1, Zen Workspaces 패턴). public sealed record WorkspaceSnapshot(string Name, List Urls); public static class TabManagerWorkspaceExtensions { /// 현재 탭 세트를 워크스페이스로 캡처(현재 상태는 유지). public static WorkspaceSnapshot CaptureWorkspace(this TabManager m, string name) => new(name, m.Tabs.Select(t => t.Url).ToList()); /// 워크스페이스로 전환: 기존 탭 제거 후 스냅샷 복원. public static void SwitchWorkspace(this TabManager m, WorkspaceSnapshot ws) => m.RestoreSession(new SessionSnapshot(ws.Urls, 0, Enumerable.Repeat(false, ws.Urls.Count).ToList())); }