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()));
}