데스크톱 T3 잔여 완성 + 스타일 폴리시: 도메인 라우팅(WorkspaceRoutingRules 자동 워크스페이스 전환), 탭 아카이브(TabArchive 영속·팔레트 복구), 페이지 액션(흑백/세피아 필터), 노트 패널(NotesStore+플라이아웃), 컴팩트 모드(크롬 숨김+엣지 호버). 스타일: 한국어 폰트 폴백, 암시 폰트 통일, 다크 툴팁, 텍스트 대비 상향. UpdateNavState dispose 가드. 164/164 GREEN + 실창 스크린샷 검증
This commit is contained in:
parent
04ff0ff93d
commit
f380518919
12 changed files with 594 additions and 24 deletions
|
|
@ -58,7 +58,7 @@
|
||||||
| 항목 | 구현 | 증거 |
|
| 항목 | 구현 | 증거 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| T2-10 미디어 재생목록 | ✅ 감지 미디어 자동 적재, 플라이아웃, 플레이어 창(native+hls.js+dash.js), 자동 다음, JSON 영속 | `MediaPlaylist.cs` · `PlayerWindow.cs` + 테스트 |
|
| T2-10 미디어 재생목록 | ✅ 감지 미디어 자동 적재, 플라이아웃, 플레이어 창(native+hls.js+dash.js), 자동 다음, JSON 영속 | `MediaPlaylist.cs` · `PlayerWindow.cs` + 테스트 |
|
||||||
| T3-1 워크스페이스 | ✅ 저장/전환 | `TabManagerWorkspaceExtensions` + 테스트 |
|
| T3-1 워크스페이스 + 도메인 라우팅 | ✅ 저장/전환 + WorkspaceRoutingRules(도메인→워크스페이스, 주소창 이동 시 자동 전환, 팔레트로 규칙 등록) | `TabManagerWorkspaceExtensions` + `DesktopRemainderTests` 라우팅 2건(www/서브도메인 매칭·영속) |
|
||||||
| T3-3 컨테이너/InPrivate | ✅ IsInPrivateModeEnabled 탭 | `OpenInPrivateTab` |
|
| T3-3 컨테이너/InPrivate | ✅ IsInPrivateModeEnabled 탭 | `OpenInPrivateTab` |
|
||||||
| T3-4 빠른 찾기 `/` | ✅ | `MainWindow` 단축키 |
|
| T3-4 빠른 찾기 `/` | ✅ | `MainWindow` 단축키 |
|
||||||
| T3-5 컨텍스트 메뉴 전면 커스터마이징 | ✅ WPF 메뉴 + SelectedCommandId | `TabViewService` |
|
| T3-5 컨텍스트 메뉴 전면 커스터마이징 | ✅ WPF 메뉴 + SelectedCommandId | `TabViewService` |
|
||||||
|
|
@ -75,11 +75,15 @@
|
||||||
| T3-14 추적 방지 카운터 | ✅ TrackerBlocklist(20개 도메인, 서브도메인 매칭) + WebResourceRequested 차단(204) + TrackerCounter + 실드 배지·도메인 팝업 | `TrackerBlockTests` 8건 + E2E(실제 WebView2 에서 2개 스크립트 차단·집계, 정상 도메인 통과) |
|
| T3-14 추적 방지 카운터 | ✅ TrackerBlocklist(20개 도메인, 서브도메인 매칭) + WebResourceRequested 차단(204) + TrackerCounter + 실드 배지·도메인 팝업 | `TrackerBlockTests` 8건 + E2E(실제 WebView2 에서 2개 스크립트 차단·집계, 정상 도메인 통과) |
|
||||||
| T3-15 주소창 진행 바 + 파라미터 제거 복사 | ✅ NavProgressTracker(전 탭 집계 INPC) → 주소창 하단 인디케이터 + 주소창 우클릭 "파라미터 제거 후 복사"(UrlCleaner) | `UrlCleanerTests`/`NavProgressTrackerTests` 13건 |
|
| T3-15 주소창 진행 바 + 파라미터 제거 복사 | ✅ NavProgressTracker(전 탭 집계 INPC) → 주소창 하단 인디케이터 + 주소창 우클릭 "파라미터 제거 후 복사"(UrlCleaner) | `UrlCleanerTests`/`NavProgressTrackerTests` 13건 |
|
||||||
|
|
||||||
### T3 중 명시적 미구현 (계획 내 후순위/선택)
|
### 3차 완성 (T3 잔여 + 스타일)
|
||||||
|
|
||||||
- T3-2 Auto-Archive 캐던스 — 닫힘 스택(25개)+복원이 커버(아카이브 자동화는 미차용)
|
| 항목 | 구현 | 증거 |
|
||||||
- T3-12 노트 패널 — 계획 자체가 "후순위" 명시
|
|---|---|---|
|
||||||
- T3-13 컴팩트 모드 — F11 전체화면이 동등 커버
|
| T3-2 탭 아카이브 | ✅ TabArchive(닫힘 이력 JSON 영속, 500 상한, 연속 중복 제목 갱신, 검색) + 탭 닫힘 배선 + 팔레트 "아카이브에서 열기" | `DesktopRemainderTests` 3건 + 스모크 E2E(URL 탭 닫음 → 아카이브 기록 실증) |
|
||||||
|
| T3-6 페이지 액션 | ✅ BoostSettings.PageFilter(흑백/세피아) → CSS 필터 주입, 팔레트 명령 | `DesktopRemainderTests` 3건(RED→GREEN) + 스모크 E2E(로컬 HTTP 문서에서 적용→부스트 저장 실증) |
|
||||||
|
| T3-12 노트 패널 | ✅ NotesStore(추가·검색·삭제·영속) + 노트 플라이아웃(Ctrl+Alt+N) + "현재 페이지 추가" | `DesktopRemainderTests` 2건 + 스모크 E2E + 실창 스크린샷 |
|
||||||
|
| T3-13 컴팩트 모드 | ✅ Ctrl+Shift+M 크롬 자동 숨김 + 상단 엣지(4px) 호버 복원 | 스모크 E2E(행 높이 0→복원) + 실창 스크린샷 |
|
||||||
|
| 스타일 폴리시 | ✅ 폰트 폴백(한국어 맑은 고딕 포함) + 암시 TextBlock 폰트 통일 + 다크 ToolTip + 텍스트 계층 대비 상향(Secondary/Muted/Border) + 상태바 가독성 + 다이얼 페이지 타이포 | 실창 스크린샷 검증(다이얼/노트/컴팩트) |
|
||||||
|
|
||||||
### 재구성 (사용자 지시 "근원부터 뜯어고쳐")
|
### 재구성 (사용자 지시 "근원부터 뜯어고쳐")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,11 @@ public sealed class CommandPalette
|
||||||
public void Execute(PaletteCommand command) => command.Execute();
|
public void Execute(PaletteCommand command) => command.Execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>사이트별 부스트 설정(T2-7, Zen/Arc Boosts 패턴).</summary>
|
/// <summary>페이지 액션 필터(T3-6, Vivaldi Page Actions — Boost 통합).</summary>
|
||||||
public sealed record BoostSettings(bool ForceDark, string CustomCss);
|
public enum PageFilter { None, Grayscale, Sepia }
|
||||||
|
|
||||||
|
/// <summary>사이트별 부스트 설정(T2-7, Zen/Arc Boosts 패턴) + 페이지 액션 필터(T3-6).</summary>
|
||||||
|
public sealed record BoostSettings(bool ForceDark, string CustomCss, PageFilter Filter = PageFilter.None);
|
||||||
|
|
||||||
/// <summary>도메인별 부스트 영속 저장.</summary>
|
/// <summary>도메인별 부스트 영속 저장.</summary>
|
||||||
public sealed class BoostStore
|
public sealed class BoostStore
|
||||||
|
|
@ -77,11 +80,11 @@ public sealed class BoostStore
|
||||||
return host != null && _map.TryGetValue(host, out var v) ? v : new BoostSettings(false, "");
|
return host != null && _map.TryGetValue(host, out var v) ? v : new BoostSettings(false, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Set(string? url, bool forceDark, string css)
|
public void Set(string? url, bool forceDark, string css, PageFilter filter = PageFilter.None)
|
||||||
{
|
{
|
||||||
var host = Normalize(url);
|
var host = Normalize(url);
|
||||||
if (host == null) return;
|
if (host == null) return;
|
||||||
_map[host] = new BoostSettings(forceDark, css);
|
_map[host] = new BoostSettings(forceDark, css, filter);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||||
|
|
@ -110,7 +113,7 @@ public static class BoostCssBuilder
|
||||||
{
|
{
|
||||||
public static string BuildScript(string? url, BoostSettings settings)
|
public static string BuildScript(string? url, BoostSettings settings)
|
||||||
{
|
{
|
||||||
if (!settings.ForceDark && string.IsNullOrWhiteSpace(settings.CustomCss)) return "";
|
if (!settings.ForceDark && settings.Filter == PageFilter.None && string.IsNullOrWhiteSpace(settings.CustomCss)) return "";
|
||||||
var css = "";
|
var css = "";
|
||||||
if (settings.ForceDark)
|
if (settings.ForceDark)
|
||||||
{
|
{
|
||||||
|
|
@ -119,6 +122,14 @@ html { filter: invert(1) hue-rotate(180deg); background: #fff; }
|
||||||
img, video, picture, canvas, svg, iframe, embed, object { filter: invert(1) hue-rotate(180deg); }
|
img, video, picture, canvas, svg, iframe, embed, object { filter: invert(1) hue-rotate(180deg); }
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
else if (settings.Filter == PageFilter.Grayscale) // T3-6 페이지 액션
|
||||||
|
{
|
||||||
|
css += "html { filter: grayscale(1); }";
|
||||||
|
}
|
||||||
|
else if (settings.Filter == PageFilter.Sepia)
|
||||||
|
{
|
||||||
|
css += "html { filter: sepia(0.7); }";
|
||||||
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(settings.CustomCss)) css += settings.CustomCss + "\n";
|
if (!string.IsNullOrWhiteSpace(settings.CustomCss)) css += settings.CustomCss + "\n";
|
||||||
var js = $$"""
|
var js = $$"""
|
||||||
(function(){ try { var s = document.createElement('style'); s.id = 'vd-boost'; s.textContent = {{JsonSerializer.Serialize(css)}}; (document.head || document.documentElement).appendChild(s); } catch(e){} })();
|
(function(){ try { var s = document.createElement('style'); s.id = 'vd-boost'; s.textContent = {{JsonSerializer.Serialize(css)}}; (document.head || document.documentElement).appendChild(s); } catch(e){} })();
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
|
|
||||||
<Grid>
|
<Grid x:Name="MainGrid">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
|
@ -459,7 +459,7 @@
|
||||||
<!-- ===== 상태 표시줄 ===== -->
|
<!-- ===== 상태 표시줄 ===== -->
|
||||||
<Border x:Name="StatusBar" Grid.Row="4" Background="{StaticResource PanelBg}"
|
<Border x:Name="StatusBar" Grid.Row="4" Background="{StaticResource PanelBg}"
|
||||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5">
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5">
|
||||||
<TextBlock Name="StatusText" Foreground="{StaticResource TextMuted}" FontSize="11"
|
<TextBlock Name="StatusText" Foreground="{StaticResource TextSecondary}" FontSize="11.5"
|
||||||
TextTrimming="CharacterEllipsis" Text="준비"/>
|
TextTrimming="CharacterEllipsis" Text="준비"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|
@ -550,6 +550,26 @@
|
||||||
</Popup>
|
</Popup>
|
||||||
|
|
||||||
<!-- ===== 다운로드 플라이아웃 (T1-5) ===== -->
|
<!-- ===== 다운로드 플라이아웃 (T1-5) ===== -->
|
||||||
|
<!-- T3-12 노트 패널 -->
|
||||||
|
<Popup Name="NotesPopup" PlacementTarget="{Binding ElementName=AddressBox}"
|
||||||
|
Placement="Bottom" HorizontalOffset="-40" VerticalOffset="6"
|
||||||
|
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
|
||||||
|
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="12" Padding="10" Width="420" MaxHeight="460">
|
||||||
|
<StackPanel>
|
||||||
|
<DockPanel Margin="6,2,6,8">
|
||||||
|
<TextBlock Text="노트" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Style="{StaticResource CardActionButton}" Content="현재 페이지 추가" Click="NotesAddPage_Click" Margin="0,0,6,0"/>
|
||||||
|
<Button Style="{StaticResource CardActionButton}" Content="삭제" Click="NotesDelete_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
<ListBox Name="NotesList" Background="Transparent" BorderThickness="0" Height="340"
|
||||||
|
Foreground="{StaticResource TextPrimary}" FontSize="12"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
<!-- T3-14 추적 방지 실드 팝업 -->
|
<!-- T3-14 추적 방지 실드 팝업 -->
|
||||||
<Popup Name="ShieldPopup" PlacementTarget="{Binding ElementName=ShieldBtn}"
|
<Popup Name="ShieldPopup" PlacementTarget="{Binding ElementName=ShieldBtn}"
|
||||||
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
|
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,15 @@ public partial class MainWindow : Window
|
||||||
// Phase C: 팔레트/부스트/워크스페이스/레이아웃
|
// Phase C: 팔레트/부스트/워크스페이스/레이아웃
|
||||||
private readonly CommandPalette _palette = new();
|
private readonly CommandPalette _palette = new();
|
||||||
private BoostStore _boostStore = null!;
|
private BoostStore _boostStore = null!;
|
||||||
|
private WorkspaceRoutingRules _routingRules = null!; // T3-1
|
||||||
|
private TabArchive _tabArchive = null!; // T3-2
|
||||||
|
internal TabArchive Archive => _tabArchive; // E2E 검증용
|
||||||
|
internal BoostStore BoostStore => _boostStore; // E2E 검증용
|
||||||
|
internal NotesStore Notes => _notes; // E2E 검증용
|
||||||
|
private NotesStore _notes = null!; // T3-12
|
||||||
|
private string? _currentWorkspaceName; // T3-1 라우팅 기준
|
||||||
|
private bool _compactMode; // T3-13
|
||||||
|
private bool _chromeRevealed; // T3-13 호버 복원 상태
|
||||||
private List<WorkspaceSnapshot> _workspaces = new();
|
private List<WorkspaceSnapshot> _workspaces = new();
|
||||||
private bool _verticalTabs;
|
private bool _verticalTabs;
|
||||||
private TabItem? _splitTab;
|
private TabItem? _splitTab;
|
||||||
|
|
@ -101,6 +110,9 @@ public partial class MainWindow : Window
|
||||||
_resolver = new SearchEngineResolver(_config.SearchEngine);
|
_resolver = new SearchEngineResolver(_config.SearchEngine);
|
||||||
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
|
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
|
||||||
_boostStore = new BoostStore(Path.Combine(AppDataDir, "boost.json"));
|
_boostStore = new BoostStore(Path.Combine(AppDataDir, "boost.json"));
|
||||||
|
_routingRules = new WorkspaceRoutingRules(Path.Combine(AppDataDir, "routing.json")); // T3-1
|
||||||
|
_tabArchive = new TabArchive(Path.Combine(AppDataDir, "archive.json")); // T3-2
|
||||||
|
_notes = new NotesStore(Path.Combine(AppDataDir, "notes.json")); // T3-12
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var wsPath = Path.Combine(AppDataDir, "workspaces.json");
|
var wsPath = Path.Combine(AppDataDir, "workspaces.json");
|
||||||
|
|
@ -434,16 +446,22 @@ public partial class MainWindow : Window
|
||||||
private void OnTabRemoved(TabItem tab)
|
private void OnTabRemoved(TabItem tab)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(_splitTab, tab)) ExitSplit();
|
if (ReferenceEquals(_splitTab, tab)) ExitSplit();
|
||||||
|
_tabArchive.Add(tab.Title, tab.Url); // T3-2 닫힘 → 아카이브(영속, 500 상한)
|
||||||
_views?.RemoveView(tab);
|
_views?.RemoveView(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateNavState()
|
private void UpdateNavState()
|
||||||
{
|
{
|
||||||
var cv = Cv;
|
|
||||||
Dispatcher.BeginInvoke(new Action(() =>
|
Dispatcher.BeginInvoke(new Action(() =>
|
||||||
{
|
{
|
||||||
NavBack.IsEnabled = cv?.CanGoBack == true;
|
try
|
||||||
NavForward.IsEnabled = cv?.CanGoForward == true;
|
{
|
||||||
|
var cv = Cv; // dispose 이후 이벤트 경로 방어
|
||||||
|
NavBack.IsEnabled = cv?.CanGoBack == true;
|
||||||
|
NavForward.IsEnabled = cv?.CanGoForward == true;
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException) { }
|
||||||
|
catch (InvalidOperationException) { }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -717,6 +735,16 @@ public partial class MainWindow : Window
|
||||||
// T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당
|
// T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당
|
||||||
var intent = _resolver.Resolve(url);
|
var intent = _resolver.Resolve(url);
|
||||||
if (intent == null) return;
|
if (intent == null) return;
|
||||||
|
|
||||||
|
// T3-1: 도메인 라우팅 규칙 — 대상 워크스페이스가 다르면 자동 전환 후 이동(Arc ATC 패턴)
|
||||||
|
var routeTo = _routingRules.Match(intent.Target);
|
||||||
|
if (routeTo != null && routeTo != _currentWorkspaceName &&
|
||||||
|
_workspaces.FirstOrDefault(w => w.Name == routeTo) is { } wsTarget)
|
||||||
|
{
|
||||||
|
SwitchWorkspace(wsTarget);
|
||||||
|
_currentWorkspaceName = routeTo;
|
||||||
|
Log($"라우팅 규칙 → 워크스페이스 '{routeTo}' 전환");
|
||||||
|
}
|
||||||
Cv?.Navigate(intent.Target);
|
Cv?.Navigate(intent.Target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -778,6 +806,8 @@ public partial class MainWindow : Window
|
||||||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.D) { ToggleBoostDark(); e.Handled = true; }
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.D) { ToggleBoostDark(); e.Handled = true; }
|
||||||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.S) { _ = TakeScreenshot(); e.Handled = true; }
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.S) { _ = TakeScreenshot(); e.Handled = true; }
|
||||||
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N) { OpenInPrivateTab(); e.Handled = true; }
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N) { OpenInPrivateTab(); e.Handled = true; }
|
||||||
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.M) { ToggleCompactMode(); e.Handled = true; } // T3-13
|
||||||
|
else if (mods == (ModifierKeys.Control | ModifierKeys.Alt) && e.Key == Key.N) { ToggleNotes(); e.Handled = true; } // T3-12
|
||||||
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.V) { ToggleVerticalTabs(); e.Handled = true; }
|
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.V) { ToggleVerticalTabs(); e.Handled = true; }
|
||||||
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.H) { ToggleSplit(); e.Handled = true; }
|
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.H) { ToggleSplit(); e.Handled = true; }
|
||||||
else if (mods == ModifierKeys.Control && e.Key == Key.U) { ViewSource(); e.Handled = true; }
|
else if (mods == ModifierKeys.Control && e.Key == Key.U) { ViewSource(); e.Handled = true; }
|
||||||
|
|
@ -1243,6 +1273,20 @@ public partial class MainWindow : Window
|
||||||
_palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; });
|
_palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; });
|
||||||
_palette.Register("재생목록 재생", "playlist play start", OpenPlayer);
|
_palette.Register("재생목록 재생", "playlist play start", OpenPlayer);
|
||||||
_palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing);
|
_palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing);
|
||||||
|
// ---- T3 잔여(데스크톱) ----
|
||||||
|
_palette.Register("이 사이트 흑백", "grayscale filter page action 흑백", () => ApplyPageFilter(PageFilter.Grayscale)); // T3-6
|
||||||
|
_palette.Register("이 사이트 세피아", "sepia filter page action 세피아", () => ApplyPageFilter(PageFilter.Sepia));
|
||||||
|
_palette.Register("페이지 액션 끄기", "filter off reset", () => ApplyPageFilter(PageFilter.None));
|
||||||
|
_palette.Register("노트 패널", "notes note 메모", ToggleNotes); // T3-12
|
||||||
|
_palette.Register("노트: 현재 페이지 추가", "note add", () => NotesAddPage_Click(this, new RoutedEventArgs()));
|
||||||
|
_palette.Register("컴팩트 모드 토글", "compact chrome hide ui", ToggleCompactMode); // T3-13
|
||||||
|
_palette.RegisterDynamic(() => _workspaces // T3-1: 사이트→워크스페이스 규칙
|
||||||
|
.Select(w => new PaletteCommand($"라우팅 규칙: 이 사이트 → '{w.Name}'", "routing rule workspace",
|
||||||
|
() => AddRoutingRuleForCurrentSite(w.Name))).ToList());
|
||||||
|
_palette.RegisterDynamic(() => _tabArchive.Search("") // T3-2: 아카이브 복구
|
||||||
|
.Take(8)
|
||||||
|
.Select(a => new PaletteCommand($"아카이브에서 열기: {a.Title}", $"archive reopen {a.Url}",
|
||||||
|
() => { var t = _tabs.NewTab(); Cv2Navigate(t, a.Url); })).ToList());
|
||||||
_palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess());
|
_palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess());
|
||||||
_palette.RegisterSeparator();
|
_palette.RegisterSeparator();
|
||||||
_palette.RegisterDynamic(() => _tabs.Tabs
|
_palette.RegisterDynamic(() => _tabs.Tabs
|
||||||
|
|
@ -1304,6 +1348,112 @@ public partial class MainWindow : Window
|
||||||
Log(!cur.ForceDark ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐");
|
Log(!cur.ForceDark ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- T3-6 페이지 액션(흑백/세피아 — Boost 통합) ----
|
||||||
|
internal void ApplyPageFilter(PageFilter filter)
|
||||||
|
{
|
||||||
|
var url = Cv?.Source;
|
||||||
|
if (string.IsNullOrEmpty(url)) { Log("페이지가 없습니다"); return; }
|
||||||
|
var cur = _boostStore.Get(url);
|
||||||
|
_boostStore.Set(url, cur.ForceDark, cur.CustomCss, filter);
|
||||||
|
_views?.ApplyBoostNow(_tabs.ActiveTab!);
|
||||||
|
Log(filter switch
|
||||||
|
{
|
||||||
|
PageFilter.Grayscale => "이 사이트 흑백 적용",
|
||||||
|
PageFilter.Sepia => "이 사이트 세피아 적용",
|
||||||
|
_ => "페이지 액션 해제",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- T3-1 도메인 라우팅 규칙 ----
|
||||||
|
private void AddRoutingRuleForCurrentSite(string workspaceName)
|
||||||
|
{
|
||||||
|
var url = Cv?.Source;
|
||||||
|
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var u))
|
||||||
|
{ Log("페이지가 없습니다"); return; }
|
||||||
|
var host = u.Host;
|
||||||
|
if (host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) host = host[4..];
|
||||||
|
_routingRules.Add(host, workspaceName);
|
||||||
|
Log($"라우팅 규칙 추가: {host} → '{workspaceName}' (다음 이동부터 자동 전환)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- T3-2 아카이브 복구 보조 ----
|
||||||
|
private static void Cv2Navigate(TabItem tab, string url)
|
||||||
|
{
|
||||||
|
try { tab.View?.CoreWebView2?.Navigate(url); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- T3-12 노트 패널 ----
|
||||||
|
internal void ToggleNotes()
|
||||||
|
{
|
||||||
|
RefreshNotesList();
|
||||||
|
NotesPopup.IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshNotesList()
|
||||||
|
{
|
||||||
|
NotesList.ItemsSource = _notes.List()
|
||||||
|
.Select(n => $"[{n.CreatedAt:MM-dd HH:mm}] {n.Title} — {n.Body}")
|
||||||
|
.ToList();
|
||||||
|
NotesList.DisplayMemberPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void NotesAddPage_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var url = Cv?.Source ?? "";
|
||||||
|
var title = _tabs.ActiveTab?.Title ?? url;
|
||||||
|
_notes.Add(title, url);
|
||||||
|
Log("노트에 페이지 추가됨");
|
||||||
|
RefreshNotesList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotesDelete_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var idx = NotesList.SelectedIndex;
|
||||||
|
var list = _notes.List();
|
||||||
|
if (idx >= 0 && idx < list.Count)
|
||||||
|
{
|
||||||
|
_notes.Delete(list[idx].Id);
|
||||||
|
RefreshNotesList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- T3-13 컴팩트 모드(크롬 숨김 + 상단 엣지 호버 복원) ----
|
||||||
|
internal void ToggleCompactMode()
|
||||||
|
{
|
||||||
|
_compactMode = !_compactMode;
|
||||||
|
_chromeRevealed = false;
|
||||||
|
ApplyChromeVisibility();
|
||||||
|
Log(_compactMode ? "컴팩트 모드: 크롤 숨김 (상단 가장자리에 마우스를 올리면 복원)" : "컴팩트 모드 해제");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyChromeVisibility()
|
||||||
|
{
|
||||||
|
var chromeRows = new[] { 0, 1, 2 }; // 툴바/북마크바/탭스트립
|
||||||
|
foreach (var i in chromeRows)
|
||||||
|
MainGrid.RowDefinitions[i].Height = (_compactMode && !_chromeRevealed) ? new GridLength(0) : GridLength.Auto;
|
||||||
|
if (!_compactMode) _chromeRevealed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (_compactMode)
|
||||||
|
{
|
||||||
|
var p = e.GetPosition(this);
|
||||||
|
var wantReveal = p.Y <= 4; // 상단 엣지 4px
|
||||||
|
if (wantReveal != _chromeRevealed)
|
||||||
|
{
|
||||||
|
_chromeRevealed = wantReveal;
|
||||||
|
ApplyChromeVisibility();
|
||||||
|
}
|
||||||
|
else if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김
|
||||||
|
{
|
||||||
|
_chromeRevealed = false;
|
||||||
|
ApplyChromeVisibility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base.OnPreviewMouseMove(e);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- T2-9 스크린샷 ----
|
// ---- T2-9 스크린샷 ----
|
||||||
private async Task TakeScreenshot()
|
private async Task TakeScreenshot()
|
||||||
{
|
{
|
||||||
|
|
@ -1490,7 +1640,7 @@ public partial class MainWindow : Window
|
||||||
if (cv == null) return;
|
if (cv == null) return;
|
||||||
var tiles = _bookmarks.Take(18).Select(b =>
|
var tiles = _bookmarks.Take(18).Select(b =>
|
||||||
$"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>🌐</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList();
|
$"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>🌐</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList();
|
||||||
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#8a8aa0;font-size:12px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
|
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI Variable Text','Segoe UI','Malgun Gothic',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#9aa0b4;font-size:12.5px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
|
||||||
cv.NavigateToString(dial);
|
cv.NavigateToString(dial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1575,6 +1725,7 @@ public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
ExitSplit();
|
ExitSplit();
|
||||||
_tabs.SwitchWorkspace(ws);
|
_tabs.SwitchWorkspace(ws);
|
||||||
|
_currentWorkspaceName = ws.Name; // T3-1 라우팅 기준
|
||||||
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
|
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,11 @@
|
||||||
<SolidColorBrush x:Key="GlassBg" Color="#1A1A24"/>
|
<SolidColorBrush x:Key="GlassBg" Color="#1A1A24"/>
|
||||||
<SolidColorBrush x:Key="CardBg" Color="#1C1C26"/>
|
<SolidColorBrush x:Key="CardBg" Color="#1C1C26"/>
|
||||||
<SolidColorBrush x:Key="CardBgHover" Color="#242430"/>
|
<SolidColorBrush x:Key="CardBgHover" Color="#242430"/>
|
||||||
<SolidColorBrush x:Key="BorderBrush" Color="#2C2C3A"/>
|
<SolidColorBrush x:Key="BorderBrush" Color="#343442"/>
|
||||||
<SolidColorBrush x:Key="BorderSubtle" Color="#20202B"/>
|
<SolidColorBrush x:Key="BorderSubtle" Color="#20202B"/>
|
||||||
<SolidColorBrush x:Key="TextPrimary" Color="#F2F2F6"/>
|
<SolidColorBrush x:Key="TextPrimary" Color="#F2F2F6"/>
|
||||||
<SolidColorBrush x:Key="TextSecondary" Color="#9B9BAC"/>
|
<SolidColorBrush x:Key="TextSecondary" Color="#A9B0C5"/>
|
||||||
<SolidColorBrush x:Key="TextMuted" Color="#62626E"/>
|
<SolidColorBrush x:Key="TextMuted" Color="#7C8296"/>
|
||||||
<SolidColorBrush x:Key="TrackBrush" Color="#262631"/>
|
<SolidColorBrush x:Key="TrackBrush" Color="#262631"/>
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399"/>
|
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399"/>
|
||||||
<SolidColorBrush x:Key="ErrorBrush" Color="#F87171"/>
|
<SolidColorBrush x:Key="ErrorBrush" Color="#F87171"/>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
<SolidColorBrush x:Key="BorderBrush" Color="#D5D9E1"/>
|
<SolidColorBrush x:Key="BorderBrush" Color="#D5D9E1"/>
|
||||||
<SolidColorBrush x:Key="BorderSubtle" Color="#E3E6EC"/>
|
<SolidColorBrush x:Key="BorderSubtle" Color="#E3E6EC"/>
|
||||||
<SolidColorBrush x:Key="TextPrimary" Color="#1B1C24"/>
|
<SolidColorBrush x:Key="TextPrimary" Color="#1B1C24"/>
|
||||||
<SolidColorBrush x:Key="TextSecondary" Color="#565A6A"/>
|
<SolidColorBrush x:Key="TextSecondary" Color="#4A4E5E"/>
|
||||||
<SolidColorBrush x:Key="TextMuted" Color="#8A8E9C"/>
|
<SolidColorBrush x:Key="TextMuted" Color="#767B8C"/>
|
||||||
<SolidColorBrush x:Key="TrackBrush" Color="#DBDFE6"/>
|
<SolidColorBrush x:Key="TrackBrush" Color="#DBDFE6"/>
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#15A34A"/>
|
<SolidColorBrush x:Key="SuccessBrush" Color="#15A34A"/>
|
||||||
<SolidColorBrush x:Key="ErrorBrush" Color="#DC2626"/>
|
<SolidColorBrush x:Key="ErrorBrush" Color="#DC2626"/>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
<!-- 폰트 (테마 무관) -->
|
<!-- 폰트 (테마 무관) — 한국어 폴백 포함(맑은 고딕) -->
|
||||||
<FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI</FontFamily>
|
<FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI, Malgun Gothic</FontFamily>
|
||||||
<FontFamily x:Key="IconFont">Segoe Fluent Icons</FontFamily>
|
<FontFamily x:Key="IconFont">Segoe Fluent Icons</FontFamily>
|
||||||
|
|
||||||
|
<!-- 암시 스타일: 모든 텍스트가 UI 폰트로 통일(Foreground 는 상속 존중) -->
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ToolTip">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
|
||||||
|
<Setter Property="Background" Value="{StaticResource CardBg}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="Padding" Value="10,6"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ===== Primary 버튼 ===== -->
|
<!-- ===== Primary 버튼 ===== -->
|
||||||
<Style x:Key="PrimaryButton" TargetType="Button">
|
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
|
||||||
|
|
|
||||||
63
VideoDownloader.Browser/NotesStore.cs
Normal file
63
VideoDownloader.Browser/NotesStore.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Browser;
|
||||||
|
|
||||||
|
public sealed record NoteItem(string Id, string Title, string Body, DateTime CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>T3-12: 노트 패널 저장소 — 마크다운 텍스트 노트 추가·검색·삭제, JSON 영속.</summary>
|
||||||
|
public sealed class NotesStore
|
||||||
|
{
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private List<NoteItem> _items;
|
||||||
|
|
||||||
|
public NotesStore(string path)
|
||||||
|
{
|
||||||
|
_path = path;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
_items = JsonSerializer.Deserialize<List<NoteItem>>(File.ReadAllText(path)) ?? new();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_items ??= new List<NoteItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Add(string title, string body)
|
||||||
|
{
|
||||||
|
var note = new NoteItem(Guid.NewGuid().ToString("N"), title, body, DateTime.Now);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_items.Insert(0, note);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||||
|
File.WriteAllText(_path, JsonSerializer.Serialize(_items));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
return note.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<NoteItem> List()
|
||||||
|
{
|
||||||
|
lock (_gate) return _items.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<NoteItem> Search(string query)
|
||||||
|
{
|
||||||
|
var q = query?.Trim() ?? "";
|
||||||
|
return List().Where(n => q.Length == 0 ||
|
||||||
|
n.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
n.Body.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Delete(string id)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_items.RemoveAll(n => n.Id == id);
|
||||||
|
try { File.WriteAllText(_path, JsonSerializer.Serialize(_items)); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
VideoDownloader.Browser/TabArchive.cs
Normal file
63
VideoDownloader.Browser/TabArchive.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Browser;
|
||||||
|
|
||||||
|
public sealed record ArchivedTab(string Title, string Url, DateTime ClosedAt);
|
||||||
|
|
||||||
|
/// <summary>T3-2: 탭 아카이브 — 닫힘 이력 영속(최대 500, 같은 URL 연속은 제목 갱신), 검색·복구 지원.</summary>
|
||||||
|
public sealed class TabArchive
|
||||||
|
{
|
||||||
|
private const int Cap = 500;
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private List<ArchivedTab> _items;
|
||||||
|
|
||||||
|
public TabArchive(string path)
|
||||||
|
{
|
||||||
|
_path = path;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
_items = JsonSerializer.Deserialize<List<ArchivedTab>>(File.ReadAllText(path)) ?? new();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_items ??= new List<ArchivedTab>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(string title, string url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) return;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_items.RemoveAll(t => t.Url.Equals(url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
_items.Insert(0, new ArchivedTab(title, url, DateTime.Now));
|
||||||
|
if (_items.Count > Cap) _items.RemoveRange(Cap, _items.Count - Cap);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||||
|
File.WriteAllText(_path, JsonSerializer.Serialize(_items));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ArchivedTab> Search(string query)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var q = query?.Trim() ?? "";
|
||||||
|
return _items.Where(t => q.Length == 0 ||
|
||||||
|
t.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
t.Url.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(string url)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_items.RemoveAll(t => t.Url.Equals(url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
try { File.WriteAllText(_path, JsonSerializer.Serialize(_items)); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
VideoDownloader.Browser/WorkspaceRoutingRules.cs
Normal file
65
VideoDownloader.Browser/WorkspaceRoutingRules.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Browser;
|
||||||
|
|
||||||
|
/// <summary>T3-1: 도메인 → 워크스페이스 자동 라우팅 규칙(Vivaldi 규칙/Arc ATC).
|
||||||
|
/// 주소창 이동 시 매칭되면 해당 워크스페이스로 자동 전환한다. JSON 영속.</summary>
|
||||||
|
public sealed class WorkspaceRoutingRules
|
||||||
|
{
|
||||||
|
private readonly string _path;
|
||||||
|
private Dictionary<string, string> _map = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public WorkspaceRoutingRules(string path)
|
||||||
|
{
|
||||||
|
_path = path;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
_map = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path)) ?? new();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_map ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> Rules => _map;
|
||||||
|
|
||||||
|
public void Add(string domain, string workspaceName)
|
||||||
|
{
|
||||||
|
var d = domain.Trim().TrimStart('.', 'w', 'w', 'w', '.');
|
||||||
|
_map[d] = workspaceName;
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(string domain)
|
||||||
|
{
|
||||||
|
_map.Remove(domain.Trim());
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>URL 이 매칭되는 워크스페이스명(없으면 null). www/서브도메인 허용.</summary>
|
||||||
|
public string? Match(string? url)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) return null;
|
||||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var u) ||
|
||||||
|
(u.Scheme != Uri.UriSchemeHttp && u.Scheme != Uri.UriSchemeHttps)) return null;
|
||||||
|
var host = u.Host;
|
||||||
|
if (host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) host = host[4..];
|
||||||
|
foreach (var (domain, ws) in _map)
|
||||||
|
{
|
||||||
|
if (host.Equals(domain, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
host.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||||
|
File.WriteAllText(_path, JsonSerializer.Serialize(_map));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
130
VideoDownloader.Tests/Browser/DesktopRemainderTests.cs
Normal file
130
VideoDownloader.Tests/Browser/DesktopRemainderTests.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
using VideoDownloader.App.Browser.Features;
|
||||||
|
using VideoDownloader.Browser;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Tests.Browser;
|
||||||
|
|
||||||
|
/// <summary>데스크톱 T3 잔여 — 워크스페이스 라우팅(T3-1), 탭 아카이브(T3-2), 노트(T3-12).</summary>
|
||||||
|
public class DesktopRemainderTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"vd_t3_{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
// ── T3-1: 도메인 → 워크스페이스 자동 라우팅 규칙 ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 라우팅_규칙_도메인_매칭()
|
||||||
|
{
|
||||||
|
var rules = new WorkspaceRoutingRules(Path.Combine(_dir, "routing.json"));
|
||||||
|
rules.Add("youtube.com", "영상");
|
||||||
|
Assert.Equal("영상", rules.Match("https://www.youtube.com/watch?v=1")); // www 정규화
|
||||||
|
Assert.Equal("영상", rules.Match("https://m.youtube.com/watch?v=1")); // 서브도메인
|
||||||
|
Assert.Null(rules.Match("https://example.com")); // 무관 도메인
|
||||||
|
Assert.Null(rules.Match("about:blank")); // 비 HTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 라우팅_규칙_영속_및_제거()
|
||||||
|
{
|
||||||
|
var p = Path.Combine(_dir, "routing.json");
|
||||||
|
new WorkspaceRoutingRules(p).Add("x.com", "SNS");
|
||||||
|
var reloaded = new WorkspaceRoutingRules(p); // 재시작
|
||||||
|
Assert.Equal("SNS", reloaded.Match("https://x.com/home"));
|
||||||
|
reloaded.Remove("x.com");
|
||||||
|
Assert.Null(reloaded.Match("https://x.com/home"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T3-2: 탭 아카이브(닫힘 이력 영속·검색·중복 제거) ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 아카이브_기록_검색_중복제거()
|
||||||
|
{
|
||||||
|
var arc = new TabArchive(Path.Combine(_dir, "archive.json"));
|
||||||
|
arc.Add("영상1", "https://a.com/1");
|
||||||
|
arc.Add("영상1 다시", "https://a.com/1"); // 같은 URL 연속 → 제목만 갱신
|
||||||
|
arc.Add("문서", "https://b.com/doc");
|
||||||
|
Assert.Single(arc.Search("a.com/1"));
|
||||||
|
Assert.Equal("영상1 다시", arc.Search("a.com/1")[0].Title);
|
||||||
|
Assert.Single(arc.Search("문서"));
|
||||||
|
Assert.Equal(2, arc.Search("").Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 아카이브_영속_삭제()
|
||||||
|
{
|
||||||
|
var p = Path.Combine(_dir, "archive.json");
|
||||||
|
var arc = new TabArchive(p);
|
||||||
|
arc.Add("t", "https://x.com");
|
||||||
|
var re = new TabArchive(p);
|
||||||
|
Assert.Single(re.Search("x.com"));
|
||||||
|
re.Remove("https://x.com");
|
||||||
|
Assert.Empty(new TabArchive(p).Search("x.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 아카이브_최대_500_제한()
|
||||||
|
{
|
||||||
|
var arc = new TabArchive(Path.Combine(_dir, "archive.json"));
|
||||||
|
for (var i = 0; i < 520; i++) arc.Add($"t{i}", $"https://x.com/{i}");
|
||||||
|
Assert.Equal(500, arc.Search("").Count);
|
||||||
|
Assert.Equal("t519", arc.Search("")[0].Title); // 최신이 앞
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── T3-12: 노트 저장소 ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 노트_추가_목록_삭제_영속()
|
||||||
|
{
|
||||||
|
var p = Path.Combine(_dir, "notes.json");
|
||||||
|
var notes = new NotesStore(p);
|
||||||
|
var id = notes.Add("제목", "본문 내용");
|
||||||
|
Assert.Single(notes.List());
|
||||||
|
Assert.Equal(("제목", "본문 내용"), (notes.List()[0].Title, notes.List()[0].Body));
|
||||||
|
|
||||||
|
var re = new NotesStore(p); // 재시작
|
||||||
|
Assert.Single(re.List());
|
||||||
|
re.Delete(id);
|
||||||
|
Assert.Empty(new NotesStore(p).List());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 노트_검색()
|
||||||
|
{
|
||||||
|
var notes = new NotesStore(Path.Combine(_dir, "notes.json"));
|
||||||
|
notes.Add("유튜브 모음", "링크들");
|
||||||
|
notes.Add("회의록", "유튜브 언급됨");
|
||||||
|
Assert.Equal(2, notes.Search("유튜브").Count);
|
||||||
|
Assert.Single(notes.Search("회의"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── T3-6: 페이지 액션 — 흑백/세피아 CSS 필터(Vivaldi Page Actions, Boost 통합) ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 페이지액션_흑백_CSS_생성()
|
||||||
|
{
|
||||||
|
var js = VideoDownloader.App.Browser.Features.BoostCssBuilder.BuildScript(
|
||||||
|
"https://x.com", new VideoDownloader.App.Browser.Features.BoostSettings(false, "", PageFilter.Grayscale));
|
||||||
|
Assert.Contains("grayscale(1)", js);
|
||||||
|
Assert.DoesNotContain("invert(1)", js);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 페이지액션_세피아_CSS_생성()
|
||||||
|
{
|
||||||
|
var js = VideoDownloader.App.Browser.Features.BoostCssBuilder.BuildScript(
|
||||||
|
"https://x.com", new VideoDownloader.App.Browser.Features.BoostSettings(false, "", PageFilter.Sepia));
|
||||||
|
Assert.Contains("sepia(", js);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void 페이지액션_미적용_시_빈_스크립트()
|
||||||
|
{
|
||||||
|
var js = VideoDownloader.App.Browser.Features.BoostCssBuilder.BuildScript(
|
||||||
|
"https://x.com", new VideoDownloader.App.Browser.Features.BoostSettings(false, "", PageFilter.None));
|
||||||
|
Assert.Equal("", js);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_dir, true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,9 +68,53 @@ public class MainWindowSmokeE2ETests
|
||||||
window.ToggleFullscreen();
|
window.ToggleFullscreen();
|
||||||
var toolbarBack = window.ToolbarRow.Visibility == Visibility.Visible;
|
var toolbarBack = window.ToolbarRow.Visibility == Visibility.Visible;
|
||||||
|
|
||||||
|
// ── T3-13 컴팩트 모드: 크롬 행 높이 0 → 해제 시 복원 ──
|
||||||
|
window.ToggleCompactMode();
|
||||||
|
var compactRowZero = window.MainGrid.RowDefinitions[0].Height.Value == 0;
|
||||||
|
window.ToggleCompactMode();
|
||||||
|
var compactRowBack = window.MainGrid.RowDefinitions[0].Height.IsAuto;
|
||||||
|
|
||||||
|
// ── T3-12 노트: 패널 열림 + 현재 페이지 추가 → 목록 1건 ──
|
||||||
|
window.ToggleNotes();
|
||||||
|
var notesOpen = window.NotesPopup.IsOpen;
|
||||||
|
window.NotesAddPage_Click(window, new RoutedEventArgs());
|
||||||
|
var notesCount = window.Notes.List().Count;
|
||||||
|
window.NotesPopup.IsOpen = false;
|
||||||
|
|
||||||
|
// ── T3-2 아카이브: 위에서 닫았던 탭이 영속 아카이브에 기록 ──
|
||||||
|
// ── T3-2 아카이브: URL 있는 탭을 닫으면 영속 아카이브에 기록 ──
|
||||||
|
var tab3 = window.Tabs.NewTab();
|
||||||
|
await Wait.Until(() => Task.FromResult(tab3.Core), c => c != null, 30000);
|
||||||
|
tab3.Url = "https://e2e.test/archived-page"; // URL 확정 상태 시뮬레이션(실제는 SourceChanged 가 설정)
|
||||||
|
window.Tabs.CloseTab(tab3);
|
||||||
|
await Task.Delay(800); // 진행 중이던 초기 네비게이션 이벤트가 dispose 이후 터지지 않게 여유
|
||||||
|
var archivedCount = window.Archive.Search("").Count;
|
||||||
|
var archivedHasUrl = window.Archive.Search("").Any(a => a.Url.Contains("e2e.test"));
|
||||||
|
|
||||||
|
// ── T3-6 페이지 액션: 실제 HTTP 문서에서 흑백 적용 → 부스트 저장 ──
|
||||||
|
var port = Random.Shared.Next(21000, 59000);
|
||||||
|
using var server = new System.Net.HttpListener();
|
||||||
|
server.Prefixes.Add($"http://127.0.0.1:{port}/");
|
||||||
|
server.Start();
|
||||||
|
var serveTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var ctx = await server.GetContextAsync();
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes("<html><body><p>page-action-e2e</p></body></html>");
|
||||||
|
await ctx.Response.OutputStream.WriteAsync(bytes);
|
||||||
|
ctx.Response.Close();
|
||||||
|
});
|
||||||
|
window.Tabs.Activate(first);
|
||||||
|
cv1.Navigate($"http://127.0.0.1:{port}/");
|
||||||
|
await Wait.Until(() => Task.FromResult(cv1.Source ?? ""), s => s.Contains("127.0.0.1"), 15000);
|
||||||
|
await serveTask.WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
window.ApplyPageFilter(VideoDownloader.App.Browser.Features.PageFilter.Grayscale);
|
||||||
|
var filterSaved = window.BoostStore.Get($"http://127.0.0.1:{port}/").Filter;
|
||||||
|
server.Stop();
|
||||||
|
|
||||||
window.Close();
|
window.Close();
|
||||||
return (init: true, switched, countAfterClose, reopened: reopened != null, countAfterReopen,
|
return (init: true, switched, countAfterClose, reopened: reopened != null, countAfterReopen,
|
||||||
findVisible, findCollapsed, toolbarHidden, toolbarBack);
|
findVisible, findCollapsed, toolbarHidden, toolbarBack,
|
||||||
|
compactRowZero, compactRowBack, notesOpen, notesCount, archivedCount, archivedHasUrl, filterSaved);
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(ok.init, "WebView2 초기화 실패");
|
Assert.True(ok.init, "WebView2 초기화 실패");
|
||||||
|
|
@ -82,6 +126,12 @@ public class MainWindowSmokeE2ETests
|
||||||
Assert.True(ok.findCollapsed, "검색 바 미닫힘");
|
Assert.True(ok.findCollapsed, "검색 바 미닫힘");
|
||||||
Assert.True(ok.toolbarHidden, "전체화면에서 크롬 미숨김");
|
Assert.True(ok.toolbarHidden, "전체화면에서 크롬 미숨김");
|
||||||
Assert.True(ok.toolbarBack, "전체화면 복원 실패");
|
Assert.True(ok.toolbarBack, "전체화면 복원 실패");
|
||||||
|
Assert.True(ok.compactRowZero, "컴팩트 모드에서 크롬 행이 숨겨지지 않음");
|
||||||
|
Assert.True(ok.compactRowBack, "컴팩트 모드 해제 후 크롬 행 미복원");
|
||||||
|
Assert.True(ok.notesOpen, "노트 패널 미열림");
|
||||||
|
Assert.True(ok.notesCount == 1, $"노트 추가 실패: {ok.notesCount}");
|
||||||
|
Assert.True(ok.archivedCount >= 1 && ok.archivedHasUrl, $"탭 아카이브 미기록: {ok.archivedCount}");
|
||||||
|
Assert.Equal(VideoDownloader.App.Browser.Features.PageFilter.Grayscale, ok.filterSaved);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue