using System; using System.Collections.Generic; using System.Data; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Interop; using System.Windows.Media; using Microsoft.Web.WebView2.Core; namespace AxCopilot.Views; /// 파일 미리보기 별도 창. WebView2 HWND airspace 문제를 근본적으로 회피합니다. public partial class PreviewWindow : Window { private static PreviewWindow? _instance; private readonly List _tabs = new(); private string? _activeTab; private bool _webViewInitialized; private string? _selectedMood; private static readonly string WebView2DataFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "WebView2_Preview"); private static readonly HashSet PreviewableExtensions = new(StringComparer.OrdinalIgnoreCase) { ".html", ".htm", ".md", ".csv", ".txt", ".json", ".xml", ".log", }; public PreviewWindow() { InitializeComponent(); Loaded += OnLoaded; SourceInitialized += OnSourceInitialized; KeyDown += (_, e) => { if (e.Key == Key.Escape) Close(); }; StateChanged += (_, _) => { MaxBtnIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE922"; }; Closed += (_, _) => _instance = null; } // ─── 상하좌우 리사이즈 (WindowStyle=None 대응) ───────────── [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); private const int WM_NCHITTEST = 0x0084; private const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14; private const int HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17; private void OnSourceInitialized(object? sender, EventArgs e) { var hwndSource = (HwndSource)PresentationSource.FromVisual(this); hwndSource?.AddHook(WndProc); } private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { if (msg == WM_NCHITTEST) { var pt = PointFromScreen(new Point( (short)(lParam.ToInt32() & 0xFFFF), (short)((lParam.ToInt32() >> 16) & 0xFFFF))); const double grip = 8; // 리사이즈 가능 영역 (px) var w = ActualWidth; var h = ActualHeight; if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; } if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; } if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; } if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; } if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; } if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; } if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; } if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; } } return IntPtr.Zero; } // ─── 싱글턴 팩토리 ────────────────────────────────────────── /// 파일을 미리보기 창에 표시합니다. 이미 열려 있으면 탭을 추가합니다. public static void ShowPreview(string filePath, string? mood = null) { if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) return; var ext = Path.GetExtension(filePath).ToLowerInvariant(); if (!PreviewableExtensions.Contains(ext)) return; Application.Current.Dispatcher.Invoke(() => { if (_instance == null || !_instance.IsLoaded) { _instance = new PreviewWindow(); // 부모 창의 테마 리소스 전달 var mainWindow = Application.Current.MainWindow; if (mainWindow != null) { foreach (var dict in mainWindow.Resources.MergedDictionaries) _instance.Resources.MergedDictionaries.Add(dict); } _instance._selectedMood = mood; _instance.Show(); } _instance.AddTab(filePath); _instance.Activate(); }); } /// 이미 열린 파일의 콘텐츠만 새로고침합니다. 활성 탭이 아닌 파일도 탭 목록에 있으면 활성 탭으로 전환 후 새로고침. public static void RefreshIfOpen(string filePath) { if (_instance == null || !_instance.IsLoaded) return; Application.Current.Dispatcher.Invoke(() => { // 현재 활성 탭이면 즉시 새로고침 if (_instance._activeTab != null && string.Equals(_instance._activeTab, filePath, StringComparison.OrdinalIgnoreCase)) { _instance.LoadContent(filePath); return; } // 탭 목록에 있으면 해당 탭으로 전환 후 새로고침 var existing = _instance._tabs.FirstOrDefault( t => string.Equals(t, filePath, StringComparison.OrdinalIgnoreCase)); if (existing != null) { _instance._activeTab = existing; _instance.LoadContent(existing); _instance.RebuildTabs(); } }); } /// 현재 미리보기 창이 열려 있는지 반환합니다. public static bool IsOpen => _instance != null && _instance.IsLoaded; // ─── 초기화 ───────────────────────────────────────────────── private async void OnLoaded(object sender, RoutedEventArgs e) { try { var env = await CoreWebView2Environment.CreateAsync( userDataFolder: WebView2DataFolder); await PreviewBrowser.EnsureCoreWebView2Async(env); _webViewInitialized = true; // 대기 중인 콘텐츠 로드 if (_activeTab != null) LoadContent(_activeTab); } catch (Exception ex) { Services.LogService.Warn($"PreviewWindow WebView2 초기화 실패: {ex.Message}"); } } // ─── 탭 관리 ──────────────────────────────────────────────── private void AddTab(string filePath) { if (!_tabs.Contains(filePath, StringComparer.OrdinalIgnoreCase)) _tabs.Add(filePath); _activeTab = filePath; RebuildTabs(); LoadContent(filePath); } private void CloseTab(string filePath) { _tabs.RemoveAll(t => string.Equals(t, filePath, StringComparison.OrdinalIgnoreCase)); if (_tabs.Count == 0) { Close(); return; } if (string.Equals(_activeTab, filePath, StringComparison.OrdinalIgnoreCase)) { _activeTab = _tabs[^1]; LoadContent(_activeTab); } RebuildTabs(); } private void RebuildTabs() { TabPanel.Children.Clear(); var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; foreach (var tabPath in _tabs) { var fileName = Path.GetFileName(tabPath); var isActive = string.Equals(tabPath, _activeTab, StringComparison.OrdinalIgnoreCase); var tabBorder = new Border { Background = Brushes.Transparent, BorderBrush = isActive ? accentBrush : Brushes.Transparent, BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0), Padding = new Thickness(10, 6, 6, 6), Cursor = Cursors.Hand, MaxWidth = _tabs.Count <= 3 ? 220 : (_tabs.Count <= 5 ? 160 : 110), }; var tabContent = new StackPanel { Orientation = Orientation.Horizontal }; tabContent.Children.Add(new TextBlock { Text = fileName, FontSize = 11.5, Foreground = isActive ? primaryText : secondaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, MaxWidth = tabBorder.MaxWidth - 36, ToolTip = tabPath, }); // 닫기 버튼 var closePath = tabPath; var closeBtn = new Border { Background = Brushes.Transparent, CornerRadius = new CornerRadius(3), Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(6, 0, 0, 0), Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center, Child = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, }, }; closeBtn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0x50, 0x50)); }; closeBtn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; closeBtn.MouseLeftButtonUp += (_, me) => { me.Handled = true; CloseTab(closePath); }; tabContent.Children.Add(closeBtn); tabBorder.Child = tabContent; // 탭 클릭 → 활성화 var clickPath = tabPath; tabBorder.MouseLeftButtonUp += (_, me) => { if (me.Handled) return; me.Handled = true; _activeTab = clickPath; RebuildTabs(); LoadContent(clickPath); }; // 호버 효과 tabBorder.MouseEnter += (s, _) => { if (s is Border b && !string.Equals(clickPath, _activeTab, StringComparison.OrdinalIgnoreCase)) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); }; tabBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; }; TabPanel.Children.Add(tabBorder); // 구분선 if (tabPath != _tabs[^1]) { TabPanel.Children.Add(new Border { Width = 1, Height = 14, Background = borderBrush, Margin = new Thickness(2, 0, 2, 0), VerticalAlignment = VerticalAlignment.Center, }); } } // 타이틀 업데이트 if (_activeTab != null) TitleText.Text = $"미리보기 — {Path.GetFileName(_activeTab)}"; } // ─── 콘텐츠 로드 ──────────────────────────────────────────── private async void LoadContent(string filePath) { var ext = Path.GetExtension(filePath).ToLowerInvariant(); PreviewBrowser.Visibility = Visibility.Collapsed; TextScroll.Visibility = Visibility.Collapsed; DataGridContent.Visibility = Visibility.Collapsed; EmptyMessage.Visibility = Visibility.Collapsed; if (!File.Exists(filePath)) { EmptyMessage.Text = "파일을 찾을 수 없습니다"; EmptyMessage.Visibility = Visibility.Visible; return; } try { switch (ext) { case ".html": case ".htm": if (!_webViewInitialized) return; // OnLoaded에서 재시도 PreviewBrowser.Source = new Uri(filePath); PreviewBrowser.Visibility = Visibility.Visible; break; case ".csv": LoadCsvContent(filePath); DataGridContent.Visibility = Visibility.Visible; break; case ".md": if (!_webViewInitialized) return; var mdText = File.ReadAllText(filePath); if (mdText.Length > 50000) mdText = mdText[..50000]; var mdHtml = Services.Agent.TemplateService.RenderMarkdownToHtml( mdText, _selectedMood ?? "modern"); PreviewBrowser.NavigateToString(mdHtml); PreviewBrowser.Visibility = Visibility.Visible; break; case ".txt": case ".json": case ".xml": case ".log": var text = File.ReadAllText(filePath); if (text.Length > 50000) text = text[..50000] + "\n\n... (이후 생략)"; TextContent.Text = text; TextScroll.Visibility = Visibility.Visible; break; default: EmptyMessage.Text = "미리보기할 수 없는 파일 형식입니다"; EmptyMessage.Visibility = Visibility.Visible; break; } } catch (Exception ex) { TextContent.Text = $"미리보기 오류: {ex.Message}"; TextScroll.Visibility = Visibility.Visible; } await System.Threading.Tasks.Task.CompletedTask; // async 경고 방지 } private void LoadCsvContent(string filePath) { var lines = File.ReadAllLines(filePath); if (lines.Length == 0) return; var dt = new DataTable(); var headers = ParseCsvLine(lines[0]); foreach (var h in headers) dt.Columns.Add(h); var maxRows = Math.Min(lines.Length, 501); for (int i = 1; i < maxRows; i++) { var vals = ParseCsvLine(lines[i]); var row = dt.NewRow(); for (int j = 0; j < Math.Min(vals.Length, headers.Length); j++) row[j] = vals[j]; dt.Rows.Add(row); } DataGridContent.ItemsSource = dt.DefaultView; } private static string[] ParseCsvLine(string line) { var fields = new List(); var current = new StringBuilder(); bool inQuotes = false; for (int i = 0; i < line.Length; i++) { char c = line[i]; if (inQuotes) { if (c == '"' && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; } else if (c == '"') inQuotes = false; else current.Append(c); } else { if (c == '"') inQuotes = true; else if (c == ',') { fields.Add(current.ToString()); current.Clear(); } else current.Append(c); } } fields.Add(current.ToString()); return fields.ToArray(); } // ─── 타이틀 바 ────────────────────────────────────────────── private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (e.ClickCount == 2) { ToggleMaximize(); return; } DragMove(); } private void OpenExternalBtn_Click(object sender, MouseButtonEventArgs e) { e.Handled = true; if (_activeTab == null || !File.Exists(_activeTab)) return; try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = _activeTab, UseShellExecute = true, }); } catch (Exception ex) { Services.LogService.Warn($"외부 프로그램 열기 실패: {ex.Message}"); } } private void MinBtn_Click(object sender, MouseButtonEventArgs e) { e.Handled = true; WindowState = WindowState.Minimized; } private void MaxBtn_Click(object sender, MouseButtonEventArgs e) { e.Handled = true; ToggleMaximize(); } private void CloseBtn_Click(object sender, MouseButtonEventArgs e) { e.Handled = true; Close(); } private void ToggleMaximize() { WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; } private void TitleBtn_Enter(object sender, MouseEventArgs e) { if (sender is Border b) b.Background = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); } private void CloseBtnEnter(object sender, MouseEventArgs e) { if (sender is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x44, 0xFF, 0x40, 0x40)); } private void TitleBtn_Leave(object sender, MouseEventArgs e) { if (sender is Border b) b.Background = Brushes.Transparent; } }