diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 903e090..6ce6018 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -173,6 +173,10 @@ public partial class App : System.Windows.Application // Phase L3-9: 뽀모도로 타이머 commandResolver.RegisterHandler(new PomoHandler()); + // ─── Phase L4 핸들러 ────────────────────────────────────────────────── + // Phase L4-1: 인라인 파일 탐색기 (Prefix=null, 경로 패턴 감지) + commandResolver.RegisterHandler(new FileBrowserHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Core/CommandResolver.cs b/src/AxCopilot/Core/CommandResolver.cs index 5fb0815..5b7f267 100644 --- a/src/AxCopilot/Core/CommandResolver.cs +++ b/src/AxCopilot/Core/CommandResolver.cs @@ -1,3 +1,4 @@ +using AxCopilot.Handlers; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; @@ -73,7 +74,15 @@ public class CommandResolver } } - // 2. Fuzzy 검색 폴백 + null-prefix 핸들러 병렬 실행 + // 2. 경로 쿼리 감지 → 파일 탐색기 단독 처리 (퍼지 검색 우선순위 우회) + if (FileBrowserHandler.IsPathQuery(input)) + { + var fb = _fuzzyHandlers.OfType().FirstOrDefault(); + if (fb != null) + return await fb.GetItemsAsync(input, ct); + } + + // 3. Fuzzy 검색 폴백 + null-prefix 핸들러 병렬 실행 var maxResults = _settings.Settings.Launcher.MaxResults; // Path 기반 중복 제거: 같은 경로의 항목이 여러 키워드로 매칭될 때 첫 번째만 표시 @@ -163,6 +172,13 @@ public class CommandResolver return; } + // 파일 탐색기 항목 실행 (FileBrowserEntry) + if (item.Data is FileBrowserEntry) + { + await ExecuteNullPrefixAsync(item, ct); + return; + } + // Fuzzy 결과 실행 (IndexEntry 기반) if (item.Data is IndexEntry entry) { diff --git a/src/AxCopilot/Handlers/FileBrowserHandler.cs b/src/AxCopilot/Handlers/FileBrowserHandler.cs new file mode 100644 index 0000000..4679c38 --- /dev/null +++ b/src/AxCopilot/Handlers/FileBrowserHandler.cs @@ -0,0 +1,185 @@ +using System.IO; +using System.Text.RegularExpressions; +using AxCopilot.SDK; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L4-1: 인라인 파일 탐색기 핸들러. +/// 입력이 파일시스템 경로처럼 보이면 (예: C:\, D:\Users\) 해당 폴더의 내용을 런처 목록으로 표시합니다. +/// → 키로 하위 폴더 진입, ← 키 또는 Backspace로 상위 폴더 이동. +/// prefix=null: 일반 쿼리 파이프라인에서 경로 감지 후 동작. +/// +public class FileBrowserHandler : IActionHandler +{ + public string? Prefix => null; // 경로 패턴 직접 감지 + + public PluginMetadata Metadata => new( + "FileBrowser", + "파일 탐색기 — 경로 입력 후 → 키로 탐색", + "1.0", + "AX"); + + // C:\, D:\path, \\server\share, ~\ 패턴 감지 + private static readonly Regex PathPattern = new( + @"^([A-Za-z]:\\|\\\\|~\\|~\/|\/)", + RegexOptions.Compiled); + + /// 쿼리가 파일시스템 경로처럼 보이는지 빠르게 판별합니다. + public static bool IsPathQuery(string query) + => !string.IsNullOrEmpty(query) && PathPattern.IsMatch(query.Trim()); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = ExpandPath(query.Trim()); + + // 경로가 아닌 쿼리는 빈 결과 반환 (다른 핸들러가 처리) + if (!IsPathQuery(query.Trim())) + return Task.FromResult>(Array.Empty()); + + // 입력이 존재하는 디렉터리이면 그 내용 표시 + if (Directory.Exists(q)) + return Task.FromResult(ListDirectory(q)); + + // 부분 경로: 마지막 세그먼트를 필터로 사용 + var parent = Path.GetDirectoryName(q); + var filter = Path.GetFileName(q).ToLowerInvariant(); + + if (parent != null && Directory.Exists(parent)) + return Task.FromResult(ListDirectory(parent, filter)); + + return Task.FromResult>(new[] + { + new LauncherItem("경로를 찾을 수 없습니다", q, null, null, Symbol: Symbols.Error) + }); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is FileBrowserEntry { IsFolder: true } dir) + { + // 폴더: 탐색기로 열기 + System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo("explorer.exe", dir.Path) + { UseShellExecute = true }); + } + else if (item.Data is FileBrowserEntry { IsFolder: false } file) + { + // 파일: 기본 앱으로 열기 + System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo(file.Path) + { UseShellExecute = true }); + } + return Task.CompletedTask; + } + + // ─── 디렉터리 내용 나열 ───────────────────────────────────────────────────── + + private static IEnumerable ListDirectory(string dir, string filter = "") + { + var items = new List(); + + // 상위 폴더 항목 (루트가 아닐 때) + var parent = Path.GetDirectoryName(dir.TrimEnd('\\', '/')); + if (!string.IsNullOrEmpty(parent)) + { + items.Add(new LauncherItem( + ".. (상위 폴더)", + parent, + null, + new FileBrowserEntry(parent, true), + Symbol: "\uE74A")); // Back 아이콘 + } + + try + { + // 폴더 먼저 + var dirs = Directory.GetDirectories(dir) + .Where(d => string.IsNullOrEmpty(filter) || + Path.GetFileName(d).Contains(filter, StringComparison.OrdinalIgnoreCase)) + .OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase) + .Take(40); + + foreach (var d in dirs) + { + var name = Path.GetFileName(d); + items.Add(new LauncherItem( + name, + d, + null, + new FileBrowserEntry(d, true), + Symbol: Symbols.Folder)); + } + + // 파일 + var files = Directory.GetFiles(dir) + .Where(f => string.IsNullOrEmpty(filter) || + Path.GetFileName(f).Contains(filter, StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase) + .Take(30); + + foreach (var f in files) + { + var name = Path.GetFileName(f); + var ext = Path.GetExtension(f).ToLowerInvariant(); + var size = FormatSize(new FileInfo(f).Length); + items.Add(new LauncherItem( + name, + $"{size} · {ext.TrimStart('.')} 파일", + null, + new FileBrowserEntry(f, false), + Symbol: ExtToSymbol(ext))); + } + } + catch (UnauthorizedAccessException) + { + items.Add(new LauncherItem("접근 권한 없음", dir, null, null, Symbol: Symbols.Error)); + } + catch (Exception ex) + { + items.Add(new LauncherItem("읽기 오류", ex.Message, null, null, Symbol: Symbols.Error)); + } + + if (items.Count == 0 || (items.Count == 1 && items[0].Symbol == "\uE74A")) + items.Add(new LauncherItem("(빈 폴더)", dir, null, null, Symbol: Symbols.Folder)); + + return items; + } + + // ─── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static string ExpandPath(string path) + { + if (path.StartsWith("~")) path = "%USERPROFILE%" + path[1..]; + return Environment.ExpandEnvironmentVariables(path); + } + + private static string FormatSize(long bytes) => bytes switch + { + < 1_024L => $"{bytes} B", + < 1_024L * 1_024 => $"{bytes / 1_024.0:F1} KB", + < 1_024L * 1_024 * 1_024 => $"{bytes / 1_048_576.0:F1} MB", + _ => $"{bytes / 1_073_741_824.0:F1} GB", + }; + + private static string ExtToSymbol(string ext) => ext switch + { + ".exe" or ".msi" => Symbols.App, + ".pdf" => "\uEA90", + ".docx" or ".doc" => "\uE8A5", + ".xlsx" or ".xls" => "\uE9F9", + ".pptx" or ".ppt" => "\uE8A5", + ".zip" or ".7z" or ".rar" => "\uED25", + ".mp4" or ".avi" or ".mkv" => "\uE714", + ".mp3" or ".wav" or ".flac" => "\uE767", + ".png" or ".jpg" or ".jpeg" or ".gif" => "\uEB9F", + ".txt" or ".md" or ".log" => "\uE8A5", + ".cs" or ".py" or ".js" or ".ts" => "\uE8A5", + ".lnk" => "\uE71B", + _ => "\uE7C3", + }; +} + +/// 파일 탐색기 핸들러에서 사용하는 항목 데이터 +public record FileBrowserEntry(string Path, bool IsFolder); diff --git a/src/AxCopilot/Views/LauncherWindow.Keyboard.cs b/src/AxCopilot/Views/LauncherWindow.Keyboard.cs index 39a148a..e3c23cf 100644 --- a/src/AxCopilot/Views/LauncherWindow.Keyboard.cs +++ b/src/AxCopilot/Views/LauncherWindow.Keyboard.cs @@ -151,13 +151,44 @@ public partial class LauncherWindow break; case Key.Right: - // 커서가 입력 끝에 있고 선택된 항목이 파일/앱이면 액션 서브메뉴 진입 - if (InputBox.CaretIndex == InputBox.Text.Length - && InputBox.Text.Length > 0 - && _vm.CanEnterActionMode()) + // 커서가 입력 끝에 있을 때 + if (InputBox.CaretIndex == InputBox.Text.Length && InputBox.Text.Length > 0) { - _vm.EnterActionMode(_vm.SelectedItem!); - e.Handled = true; + // 파일 탐색기: 선택된 항목이 폴더이면 해당 경로로 진입 + if (_vm.SelectedItem?.Data is AxCopilot.Handlers.FileBrowserEntry { IsFolder: true } fb) + { + _vm.InputText = fb.Path.TrimEnd('\\', '/') + "\\"; + Dispatcher.BeginInvoke(() => + { + InputBox.CaretIndex = InputBox.Text.Length; + }, System.Windows.Threading.DispatcherPriority.Input); + e.Handled = true; + } + // 일반 항목: 액션 서브메뉴 진입 + else if (_vm.CanEnterActionMode()) + { + _vm.EnterActionMode(_vm.SelectedItem!); + e.Handled = true; + } + } + break; + + case Key.Left: + // 파일 탐색기 모드에서 커서가 끝에 있고 입력이 경로이면 상위 폴더로 이동 + if (InputBox.CaretIndex == InputBox.Text.Length + && AxCopilot.Handlers.FileBrowserHandler.IsPathQuery(InputBox.Text)) + { + var trimmed = InputBox.Text.TrimEnd('\\', '/'); + var parent = System.IO.Path.GetDirectoryName(trimmed); + if (!string.IsNullOrEmpty(parent)) + { + _vm.InputText = parent.TrimEnd('\\', '/') + "\\"; + Dispatcher.BeginInvoke(() => + { + InputBox.CaretIndex = InputBox.Text.Length; + }, System.Windows.Threading.DispatcherPriority.Input); + e.Handled = true; + } } break; diff --git a/src/AxCopilot/Views/QuickLookWindow.xaml b/src/AxCopilot/Views/QuickLookWindow.xaml new file mode 100644 index 0000000..f10bbf7 --- /dev/null +++ b/src/AxCopilot/Views/QuickLookWindow.xaml @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/QuickLookWindow.xaml.cs b/src/AxCopilot/Views/QuickLookWindow.xaml.cs new file mode 100644 index 0000000..5662a46 --- /dev/null +++ b/src/AxCopilot/Views/QuickLookWindow.xaml.cs @@ -0,0 +1,425 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using UglyToad.PdfPig; +using WpfColor = System.Windows.Media.Color; + +namespace AxCopilot.Views; + +/// +/// F3 파일 빠른 미리보기 창. +/// 선택된 파일/폴더의 내용을 이미지·텍스트·정보 3가지 뷰로 표시합니다. +/// +public partial class QuickLookWindow : Window +{ + // ─── 지원 확장자 ────────────────────────────────────────────────────────── + + private static readonly HashSet ImageExts = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif" + }; + + private static readonly HashSet TextExts = new(StringComparer.OrdinalIgnoreCase) + { + ".txt", ".md", ".cs", ".vb", ".fs", ".py", ".js", ".ts", ".jsx", ".tsx", + ".json", ".xml", ".xaml", ".yaml", ".yml", ".toml", ".ini", ".conf", + ".log", ".csv", ".html", ".htm", ".css", ".scss", ".less", + ".sql", ".sh", ".bash", ".bat", ".cmd", ".ps1", + ".config", ".env", ".gitignore", ".editorconfig", + ".java", ".cpp", ".c", ".h", ".hpp", ".rs", ".go", ".rb", ".php", ".swift", + ".vue", ".svelte", ".dockerfile" + }; + + private static readonly HashSet CodeExts = new(StringComparer.OrdinalIgnoreCase) + { + ".cs", ".vb", ".fs", ".py", ".js", ".ts", ".jsx", ".tsx", + ".json", ".xml", ".xaml", ".yaml", ".yml", ".toml", + ".sql", ".sh", ".bash", ".bat", ".cmd", ".ps1", + ".java", ".cpp", ".c", ".h", ".hpp", ".rs", ".go", ".rb", ".php", ".swift", + ".css", ".scss", ".less", ".html", ".htm", ".vue", ".svelte" + }; + + private static readonly HashSet PdfExts = new(StringComparer.OrdinalIgnoreCase) + { ".pdf" }; + + private static readonly HashSet WordExts = new(StringComparer.OrdinalIgnoreCase) + { ".docx", ".doc" }; + + private static readonly HashSet ExcelExts = new(StringComparer.OrdinalIgnoreCase) + { ".xlsx", ".xls" }; + + // ─── 생성 ───────────────────────────────────────────────────────────────── + + public QuickLookWindow(string path, Window owner) + { + InitializeComponent(); + Owner = owner; + KeyDown += OnKeyDown; + Loaded += (_, _) => LoadPreview(path); + } + + // ─── 이벤트 ────────────────────────────────────────────────────────────── + + private void OnKeyDown(object sender, KeyEventArgs e) + { + if (e.Key is Key.Escape or Key.F3) + { + Close(); + e.Handled = true; + } + } + + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) DragMove(); + } + + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); + + // ─── 미리보기 로드 ─────────────────────────────────────────────────────── + + private void LoadPreview(string path) + { + try + { + FileNameText.Text = Path.GetFileName(path); + + if (Directory.Exists(path)) + { + LoadFolderInfo(path); + return; + } + + if (!File.Exists(path)) + { + ShowInfo("\uE7BA", "파일을 찾을 수 없습니다.", ""); + return; + } + + var ext = Path.GetExtension(path); + var info = new FileInfo(path); + + FooterPath.Text = path; + FooterMeta.Text = $"{FormatSize(info.Length)} · {info.LastWriteTime:yyyy-MM-dd HH:mm}"; + + if (ImageExts.Contains(ext)) + LoadImagePreview(path, info); + else if (PdfExts.Contains(ext)) + LoadPdfPreview(path, info); + else if (WordExts.Contains(ext)) + LoadWordPreview(path, info); + else if (ExcelExts.Contains(ext)) + LoadExcelPreview(path, info); + else if (TextExts.Contains(ext)) + LoadTextPreview(path, ext); + else + LoadFileInfo(path, ext, info); + } + catch (Exception ex) + { + ShowInfo("\uE783", $"미리보기 오류", ex.Message); + } + } + + // ─── 이미지 ────────────────────────────────────────────────────────────── + + private void LoadImagePreview(string path, FileInfo info) + { + FileTypeIcon.Text = "\uEB9F"; + try + { + var bi = new BitmapImage(); + bi.BeginInit(); + bi.CacheOption = BitmapCacheOption.OnLoad; + bi.UriSource = new Uri(path, UriKind.Absolute); + bi.DecodePixelWidth = 800; // 메모리 절약 + bi.EndInit(); + bi.Freeze(); + + PreviewImage.Source = bi; + // 이미지 해상도를 타이틀바에 추가 + FileNameText.Text = $"{Path.GetFileName(path)} ({bi.PixelWidth}×{bi.PixelHeight})"; + ImageScrollViewer.Visibility = Visibility.Visible; + } + catch + { + ShowInfo("\uEB9F", "이미지를 불러올 수 없습니다.", info.Name); + } + } + + // ─── 텍스트 / 코드 ──────────────────────────────────────────────────────── + + private void LoadTextPreview(string path, string ext) + { + FileTypeIcon.Text = "\uE8A5"; + try + { + const int MaxLines = 300; + List lines; + try + { + lines = File.ReadLines(path, Encoding.UTF8).Take(MaxLines).ToList(); + } + catch + { + lines = File.ReadLines(path).Take(MaxLines).ToList(); + } + + var text = string.Join("\n", lines); + if (lines.Count == MaxLines) text += "\n\n… (이하 생략, 최대 300줄)"; + + PreviewText.Text = text; + + // 코드 파일: 줄 번호 + 배경 강조 + if (CodeExts.Contains(ext)) + { + ApplyCodeStyle(lines, ext); + } + + TextScrollViewer.Visibility = Visibility.Visible; + } + catch + { + ShowInfo("\uE8A5", "텍스트를 불러올 수 없습니다.", path); + } + } + + private void ApplyCodeStyle(List lines, string ext) + { + // 줄 번호 표시 + LineNumbers.Text = string.Join("\n", Enumerable.Range(1, lines.Count)); + LineNumbers.Visibility = Visibility.Visible; + LineNumBg.Visibility = Visibility.Visible; + + // 코드 본문을 줄 번호 너비만큼 우측으로 밀기 + PreviewText.Margin = new Thickness(50, 12, 14, 12); + + // 확장자별 배경 색조 + var (accent, dim) = ext.ToLowerInvariant() switch + { + ".cs" or ".vb" or ".fs" => ("#0A6ABDE8", "#10143A57"), // 파랑 — C#/VB/F# + ".py" => ("#0AF5D55C", "#10254A10"), // 초록 — Python + ".js" or ".ts" or ".jsx" or ".tsx" => ("#0AF0B429", "#10453200"), // 앰버 — JS/TS + ".json" or ".yaml" or ".yml" or ".toml" => ("#0AB0B0C0", "#10202030"), // 회색 — 데이터 + ".sql" => ("#0AFF8C69", "#10401A00"), // 주황 — SQL + ".html" or ".htm" or ".xml" or ".xaml" => ("#0AFF7878", "#10400000"), // 빨강 — 마크업 + ".css" or ".scss" or ".less" => ("#0AFF69B4", "#10400030"), // 핑크 — 스타일 + ".sh" or ".bash" or ".ps1" or ".bat" => ("#0A90FF90", "#10003010"), // 연두 — 쉘 + ".cpp" or ".c" or ".h" or ".hpp" or ".rs" => ("#0AD499FF", "#10181830"), // 보라 — C/C++/Rust + ".go" => ("#0A00BCD4", "#10001E2A"), // 청록 — Go + _ => ("#0AFFFFFF", "#10181828"), // 기본 + }; + + try + { + var accentBrush = new SolidColorBrush( + (WpfColor)ColorConverter.ConvertFromString(accent)); + var dimBrush = new SolidColorBrush( + (WpfColor)ColorConverter.ConvertFromString(dim)); + + // TextScrollViewer 배경에 코드 색조 적용 + TextScrollViewer.Background = dimBrush; + LineNumBg.Background = accentBrush; + } + catch { /* 색 변환 실패 무시 */ } + } + + // ─── PDF ───────────────────────────────────────────────────────────────── + + private void LoadPdfPreview(string path, FileInfo info) + { + FileTypeIcon.Text = "\uEA90"; + try + { + using var doc = PdfDocument.Open(path); + var totalPages = doc.NumberOfPages; + var sb = new StringBuilder(); + const int PreviewPages = 10; + var pages = Math.Min(totalPages, PreviewPages); + + for (int i = 1; i <= pages; i++) + { + var page = doc.GetPage(i); + var pageText = page.Text; + if (!string.IsNullOrWhiteSpace(pageText)) + { + sb.AppendLine($"── 페이지 {i} ──"); + sb.AppendLine(pageText.Trim()); + sb.AppendLine(); + } + } + + if (totalPages > PreviewPages) + sb.AppendLine($"… (전체 {totalPages}페이지 중 {PreviewPages}페이지 표시)"); + + PdfMetaText.Text = $"{totalPages}페이지 · {FormatSize(info.Length)}"; + PdfPreviewText.Text = sb.Length > 0 ? sb.ToString() : "(텍스트 추출 불가 — 스캔 PDF)"; + PdfScrollViewer.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + ShowInfo("\uEA90", "PDF를 불러올 수 없습니다.", ex.Message); + } + } + + // ─── Word (.docx) ──────────────────────────────────────────────────────── + + private void LoadWordPreview(string path, FileInfo info) + { + FileTypeIcon.Text = "\uE8A5"; + OfficeTypeIcon.Text = "\uE8A5"; + try + { + using var doc = WordprocessingDocument.Open(path, false); + var body = doc.MainDocumentPart?.Document?.Body; + if (body == null) { ShowInfo("\uE8A5", "Word 문서를 열 수 없습니다.", path); return; } + + var sb = new StringBuilder(); + foreach (var para in body.Descendants()) + { + var line = para.InnerText; + if (!string.IsNullOrWhiteSpace(line)) + sb.AppendLine(line); + if (sb.Length > 8000) { sb.AppendLine("\n… (이하 생략)"); break; } + } + + OfficeMetaText.Text = $"Word 문서 · {FormatSize(info.Length)}"; + OfficePreviewText.Text = sb.Length > 0 ? sb.ToString() : "(내용 없음)"; + OfficeScrollViewer.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + ShowInfo("\uE8A5", "Word 문서를 불러올 수 없습니다.", ex.Message); + } + } + + // ─── Excel (.xlsx) ──────────────────────────────────────────────────────── + + private void LoadExcelPreview(string path, FileInfo info) + { + FileTypeIcon.Text = "\uE9F9"; + OfficeTypeIcon.Text = "\uE9F9"; + try + { + using var doc = SpreadsheetDocument.Open(path, false); + var wb = doc.WorkbookPart; + if (wb == null) { ShowInfo("\uE9F9", "Excel 문서를 열 수 없습니다.", path); return; } + + // 공유 문자열 테이블 + var sharedStrings = wb.SharedStringTablePart?.SharedStringTable + .Elements() + .Select(x => x.InnerText) + .ToList() ?? new List(); + + var sb = new StringBuilder(); + int sheetCount = 0; + + foreach (var sheetPart in wb.WorksheetParts.Take(3)) + { + sheetCount++; + sb.AppendLine($"── 시트 {sheetCount} ──"); + var rows = sheetPart.Worksheet.Descendants().Take(50); + foreach (var row in rows) + { + var cells = row.Elements().Select(c => + { + if (c.DataType?.Value == CellValues.SharedString && + int.TryParse(c.InnerText, out int idx) && idx < sharedStrings.Count) + return sharedStrings[idx]; + return c.InnerText; + }); + sb.AppendLine(string.Join("\t", cells)); + if (sb.Length > 8000) { sb.AppendLine("… (이하 생략)"); goto done; } + } + } + done: + OfficeMetaText.Text = $"Excel 문서 · {FormatSize(info.Length)}"; + OfficePreviewText.Text = sb.Length > 0 ? sb.ToString() : "(내용 없음)"; + OfficeScrollViewer.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + ShowInfo("\uE9F9", "Excel 문서를 불러올 수 없습니다.", ex.Message); + } + } + + // ─── 폴더 ──────────────────────────────────────────────────────────────── + + private void LoadFolderInfo(string path) + { + FileTypeIcon.Text = "\uE8B7"; + InfoTypeIcon.Text = "\uE8B7"; + FooterPath.Text = path; + + try + { + var di = new DirectoryInfo(path); + var items = di.GetFileSystemInfos(); + var files = items.Count(i => i is FileInfo); + var dirs = items.Count(i => i is DirectoryInfo); + + InfoTypeName.Text = di.Name; + InfoSubText.Text = $"파일 {files}개 · 폴더 {dirs}개"; + FooterMeta.Text = $"수정: {di.LastWriteTime:yyyy-MM-dd HH:mm}"; + } + catch + { + InfoTypeName.Text = Path.GetFileName(path); + InfoSubText.Text = "폴더"; + } + + InfoPanel.Visibility = Visibility.Visible; + } + + // ─── 기타 파일 정보 ────────────────────────────────────────────────────── + + private void LoadFileInfo(string path, string ext, FileInfo info) + { + var (icon, typeName) = ext.ToLowerInvariant() switch + { + ".exe" or ".msi" or ".appx" => ("\uE756", "실행 파일"), + ".pdf" => ("\uEA90", "PDF 문서"), + ".docx" or ".doc" => ("\uE8A5", "Word 문서"), + ".xlsx" or ".xls" => ("\uE9F9", "Excel 문서"), + ".pptx" or ".ppt" => ("\uE8A5", "PowerPoint"), + ".zip" or ".7z" or ".rar" or ".gz" => ("\uED25", "압축 파일"), + ".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" => ("\uE714", "동영상"), + ".mp3" or ".wav" or ".flac" or ".aac" => ("\uE767", "오디오"), + ".lnk" => ("\uE71B", "바로 가기"), + ".dll" or ".sys" => ("\uECAA", "시스템 파일"), + _ => ("\uE7C3", ext.TrimStart('.').ToUpperInvariant() + " 파일") + }; + + FileTypeIcon.Text = icon; + InfoTypeIcon.Text = icon; + InfoTypeName.Text = typeName; + InfoSubText.Text = info.Name; + InfoPanel.Visibility = Visibility.Visible; + } + + // ─── 오류/정보 패널 ─────────────────────────────────────────────────────── + + private void ShowInfo(string icon, string title, string sub) + { + InfoTypeIcon.Text = icon; + InfoTypeName.Text = title; + InfoSubText.Text = sub; + InfoPanel.Visibility = Visibility.Visible; + } + + // ─── 파일 크기 포맷 ─────────────────────────────────────────────────────── + + private static string FormatSize(long bytes) => bytes switch + { + < 1_024L => $"{bytes} B", + < 1_024L * 1_024 => $"{bytes / 1_024.0:F1} KB", + < 1_024L * 1_024 * 1_024 => $"{bytes / 1_024.0 / 1_024.0:F1} MB", + _ => $"{bytes / 1_024.0 / 1_024.0 / 1_024.0:F1} GB" + }; +}