Files
AX-Copilot/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs
lacvet 27bd8de83a [Phase47] 대형 파일 분할 리팩터링 3차 — 8개 신규 파셜 파일 생성
## 분할 대상 및 결과

### ChatWindow.ResponseHandling.cs (741줄 → 269줄)
- ChatWindow.StreamingUI.cs (303줄, 신규): CreateStreamingContainer, FinalizeStreamingContainer, ParseSuggestionChips, FormatTokenCount, EstimateTokenCount, StopGeneration
- ChatWindow.ConversationExport.cs (188줄, 신규): ForkConversation, OpenCommandPalette, ExecuteCommand, ExportConversation, ExportToHtml

### ChatWindow.PreviewAndFiles.cs (709줄 → ~340줄)
- ChatWindow.PreviewPopup.cs (~230줄, 신규): ShowPreviewTabContextMenu, OpenPreviewPopupWindow, _previewTabPopup 필드

### HelpDetailWindow.xaml.cs (673줄 → 254줄)
- HelpDetailWindow.Shortcuts.cs (168줄, 신규): BuildShortcutItems() 정적 메서드 (단축키 항목 160개+ 생성)
- HelpDetailWindow.Navigation.cs (266줄, 신규): 테마 프로퍼티, BuildTopMenu/SwitchTopMenu, BuildCategoryBar, NavigateToPage, 이벤트 핸들러
- partial class 전환: `public partial class HelpDetailWindow : Window`

### SkillService.cs (661줄 → 386줄)
- SkillService.Import.cs (203줄, 신규): ExportSkill, ImportSkills, MapToolNames — 가져오기/내보내기 섹션
- SkillDefinition.cs (81줄, 신규): SkillDefinition 클래스 독립 파일로 분리 (별도 최상위 클래스)
- partial class 전환: `public static partial class SkillService`

## NEXT_ROADMAP.md Phase 46 완료 항목 추가

## 빌드 결과: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 21:02:53 +09:00

463 lines
17 KiB
C#

