[Phase L4] 파일탐색기·QuickLook·단위변환 단축 3종 완료

FileBrowserHandler (185줄) — L4-1 인라인 파일 탐색기:
- Handlers/FileBrowserHandler.cs: Prefix=null, 경로 패턴 감지(C:\, D:\, \, ~\)
- 폴더/파일 나열: 상위폴더(..) + 하위폴더 40개 + 파일 30개
- 확장자별 MDL2 아이콘, 파일 크기 포맷(B/KB/MB/GB), 필터링 지원
- FileBrowserEntry(Path, IsFolder) record 정의
- App.xaml.cs: Phase L4 섹션에 FileBrowserHandler 등록

CommandResolver (18줄 추가) — 경로 쿼리 우선 처리:
- 퍼지 검색 전 IsPathQuery() 감지 → 파일탐색기 단독 결과 반환(항목 수 제한 없음)
- FileBrowserEntry 실행 라우팅 → ExecuteNullPrefixAsync 위임

LauncherWindow.Keyboard.cs (41줄 추가) — 키보드 탐색:
- Key.Right: FileBrowserEntry {IsFolder:true} 선택 시 해당 경로로 InputText 업데이트
- Key.Left: 경로 쿼리 상태에서 상위 폴더로 이동(Path.GetDirectoryName)
- 기존 → 키 액션모드 진입 로직 유지

QuickLookWindow (L4-2 F3 미리보기 강화):
- XAML: 줄번호 열(LineNumBg+LineNumbers), PDF 패널(빨강 배지), Office 패널(파랑 배지) 추가
- Code-behind: PDF(PdfPig), Word(OpenXml), Excel(OpenXml) 미리보기 구현
- ApplyCodeStyle(): 언어별 배경 색조(C#=파랑, Python=녹색, JS=앰버 등)
- 빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 11:23:18 +09:00
parent 75cb4ba6e9
commit d4a1532d81
6 changed files with 897 additions and 7 deletions

View File

@@ -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();

View File

@@ -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<FileBrowserHandler>().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)
{

View File

@@ -0,0 +1,185 @@
using System.IO;
using System.Text.RegularExpressions;
using AxCopilot.SDK;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L4-1: 인라인 파일 탐색기 핸들러.
/// 입력이 파일시스템 경로처럼 보이면 (예: C:\, D:\Users\) 해당 폴더의 내용을 런처 목록으로 표시합니다.
/// → 키로 하위 폴더 진입, ← 키 또는 Backspace로 상위 폴더 이동.
/// prefix=null: 일반 쿼리 파이프라인에서 경로 감지 후 동작.
/// </summary>
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);
/// <summary>쿼리가 파일시스템 경로처럼 보이는지 빠르게 판별합니다.</summary>
public static bool IsPathQuery(string query)
=> !string.IsNullOrEmpty(query) && PathPattern.IsMatch(query.Trim());
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = ExpandPath(query.Trim());
// 경로가 아닌 쿼리는 빈 결과 반환 (다른 핸들러가 처리)
if (!IsPathQuery(query.Trim()))
return Task.FromResult<IEnumerable<LauncherItem>>(Array.Empty<LauncherItem>());
// 입력이 존재하는 디렉터리이면 그 내용 표시
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<IEnumerable<LauncherItem>>(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<LauncherItem> ListDirectory(string dir, string filter = "")
{
var items = new List<LauncherItem>();
// 상위 폴더 항목 (루트가 아닐 때)
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",
};
}
/// <summary>파일 탐색기 핸들러에서 사용하는 항목 데이터</summary>
public record FileBrowserEntry(string Path, bool IsFolder);

View File

