506 lines
18 KiB
C#
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;
|
|
}
|
|
}
|