using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
public partial class ChatWindow
{
// ─── 미리보기 패널 (탭 기반) ─────────────────────────────────────────────
private static readonly HashSet<string> _previewableExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".md", ".txt", ".csv", ".json", ".xml", ".log",
};
/// <summary>열려 있는 프리뷰 탭 목록 (파일 경로 기준).</summary>
private readonly List<string> _previewTabs = new();
private string? _activePreviewTab;
private void TryShowPreview(string filePath)
{
if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath))
return;
// 별도 커스텀 창으로 미리보기 (WebView2 HWND airspace 문제 근본 해결)
PreviewWindow.ShowPreview(filePath, _selectedMood);
}
private void ShowPreviewPanel(string filePath)
{
// 탭에 없으면 추가
if (!_previewTabs.Contains(filePath, StringComparer.OrdinalIgnoreCase))
_previewTabs.Add(filePath);
_activePreviewTab = filePath;
// 패널 열기
if (PreviewColumn.Width.Value < 100)
{
PreviewColumn.Width = new GridLength(420);
SplitterColumn.Width = new GridLength(5);
}
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
BtnPreviewToggle.Visibility = Visibility.Visible;
RebuildPreviewTabs();
LoadPreviewContent(filePath);
}
/// <summary>탭 바 UI를 다시 구성합니다.</summary>
private void RebuildPreviewTabs()
{
PreviewTabPanel.Children.Clear();
var accentBrush = ThemeResourceHelper.Accent(this);
var secondaryText = ThemeResourceHelper.Secondary(this);
var primaryText = ThemeResourceHelper.Primary(this);
var borderBrush = ThemeResourceHelper.Border(this);
foreach (var tabPath in _previewTabs)
{
var fileName = System.IO.Path.GetFileName(tabPath);
var isActive = string.Equals(tabPath, _activePreviewTab, StringComparison.OrdinalIgnoreCase);
var tabBorder = new Border
{
Background = isActive
? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF))
: Brushes.Transparent,
BorderBrush = isActive ? accentBrush : Brushes.Transparent,
BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0),
Padding = new Thickness(8, 6, 4, 6),
Cursor = Cursors.Hand,
MaxWidth = _previewTabs.Count <= 3 ? 200 : (_previewTabs.Count <= 5 ? 140 : 100),
};
var tabContent = new StackPanel { Orientation = Orientation.Horizontal };
// 파일명
tabContent.Children.Add(new TextBlock
{
Text = fileName,
FontSize = 11,
Foreground = isActive ? primaryText : secondaryText,
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = tabBorder.MaxWidth - 30,
ToolTip = tabPath,
});
// 닫기 버튼 (x) — 활성 탭은 항상 표시, 비활성 탭은 호버 시에만 표시
var closeFg = isActive ? primaryText : secondaryText;
var closeBtnText = new TextBlock
{
Text = "\uE711",
FontFamily = ThemeResourceHelper.SegoeMdl2,
FontSize = 10,
Foreground = closeFg,
VerticalAlignment = VerticalAlignment.Center,
};
var closeBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(3),
Padding = new Thickness(3, 2, 3, 2),
Margin = new Thickness(5, 0, 0, 0),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
// 비활성 탭은 초기에 숨김, 활성 탭은 항상 표시
Visibility = isActive ? Visibility.Visible : Visibility.Hidden,
Child = closeBtnText,
};
var closePath = tabPath;
closeBtn.MouseEnter += (s, _) =>
{
if (s is Border b)
{
b.Background = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x50, 0x50));
if (b.Child is TextBlock tb) tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x60, 0x60));
}
};
closeBtn.MouseLeave += (s, _) =>
{
if (s is Border b)
{
b.Background = Brushes.Transparent;
if (b.Child is TextBlock tb) tb.Foreground = closeFg;
}
};
closeBtn.Tag = "close"; // 닫기 버튼 식별용
closeBtn.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true; // 부모 탭 클릭 이벤트 차단
ClosePreviewTab(closePath);
};
tabContent.Children.Add(closeBtn);
tabBorder.Child = tabContent;
// 탭 클릭 → 활성화 (MouseLeftButtonUp 사용: 닫기 버튼의 PreviewMouseLeftButtonDown보다 늦게 실행되어 충돌 방지)
var clickPath = tabPath;
tabBorder.MouseLeftButtonUp += (_, e) =>
{
if (e.Handled) return;
e.Handled = true;
_activePreviewTab = clickPath;
RebuildPreviewTabs();
LoadPreviewContent(clickPath);
};
// 우클릭 → 컨텍스트 메뉴
var ctxPath = tabPath;
tabBorder.MouseRightButtonUp += (_, e) =>
{
e.Handled = true;
ShowPreviewTabContextMenu(ctxPath);
};
// 더블클릭 → 별도 창에서 보기
var dblPath = tabPath;
tabBorder.MouseLeftButtonDown += (_, e) =>
{
if (e.Handled) return;
if (e.ClickCount == 2)
{
e.Handled = true;
OpenPreviewPopupWindow(dblPath);
}
};
// 호버 효과 — 비활성 탭에서 배경 강조 + 닫기 버튼 표시
var capturedIsActive = isActive;
var capturedCloseBtn = closeBtn;
tabBorder.MouseEnter += (s, _) =>
{
if (s is Border b && !capturedIsActive)
b.Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
// 비활성 탭도 호버 시 닫기 버튼 표시
if (!capturedIsActive)
capturedCloseBtn.Visibility = Visibility.Visible;
};
tabBorder.MouseLeave += (s, _) =>
{
if (s is Border b && !capturedIsActive)
b.Background = Brushes.Transparent;
// 비활성 탭 호버 해제 시 닫기 버튼 숨김
if (!capturedIsActive)
capturedCloseBtn.Visibility = Visibility.Hidden;
};
PreviewTabPanel.Children.Add(tabBorder);
// 탭 사이 구분선
if (tabPath != _previewTabs[^1])
{
PreviewTabPanel.Children.Add(new Border
{
Width = 1, Height = 14,
Background = borderBrush,
Margin = new Thickness(0, 4, 0, 4),
VerticalAlignment = VerticalAlignment.Center,
});
}
}
}
private void ClosePreviewTab(string filePath)
{
_previewTabs.Remove(filePath);
if (_previewTabs.Count == 0)
{
HidePreviewPanel();
return;
}
// 닫힌 탭이 활성 탭이면 마지막 탭으로 전환
if (string.Equals(filePath, _activePreviewTab, StringComparison.OrdinalIgnoreCase))
{
_activePreviewTab = _previewTabs[^1];
LoadPreviewContent(_activePreviewTab);
}
RebuildPreviewTabs();
}
private async void LoadPreviewContent(string filePath)
{
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
// 모든 콘텐츠 숨기기
PreviewWebView.Visibility = Visibility.Collapsed;
PreviewTextScroll.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
PreviewEmpty.Visibility = Visibility.Collapsed;
if (!System.IO.File.Exists(filePath))
{
PreviewEmpty.Text = "파일을 찾을 수 없습니다";
PreviewEmpty.Visibility = Visibility.Visible;
return;
}
try
{
switch (ext)
{
case ".html":
case ".htm":
await EnsureWebViewInitializedAsync();
PreviewWebView.Source = new Uri(filePath);
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".csv":
LoadCsvPreview(filePath);
PreviewDataGrid.Visibility = Visibility.Visible;
break;
case ".md":
await EnsureWebViewInitializedAsync();
var mdText = System.IO.File.ReadAllText(filePath);
if (mdText.Length > 50000) mdText = mdText[..50000];
var mdHtml = Services.Agent.TemplateService.RenderMarkdownToHtml(mdText, _selectedMood);
PreviewWebView.NavigateToString(mdHtml);
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".txt":
case ".json":
case ".xml":
case ".log":
var text = System.IO.File.ReadAllText(filePath);
if (text.Length > 50000) text = text[..50000] + "\n\n... (이후 생략)";
PreviewTextBlock.Text = text;
PreviewTextScroll.Visibility = Visibility.Visible;
break;
default:
PreviewEmpty.Text = "미리보기할 수 없는 파일 형식입니다";
PreviewEmpty.Visibility = Visibility.Visible;
break;
}
}
catch (Exception ex)
{
PreviewTextBlock.Text = $"미리보기 오류: {ex.Message}";
PreviewTextScroll.Visibility = Visibility.Visible;
}
}
private bool _webViewInitialized;
private static readonly string WebView2DataFolder =
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "WebView2");
private async Task EnsureWebViewInitializedAsync()
{
if (_webViewInitialized) return;
try
{
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
userDataFolder: WebView2DataFolder);
await PreviewWebView.EnsureCoreWebView2Async(env);
_webViewInitialized = true;
}
catch (Exception ex)
{
Services.LogService.Warn($"WebView2 초기화 실패: {ex.Message}");
}
}
private void LoadCsvPreview(string filePath)
{
try
{
var lines = System.IO.File.ReadAllLines(filePath);
if (lines.Length == 0) return;
var dt = new System.Data.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);
}
PreviewDataGrid.ItemsSource = dt.DefaultView;
}
catch (Exception ex)
{
PreviewTextBlock.Text = $"CSV 로드 오류: {ex.Message}";
PreviewTextScroll.Visibility = Visibility.Visible;
PreviewDataGrid.Visibility = Visibility.Collapsed;
}
}
private static string[] ParseCsvLine(string line)
{
var fields = new System.Collections.Generic.List<string>();
var current = new System.Text.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 HidePreviewPanel()
{
_previewTabs.Clear();
_activePreviewTab = null;
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
PreviewWebView.Visibility = Visibility.Collapsed;
PreviewTextScroll.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
try { if (_webViewInitialized) PreviewWebView.CoreWebView2?.NavigateToString("<html></html>"); } catch (Exception) { /* WebView 초기화 실패 */ }
}
/// <summary>프리뷰 탭 바 클릭 시 WebView2에서 포커스를 회수 (HWND airspace 문제 방지).</summary>
private void PreviewTabBar_PreviewMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
// WebView2가 포커스를 잡고 있으면 WPF 버튼 클릭이 무시될 수 있으므로 포커스를 강제 이동
if (PreviewWebView.IsFocused || PreviewWebView.IsKeyboardFocusWithin)
{
var border = sender as Border;
border?.Focus();
}
}
private void BtnClosePreview_Click(object sender, RoutedEventArgs e)
{
HidePreviewPanel();
BtnPreviewToggle.Visibility = Visibility.Collapsed;
}
private void BtnPreviewToggle_Click(object sender, RoutedEventArgs e)
{
if (PreviewPanel.Visibility == Visibility.Visible)
{
// 숨기기 (탭은 유지)
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
}
else if (_previewTabs.Count > 0)
{
// 다시 열기
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
PreviewColumn.Width = new GridLength(420);
SplitterColumn.Width = new GridLength(5);
RebuildPreviewTabs();
if (_activePreviewTab != null) LoadPreviewContent(_activePreviewTab);
}
}
private void BtnOpenExternal_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(_activePreviewTab) || !System.IO.File.Exists(_activePreviewTab))
return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = _activePreviewTab,
UseShellExecute = true,
});
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}");
}
}
}