Files
AX-Copilot/src/AxCopilot/Handlers/FileBrowserHandler.cs
lacvet d4a1532d81 [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
2026-04-04 11:23:18 +09:00

186 lines
7.3 KiB
C#

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