using AxCopilot.Handlers; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Core; /// /// 입력된 텍스트를 파싱하여 적절한 ActionHandler로 라우팅합니다. /// Prefix 기반 라우팅 테이블을 관리합니다. /// public class CommandResolver { private readonly FuzzyEngine _fuzzy; private readonly SettingsService _settings; private readonly Dictionary _handlers = new(); /// Prefix = null 핸들러 목록 — 모든 쿼리에 병렬 실행 private readonly List _fuzzyHandlers = new(); public CommandResolver(FuzzyEngine fuzzy, SettingsService settings) { _fuzzy = fuzzy; _settings = settings; } /// /// 핸들러를 등록합니다. 플러그인 로드 시에도 이 메서드를 호출합니다. /// public void RegisterHandler(IActionHandler handler) { // Prefix 없는 핸들러 → 모든 쿼리에 부가 결과 제공 (예: BookmarkHandler) if (handler.Prefix == null) { _fuzzyHandlers.Add(handler); LogService.Info($"FuzzyHandler 등록: name='{handler.Metadata.Name}'"); return; } if (_handlers.ContainsKey(handler.Prefix)) LogService.Warn($"Prefix '{handler.Prefix}' 중복 등록: " + $"'{handler.Metadata.Name}'이 기존 핸들러를 덮어씁니다."); _handlers[handler.Prefix] = handler; LogService.Info($"Handler 등록: prefix='{handler.Prefix}', name='{handler.Metadata.Name}'"); } /// /// 입력 텍스트를 분석하여 결과 목록을 반환합니다. /// public async Task> ResolveAsync(string input, CancellationToken ct) { if (string.IsNullOrWhiteSpace(input)) return Enumerable.Empty(); // 1. Prefix 기반 라우팅 foreach (var (prefix, handler) in _handlers) { if (input.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { var query = input.Length > prefix.Length ? input[prefix.Length..].Trim() : ""; try { return await handler.GetItemsAsync(query, ct); } catch (OperationCanceledException) { throw; } catch (Exception ex) { LogService.Error($"Handler '{handler.Metadata.Name}' 오류: {ex.Message}"); return [new SDK.LauncherItem($"오류: {ex.Message}", handler.Metadata.Name, null, null)]; } } } // 2. 경로 쿼리 감지 → 파일 탐색기 단독 처리 (퍼지 검색 우선순위 우회) if (FileBrowserHandler.IsPathQuery(input)) { var fb = _fuzzyHandlers.OfType().FirstOrDefault(); if (fb != null) return await fb.GetItemsAsync(input, ct); } // 3. 고급 필터 문법 감지 (ext:, size:, modified:, type:, in:) var maxResults = _settings.Settings.Launcher.MaxResults; var (cleanQuery, filters) = SearchFilterParser.Parse(input); if (filters.HasFilters) { return BuildFilteredResults(cleanQuery, filters, maxResults); } // 4. Fuzzy 검색 폴백 + null-prefix 핸들러 병렬 실행 // Path 기반 중복 제거: 같은 경로의 항목이 여러 키워드로 매칭될 때 첫 번째만 표시 var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); // SortByUsage에 lazy 시퀀스를 직접 전달 → 중간 ToList 1회 제거 var fuzzyItems = UsageRankingService.SortByUsage( _fuzzy.Search(input, maxResults * 2) // 중복 제거 여유분 .Where(r => seenPaths.Add(r.Entry.Path)) // Path가 처음 등장할 때만 통과 .Take(maxResults) .Select(r => new SDK.LauncherItem( r.Entry.DisplayName, r.Entry.Type == IndexEntryType.Alias ? r.Entry.AliasType switch { "url" => "URL 단축키", "batch" => "명령 단축키", _ => r.Entry.Path } : r.Entry.Path + " ⇧ Shift+Enter: 폴더 열기", IconCacheService.GetIconPath(r.Entry.Path, r.Entry.Type == IndexEntryType.Folder), r.Entry, Symbol: r.Entry.Type switch { IndexEntryType.App => Symbols.App, IndexEntryType.Folder => Symbols.Folder, IndexEntryType.Alias => r.Entry.AliasType switch { "url" => Symbols.Globe, "batch" => Symbols.Terminal, _ => Symbols.Plugin }, _ => Symbols.File }, Group: r.Entry.Type switch { IndexEntryType.App => "앱", IndexEntryType.Folder => "폴더", IndexEntryType.Alias => "단축키", _ => "파일" } )), item => (item.Data as IndexEntry)?.Path ).ToList(); // 단일 ToList로 List 확정 // null-prefix 핸들러 결과를 뒤에 추가 (최대 3개씩) if (_fuzzyHandlers.Count > 0) { var extraTasks = _fuzzyHandlers .Select(h => SafeGetItemsAsync(h, input, ct)) .ToList(); await Task.WhenAll(extraTasks); foreach (var task in extraTasks) { if (task.IsCompletedSuccessfully) fuzzyItems.AddRange(task.Result.Take(3)); } } return fuzzyItems; } /// /// 선택된 항목을 실행합니다. /// public async Task ExecuteAsync(SDK.LauncherItem item, string lastInput, CancellationToken ct) { // Prefix 기반 실행 foreach (var (prefix, handler) in _handlers) { if (lastInput.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { // 명령어 사용 통계 기록 (prefix + 첫 단어) var q = lastInput.Length > prefix.Length ? lastInput[prefix.Length..].Trim().Split(' ')[0] : ""; var cmdKey = string.IsNullOrEmpty(q) ? prefix : $"{prefix}{q}"; UsageStatisticsService.RecordCommandUsage(cmdKey); await handler.ExecuteAsync(item, ct); return; } } // null-prefix 핸들러 결과 실행 (Data가 string = URL인 경우) if (item.Data is string urlData && urlData.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { await ExecuteNullPrefixAsync(item, ct); return; } // 파일 탐색기 항목 실행 (FileBrowserEntry) if (item.Data is FileBrowserEntry) { await ExecuteNullPrefixAsync(item, ct); return; } // Fuzzy 결과 실행 (IndexEntry 기반) if (item.Data is IndexEntry entry) { var expanded = Environment.ExpandEnvironmentVariables(entry.Path); try { // Process.Start를 먼저 실행하여 체감 속도 확보 await Task.Run(() => System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(expanded) { UseShellExecute = true })); } catch (Exception ex) { LogService.Error($"실행 실패: {expanded} - {ex.Message}"); } // 통계 기록은 파일 열기 이후 비동기로 _ = Task.Run(() => UsageRankingService.RecordExecution(entry.Path)); } } public IReadOnlyDictionary RegisteredHandlers => _handlers; // ─── 고급 필터 검색 결과 생성 ───────────────────────────────────────── private IEnumerable BuildFilteredResults( string textQuery, ParsedFilters filters, int maxResults) { var results = new List(); // 필터 힌트 항목 (상단 표시 — 어떤 필터가 적용 중인지 안내) var hint = SearchFilterParser.Describe(filters); results.Add(new SDK.LauncherItem( $"필터 적용 중: {hint}", string.IsNullOrEmpty(textQuery) ? "전체 항목에서 검색 중" : $"'{textQuery}' + 필터", null, null, Symbol: "\uE16E", // Filter 아이콘 (MDL2) Group: "필터")); // 퍼지 + 필터 적용 (size/modified는 파일시스템 접근이 있으므로 Task.Run에서 실행) var filtered = _fuzzy.SearchWithFilter( textQuery, e => SearchFilterParser.Matches(e, filters), maxResults); var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var r in filtered) { if (!seenPaths.Add(r.Entry.Path)) continue; // 크기/수정일 부가 정보 — 파일 항목에만 추가 string sub = r.Entry.Path + " ⇧ Shift+Enter: 폴더 열기"; if (r.Entry.Type == IndexEntryType.File && (filters.SizeBytes != null || filters.ModifiedFrom != null)) { try { var fi = new System.IO.FileInfo( Environment.ExpandEnvironmentVariables(r.Entry.Path)); if (fi.Exists) { var sizeTxt = FormatBytes(fi.Length); var dateTxt = fi.LastWriteTime.ToString("yyyy-MM-dd"); sub = $"{sizeTxt} · {dateTxt} · {r.Entry.Path}"; } } catch { /* 접근 실패 시 기본 subtitle */ } } results.Add(new SDK.LauncherItem( r.Entry.DisplayName, sub, null, r.Entry, Symbol: r.Entry.Type switch { IndexEntryType.App => Symbols.App, IndexEntryType.Folder => Symbols.Folder, _ => Symbols.File }, Group: "검색 결과")); } if (results.Count == 1) // 힌트 항목만 있음 → 결과 없음 메시지 { results.Add(new SDK.LauncherItem( "검색 결과 없음", $"필터 조건을 확인하세요: {hint}", null, null, Symbol: Symbols.Error, Group: "검색 결과")); } return results; } private static string FormatBytes(long b) => b switch { >= 1024L * 1024 * 1024 => $"{b / (1024.0 * 1024 * 1024):F1}GB", >= 1024L * 1024 => $"{b / (1024.0 * 1024):F1}MB", >= 1024L => $"{b / 1024.0:F1}KB", _ => $"{b}B" }; // null-prefix 핸들러 실행 (ExecuteAsync 라우팅) public async Task ExecuteNullPrefixAsync(SDK.LauncherItem item, CancellationToken ct) { foreach (var handler in _fuzzyHandlers) { try { await handler.ExecuteAsync(item, ct); return; } catch (OperationCanceledException) { throw; } catch (Exception ex) { LogService.Error($"FuzzyHandler '{handler.Metadata.Name}' 실행 오류: {ex.Message}"); } } } private static async Task> SafeGetItemsAsync( IActionHandler handler, string query, CancellationToken ct) { try { return await handler.GetItemsAsync(query, ct); } catch (OperationCanceledException) { throw; } catch (Exception ex) { LogService.Error($"FuzzyHandler '{handler.Metadata.Name}' 오류: {ex.Message}"); return Enumerable.Empty(); } } }