diff --git a/VideoDownloader.App/Browser/Tabs/TabItem.cs b/VideoDownloader.App/Browser/Tabs/TabItem.cs
new file mode 100644
index 0000000..4f98a45
--- /dev/null
+++ b/VideoDownloader.App/Browser/Tabs/TabItem.cs
@@ -0,0 +1,59 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using Microsoft.Web.WebView2.Wpf;
+
+namespace VideoDownloader.App.Browser.Tabs;
+
+/// 탭 하나의 상태. View(WebView2 컨트롤)는 앱 레이어에서 주입한다(단위 테스트에선 null).
+public sealed class TabItem : INotifyPropertyChanged
+{
+ public Guid Id { get; } = Guid.NewGuid();
+
+ /// 탭에 연결된 WebView2 컨트롤. 닫힌 탭 재오픈 시 재생성될 수 있음.
+ public WebView2? View { get; set; }
+
+ /// CoreWebView2 (초기화 전 null)
+ 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); }
+
+ /// 마지막 활성 시각(탭 절전 판정용)
+ public DateTime LastActivated { get; set; } = DateTime.UtcNow;
+
+ /// 닫힌 시점의 인덱스(닫힌 탭 복원용). 열려 있는 동안은 무의미.
+ internal int ClosedIndex { get; set; } = -1;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void Set(ref T field, T value, [CallerMemberName] string? name = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value)) return;
+ field = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+}
+
+/// 세션 스냅샷(T1-10): 탭 URL 순서 + 활성 인덱스 + 핀 여부.
+public sealed record SessionSnapshot(List Urls, int ActiveIndex, List Pinned);
diff --git a/VideoDownloader.App/Browser/Tabs/TabManager.cs b/VideoDownloader.App/Browser/Tabs/TabManager.cs
new file mode 100644
index 0000000..8cfde04
--- /dev/null
+++ b/VideoDownloader.App/Browser/Tabs/TabManager.cs
@@ -0,0 +1,164 @@
+using System.Collections.ObjectModel;
+
+namespace VideoDownloader.App.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)));
+ }
+}
diff --git a/VideoDownloader.App/Browser/Tabs/TabSessionStore.cs b/VideoDownloader.App/Browser/Tabs/TabSessionStore.cs
new file mode 100644
index 0000000..c15c733
--- /dev/null
+++ b/VideoDownloader.App/Browser/Tabs/TabSessionStore.cs
@@ -0,0 +1,38 @@
+using System.IO;
+using System.Text.Json;
+
+namespace VideoDownloader.App.Browser.Tabs;
+
+/// 세션 JSON 저장소(T1-10). 종료 시 탭 URL 저장, 시작 시 복원.
+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(File.ReadAllText(path));
+ if (snap == null || snap.Urls == null || snap.Urls.Count == 0) return null;
+ var pinned = snap.Pinned ?? new List();
+ while (pinned.Count < snap.Urls.Count) pinned.Add(false);
+ return snap with { Pinned = pinned };
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/VideoDownloader.Tests/Browser/TabManagerTests.cs b/VideoDownloader.Tests/Browser/TabManagerTests.cs
new file mode 100644
index 0000000..8bc7125
--- /dev/null
+++ b/VideoDownloader.Tests/Browser/TabManagerTests.cs
@@ -0,0 +1,228 @@
+using VideoDownloader.App.Browser.Tabs;
+
+namespace VideoDownloader.Tests.Browser;
+
+// Phase A 탭 아키텍처: TabManager 순수 로직(뷰 팩토리 주입 — WebView2 없이 검증).
+public class TabManagerTests
+{
+ private static TabManager NewManager() => new(() => new TabItem());
+
+ [Fact]
+ public void NewTab은_탭을_추가하고_활성화한다()
+ {
+ var m = NewManager();
+ var t = m.NewTab("https://a.com");
+ Assert.Single(m.Tabs);
+ Assert.Same(t, m.ActiveTab);
+ Assert.Equal("https://a.com", t.Url);
+ }
+
+ [Fact]
+ public void 두_번째_탭은_이전_탭을_비활성화하고_활성화된다()
+ {
+ var m = NewManager();
+ var t1 = m.NewTab("https://a.com");
+ var t2 = m.NewTab("https://b.com");
+ Assert.Same(t2, m.ActiveTab);
+ Assert.False(t1.IsActive);
+ Assert.True(t2.IsActive);
+ }
+
+ [Fact]
+ public void Activate는_활성_탭을_바꾸고_이벤트를_발생시킨다()
+ {
+ var m = NewManager();
+ var t1 = m.NewTab();
+ var t2 = m.NewTab();
+ var fired = 0;
+ m.ActiveChanged += _ => fired++;
+ m.Activate(t1);
+ Assert.Same(t1, m.ActiveTab);
+ Assert.True(t1.IsActive);
+ Assert.False(t2.IsActive);
+ Assert.Equal(1, fired);
+ }
+
+ [Fact]
+ public void CloseTab은_제거하고_이웃_탭을_활성화한다()
+ {
+ var m = NewManager();
+ m.NewTab("https://a.com"); // index 0
+ var t2 = m.NewTab("https://b.com"); // index 1 (active)
+ var t3 = m.NewTab("https://c.com"); // index 2
+ m.Activate(t2);
+
+ m.CloseTab(t2);
+ Assert.Equal(2, m.Tabs.Count);
+ Assert.DoesNotContain(t2, m.Tabs);
+ // 닫힌 탭 오른쪽이 있으면 그 탭(t3) 활성화 (Chrome 동작)
+ Assert.Same(t3, m.ActiveTab);
+ }
+
+ [Fact]
+ public void 마지막_탭을_닫으면_새_빈_탭으로_대체된다()
+ {
+ var m = NewManager();
+ var only = m.NewTab("https://solo.com");
+ m.CloseTab(only);
+ Assert.Single(m.Tabs);
+ Assert.NotSame(only, m.ActiveTab);
+ Assert.Same(m.ActiveTab, m.Tabs[0]);
+ }
+
+ [Fact]
+ public void 닫힌_탭은_스택에_쌓이고_ReopenClosedTab으로_원래_위치에_복원된다()
+ {
+ var m = NewManager();
+ var t1 = m.NewTab("https://a.com");
+ m.NewTab("https://b.com");
+ m.NewTab("https://c.com"); // index 2, active
+ m.CloseTab(m.Tabs[2]); // 마지막 인덱스 탭 닫기
+
+ var reopened = m.ReopenClosedTab();
+ Assert.NotNull(reopened);
+ Assert.Equal("https://c.com", reopened!.Url);
+ Assert.Equal(3, m.Tabs.Count);
+ Assert.Equal(2, m.Tabs.IndexOf(reopened)); // 원래 인덱스 복원
+ Assert.Same(reopened, m.ActiveTab);
+ Assert.Empty(m.ClosedStack);
+ }
+
+ [Fact]
+ public void 닫힌_탭_스택은_최대_25개다()
+ {
+ var m = NewManager();
+ for (int i = 0; i < 30; i++)
+ {
+ var t = m.NewTab($"https://x{i}.com");
+ m.CloseTab(t);
+ }
+ Assert.Equal(25, m.ClosedStack.Count);
+ Assert.Equal("https://x29.com", m.ClosedStack.First().Url); // LIFO 최신 우선
+ }
+
+ [Fact]
+ public void ActivateNext_Previous는_순환한다()
+ {
+ var m = NewManager();
+ var t1 = m.NewTab("https://a.com");
+ var t2 = m.NewTab("https://b.com");
+ m.Activate(t1);
+
+ m.ActivateNext();
+ Assert.Same(t2, m.ActiveTab);
+ m.ActivateNext(); // 순환
+ Assert.Same(t1, m.ActiveTab);
+ m.ActivatePrevious();
+ Assert.Same(t2, m.ActiveTab);
+ }
+
+ [Fact]
+ public void ActivateAt와_ActivateLast가_동작한다()
+ {
+ var m = NewManager();
+ m.NewTab("a"); m.NewTab("b"); var last = m.NewTab("c");
+ m.ActivateAt(0);
+ Assert.Same(m.Tabs[0], m.ActiveTab);
+ m.ActivateLast();
+ Assert.Same(last, m.ActiveTab);
+ }
+
+ [Fact]
+ public void Move는_탭_순서를_바꾼다()
+ {
+ var m = NewManager();
+ var t1 = m.NewTab("a"); var t2 = m.NewTab("b"); var t3 = m.NewTab("c");
+ m.Move(t1, 2); // a 를 끝으로
+ Assert.Equal(2, m.Tabs.IndexOf(t1));
+ Assert.Equal(0, m.Tabs.IndexOf(t2));
+ Assert.Equal(1, m.Tabs.IndexOf(t3));
+ }
+
+ [Fact]
+ public void Pin은_탭을_좌측_끝으로_이동시키고_핀_표시한다()
+ {
+ var m = NewManager();
+ m.NewTab("a"); m.NewTab("b");
+ var t3 = m.NewTab("c");
+ m.SetPinned(t3, true);
+ Assert.True(t3.IsPinned);
+ Assert.Equal(0, m.Tabs.IndexOf(t3)); // 핀 탭은 좌측 정렬
+ m.SetPinned(t3, false);
+ Assert.False(t3.IsPinned);
+ }
+
+ [Fact]
+ public void CaptureSession은_URL과_활성_인덱스와_핀을_담는다()
+ {
+ var m = NewManager();
+ m.NewTab("https://a.com");
+ var t2 = m.NewTab("https://b.com");
+ m.NewTab("https://c.com");
+ m.SetPinned(t2, true); // b 가 좌측으로 이동 → 순서: b,a,c
+ m.Activate(t2);
+
+ var s = m.CaptureSession();
+ Assert.Equal(new[] { "https://b.com", "https://a.com", "https://c.com" }, s.Urls);
+ Assert.Equal(0, s.ActiveIndex);
+ Assert.Equal(new[] { true, false, false }, s.Pinned);
+ }
+
+ [Fact]
+ public void RestoreSession은_저장된_탭들을_재생성한다()
+ {
+ var m = NewManager();
+ // CaptureSession 불변식: 핀 탭이 먼저 오는 순서로 저장됨
+ var snap = new SessionSnapshot(
+ new() { "https://b.com", "https://a.com" }, 0, new() { true, false });
+ m.RestoreSession(snap);
+ Assert.Equal(2, m.Tabs.Count);
+ Assert.Equal("https://b.com", m.ActiveTab!.Url);
+ Assert.True(m.Tabs[0].IsPinned); // 핀 탭이 좌측
+ Assert.False(m.Tabs[1].IsPinned);
+ }
+
+ [Fact]
+ public void RestoreSession은_기존_탭을_치환한다()
+ {
+ var m = NewManager();
+ m.NewTab("https://old.com");
+ m.RestoreSession(new SessionSnapshot(new() { "https://new.com" }, 0, new() { false }));
+ Assert.Single(m.Tabs);
+ Assert.Equal("https://new.com", m.Tabs[0].Url);
+ }
+}
+
+// 세션 JSON 저장소(T1-10) 분리 테스트
+public class TabSessionStoreTests : IDisposable
+{
+ private readonly string _path = Path.Combine(Path.GetTempPath(), $"session_{Guid.NewGuid():N}.json");
+
+ [Fact]
+ public void 저장_후_로드_라운드트립()
+ {
+ var snap = new SessionSnapshot(
+ new() { "https://a.com", "https://b.com", "https://c.com" }, 2, new() { true, false, false });
+ TabSessionStore.Save(_path, snap);
+ var loaded = TabSessionStore.Load(_path);
+ Assert.NotNull(loaded);
+ Assert.Equal(snap.Urls, loaded!.Urls);
+ Assert.Equal(2, loaded.ActiveIndex);
+ Assert.Equal(snap.Pinned, loaded.Pinned);
+ }
+
+ [Fact]
+ public void 파일_없으면_null()
+ {
+ Assert.Null(TabSessionStore.Load(_path));
+ }
+
+ [Fact]
+ public void 손상된_JSON이면_null()
+ {
+ File.WriteAllText(_path, "{ broken");
+ Assert.Null(TabSessionStore.Load(_path));
+ }
+
+ public void Dispose() { try { File.Delete(_path); } catch { } }
+}