Files
AX-Copilot-Codex/src/AxCopilot/Views/PreviewWindow.xaml.cs

506 lines
18 KiB
C#

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;
/// <summary>파일 미리보기 별도 창. WebView2 HWND airspace 문제를 근본적으로 회피합니다.</summary>
public partial class PreviewWindow : Window
{
private static PreviewWindow? _instance;
private readonly List<string> _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<string> 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;
}
// ─── 싱글턴 팩토리 ──────────────────────────────────────────
/// <summary>파일을 미리보기 창에 표시합니다. 이미 열려 있으면 탭을 추가합니다.</summary>
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();
});
}
/// <summary>이미 열린 파일의 콘텐츠만 새로고침합니다. 활성 탭이 아닌 파일도 탭 목록에 있으면 활성 탭으로 전환 후 새로고침.</summary>
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();
}
});
}
/// <summary>현재 미리보기 창이 열려 있는지 반환합니다.</summary>
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<string>();
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;
}
}