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