[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:
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
185
src/AxCopilot/Handlers/FileBrowserHandler.cs
Normal file
185
src/AxCopilot/Handlers/FileBrowserHandler.cs
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
|
||||
229
src/AxCopilot/Views/QuickLookWindow.xaml
Normal file
229
src/AxCopilot/Views/QuickLookWindow.xaml
Normal 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=""
|
||||
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="" 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=""
|
||||
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=""
|
||||
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>
|
||||
425
src/AxCopilot/Views/QuickLookWindow.xaml.cs
Normal file
425
src/AxCopilot/Views/QuickLookWindow.xaml.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user