@@ -151,14 +151,45 @@ 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)
{
// 파일 탐색기: 선택된 항목이 폴더이면 해당 경로로 진입
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;
case Key.PageDown:

View File

@@ -0,0 +1,229 @@
<Window x:Class="AxCopilot.Views.QuickLookWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Commander — 빠른 미리보기"
Width="400" Height="500"
MinWidth="260" MinHeight="200"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="Manual"
ResizeMode="CanResizeWithGrip"
Topmost="True">
<!-- F3 빠른 미리보기: 이미지/텍스트/폴더/파일 정보 -->
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Margin="6">
<Border.Effect>
<DropShadowEffect BlurRadius="22" ShadowDepth="4" Opacity="0.32" Color="Black" Direction="270"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/> <!-- 타이틀바 -->
<RowDefinition Height="*"/> <!-- 컨텐츠 -->
<RowDefinition Height="Auto"/> <!-- 하단 메타 -->
</Grid.RowDefinitions>
<!-- ─── 타이틀바 ─────────────────────────────────────────────── -->
<Border Grid.Row="0" CornerRadius="12,12,0,0"
Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseDown">
<Grid Margin="14,0,8,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="FileTypeIcon"
Text="&#xE7C3;"
FontFamily="Segoe MDL2 Assets" FontSize="15"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center" Margin="0,1,10,0"/>
<TextBlock x:Name="FileNameText" Text=""
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="270"/>
</StackPanel>
<!-- 닫기 버튼 -->
<Border HorizontalAlignment="Right" VerticalAlignment="Center"
CornerRadius="4" Padding="8,4" Cursor="Hand"
MouseLeftButtonUp="BtnClose_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#40C05050"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
</Grid>
</Border>
<!-- ─── 컨텐츠 영역 ───────────────────────────────────────────── -->
<Grid Grid.Row="1">
<!-- 이미지 미리보기 -->
<ScrollViewer x:Name="ImageScrollViewer"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed"
Background="{DynamicResource LauncherBackground}">
<Image x:Name="PreviewImage"
RenderOptions.BitmapScalingMode="HighQuality"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="8"/>
</ScrollViewer>
<!-- 텍스트/코드 미리보기 -->
<ScrollViewer x:Name="TextScrollViewer"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed">
<Grid>
<!-- 코드 배경 (줄 번호 열) -->
<Border x:Name="LineNumBg"
HorizontalAlignment="Left"
Width="40"
Background="#0AFFFFFF"
Visibility="Collapsed"/>
<!-- 줄 번호 텍스트 -->
<TextBlock x:Name="LineNumbers"
FontFamily="Cascadia Code, Consolas, Courier New"
FontSize="12"
Foreground="{DynamicResource SecondaryText}"
TextWrapping="NoWrap"
HorizontalAlignment="Left"
Opacity="0.5"
Margin="6,12,0,12"
Visibility="Collapsed"/>
<!-- 코드/텍스트 본문 -->
<TextBlock x:Name="PreviewText"
FontFamily="Cascadia Code, Consolas, Courier New"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
TextWrapping="NoWrap"
Margin="14,12"/>
</Grid>
</ScrollViewer>
<!-- PDF 미리보기 (텍스트 추출) -->
<ScrollViewer x:Name="PdfScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed">
<StackPanel>
<!-- PDF 헤더 배지 -->
<Border Background="#15EA4335" CornerRadius="6"
Padding="12,6" Margin="10,10,10,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xEA90;"
FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="#EA4335" VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock x:Name="PdfMetaText"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<TextBlock x:Name="PdfPreviewText"
FontFamily="Segoe UI, Malgun Gothic"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
TextWrapping="Wrap"
Margin="14,12"/>
</StackPanel>
</ScrollViewer>
<!-- Office 문서 미리보기 (텍스트 추출) -->
<ScrollViewer x:Name="OfficeScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed">
<StackPanel>
<!-- Office 헤더 배지 -->
<Border Background="#152196F3" CornerRadius="6"
Padding="12,6" Margin="10,10,10,0">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="OfficeTypeIcon"
FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="#2196F3" VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock x:Name="OfficeMetaText"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<TextBlock x:Name="OfficePreviewText"
FontFamily="Segoe UI, Malgun Gothic"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
TextWrapping="Wrap"
Margin="14,12"/>
</StackPanel>
</ScrollViewer>
<!-- 정보 패널 (폴더/앱/기타 파일) -->
<StackPanel x:Name="InfoPanel"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Visibility="Collapsed">
<TextBlock x:Name="InfoTypeIcon"
Text="&#xE7C3;"
FontFamily="Segoe MDL2 Assets"
FontSize="52"
Foreground="{DynamicResource AccentColor}"
HorizontalAlignment="Center"/>
<TextBlock x:Name="InfoTypeName"
Text=""
FontSize="14"
FontWeight="Medium"
Foreground="{DynamicResource PrimaryText}"
HorizontalAlignment="Center"
Margin="0,10,0,0"
TextWrapping="Wrap"
TextAlignment="Center"
MaxWidth="320"/>
<TextBlock x:Name="InfoSubText"
Text=""
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center"
Margin="0,4,0,0"/>
</StackPanel>
</Grid>
<!-- ─── 하단 메타 정보 ─────────────────────────────────────────── -->
<Border Grid.Row="2" CornerRadius="0,0,12,12"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0"
Padding="14,7">
<Grid>
<TextBlock x:Name="FooterPath"
Text=""
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
TextTrimming="CharacterEllipsis"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxWidth="240"/>
<TextBlock x:Name="FooterMeta"
Text=""
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Right"
VerticalAlignment="Center"/>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -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;
/// <summary>
/// F3 파일 빠른 미리보기 창.
/// 선택된 파일/폴더의 내용을 이미지·텍스트·정보 3가지 뷰로 표시합니다.
/// </summary>
public partial class QuickLookWindow : Window
{
// ─── 지원 확장자 ──────────────────────────────────────────────────────────
private static readonly HashSet<string> ImageExts = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"
};
private static readonly HashSet<string> 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<string> 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<string> PdfExts = new(StringComparer.OrdinalIgnoreCase)
{ ".pdf" };
private static readonly HashSet<string> WordExts = new(StringComparer.OrdinalIgnoreCase)
{ ".docx", ".doc" };
private static readonly HashSet<string> 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<string> 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<string> 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<DocumentFormat.OpenXml.Wordprocessing.Paragraph>())
{
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<SharedStringItem>()
.Select(x => x.InnerText)
.ToList() ?? new List<string>();
var sb = new StringBuilder();
int sheetCount = 0;
foreach (var sheetPart in wb.WorksheetParts.Take(3))
{
sheetCount++;
sb.AppendLine($"── 시트 {sheetCount} ──");
var rows = sheetPart.Worksheet.Descendants<Row>().Take(50);
foreach (var row in rows)
{
var cells = row.Elements<Cell>().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"
};
}