창 위치 기억 (Feature 1):
- AppSettings.cs: LauncherSettings에 RememberPosition, LastLeft, LastTop 프로퍼티 추가
- SettingsViewModel.cs/.Properties.cs/.Methods.cs: RememberPosition 바인딩 프로퍼티 연동
- LauncherWindow.Animations.cs: CenterOnScreen() — RememberPosition ON 시 저장 좌표 복원
- LauncherWindow.Shell.cs: Window_Deactivated — 비활성화 시 현재 위치 비동기 저장
- SettingsWindow.xaml: 런처 탭 › "마지막 위치 기억" 토글 추가
파일 아이콘 표시 (Feature 2):
- Services/IconCacheService.cs (신규, 192줄): Shell32 SHGetFileInfo로 아이콘 추출,
%LOCALAPPDATA%\AxCopilot\IconCache\에 PNG 캐시, WarmUp()으로 앱 시작 시 미리 준비
- Core/CommandResolver.cs: 퍼지 검색 결과에 IconCacheService.GetIconPath() 연결
- Handlers/FileBrowserHandler.cs: 상위폴더·폴더·파일 항목에 IconCacheService 연결
- App.xaml.cs: SystemIdle 시점에 IconCacheService.WarmUp() 호출
퍼지 검색 랭킹 개선 (Feature 3):
- Services/UsageRankingService.cs 전면 개선: 기존 int 횟수 → UsageRecord{Count, LastUsedMs}
- GetScore() 반환형 int → double, 30일 반감기 지수 감쇠(decay=exp(-days/43.3)) 적용
- 구형 usage.json 자동 마이그레이션 (count만 있는 형식 → 신규 형식)
- GetTopItems() / SortByUsage() 점수 기준 정렬로 업데이트
미리보기 패널 (Feature 4):
- ViewModels/LauncherViewModel.cs: PreviewText, HasPreview 프로퍼티 + UpdatePreviewAsync()
클립보드 텍스트(최대 400자) 및 텍스트 파일(최초 6줄) 미리보기, 80ms 디바운스
- Views/LauncherWindow.xaml: RowDefinitions 7→8개, Row5에 PreviewPanel Border 삽입,
IndexStatusText Row5→6, WidgetBar Row6→7, ToastOverlay RowSpan 3→4
빌드: 경고 0, 오류 0
325 lines
13 KiB
C#
325 lines
13 KiB
C#
using AxCopilot.Handlers;
|
|
using AxCopilot.SDK;
|
|
using AxCopilot.Services;
|
|
using AxCopilot.Themes;
|
|
|
|
namespace AxCopilot.Core;
|
|
|
|
/// <summary>
|
|
/// 입력된 텍스트를 파싱하여 적절한 ActionHandler로 라우팅합니다.
|
|
/// Prefix 기반 라우팅 테이블을 관리합니다.
|
|
/// </summary>
|
|
public class CommandResolver
|
|
{
|
|
private readonly FuzzyEngine _fuzzy;
|
|
private readonly SettingsService _settings;
|
|
private readonly Dictionary<string, IActionHandler> _handlers = new();
|
|
/// <summary>Prefix = null 핸들러 목록 — 모든 쿼리에 병렬 실행</summary>
|
|
private readonly List<IActionHandler> _fuzzyHandlers = new();
|
|
|
|
public CommandResolver(FuzzyEngine fuzzy, SettingsService settings)
|
|
{
|
|
_fuzzy = fuzzy;
|
|
_settings = settings;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 핸들러를 등록합니다. 플러그인 로드 시에도 이 메서드를 호출합니다.
|
|
/// </summary>
|
|
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}'");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 입력 텍스트를 분석하여 결과 목록을 반환합니다.
|
|
/// </summary>
|
|
public async Task<IEnumerable<SDK.LauncherItem>> ResolveAsync(string input, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(input))
|
|
return Enumerable.Empty<SDK.LauncherItem>();
|
|
|
|
// 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<FileBrowserHandler>().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<string>(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<LauncherItem> 확정
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 선택된 항목을 실행합니다.
|
|
/// </summary>
|
|
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<string, IActionHandler> RegisteredHandlers => _handlers;
|
|
|
|
// ─── 고급 필터 검색 결과 생성 ─────────────────────────────────────────
|
|
private IEnumerable<SDK.LauncherItem> BuildFilteredResults(
|
|
string textQuery, ParsedFilters filters, int maxResults)
|
|
{
|
|
var results = new List<SDK.LauncherItem>();
|
|
|
|
// 필터 힌트 항목 (상단 표시 — 어떤 필터가 적용 중인지 안내)
|
|
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<string>(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<IEnumerable<SDK.LauncherItem>> 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<SDK.LauncherItem>();
|
|
}
|
|
}
|
|
}
|