diff --git a/README.md b/README.md index 36cd371..8be7161 100644 --- a/README.md +++ b/README.md @@ -1318,3 +1318,7 @@ MIT License - 업데이트: 2026-04-06 18:09 (KST) - 채팅 메시지의 좋아요/싫어요 토글을 다시 정리했다. [ChatWindow.MessageInteractions.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.MessageInteractions.cs) 에서 두 버튼이 각자 상태를 따로 들고 있던 구조를 없애고, 하나의 shared feedback 상태(`like/dislike/null`)를 기준으로 상호배타 토글되도록 재구성했다. - 이제 `좋아요`도 즉시 색상/배경 상태가 바뀌고, `싫어요`를 다시 누르면 원래 상태(null)로 정상 해제된다. 버튼 시각 표현도 같은 glyph를 유지하되 active 색상과 라운드 chip 배경/테두리로 구분해, 특정 filled glyph가 보이지 않던 문제를 함께 줄였다. +- 업데이트: 2026-04-06 18:24 (KST) + - 런처 색인 구조를 임시 지연 실행에서 `영속 캐시 + watcher 증분 반영` 방식으로 바꿨다. [IndexService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/IndexService.cs)는 이제 `%APPDATA%\\AxCopilot\\index\\launcher-index.json`에 파일 시스템 인덱스를 저장하고, 앱 시작 시 캐시를 즉시 로드해 첫 검색부터 이전 색인을 재사용한다. + - `FileSystemWatcher`도 더 이상 파일 하나 바뀔 때마다 3초 뒤 전체 재빌드를 때리지 않고, 생성/삭제/파일 이름 변경은 가능한 범위에서 해당 항목만 증분 반영한다. 디렉터리 이름 변경처럼 하위 경로 전체 영향이 큰 경우에만 전체 재색인으로 폴백한다. + - [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs) 는 앱 시작 시 캐시 로드와 watcher 시작을 바로 수행하고, 실제 무거운 전체 재색인은 첫 검색 시 `EnsureIndexWarmupStarted()`로 한 번만 보강 실행하도록 정리했다. 이 변경으로 런처는 즉시 검색 가능 상태를 유지하면서도, 평소엔 전체 재색인 비용을 반복해서 치르지 않게 됐다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 91e58fc..e59c397 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4999,3 +4999,6 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - Document update: 2026-04-06 18:02 (KST) - Hardened vLLM response handling for IBM deployment endpoints by accepting `results[].generated_text`, `output_text`, and `choices[].message.content`, and by short-circuiting tool-use requests in `LlmService.ToolUse.cs` with a `ToolCallNotSupportedException` so IBM deployment chat connections do not receive an incompatible OpenAI function-calling payload. - Document update: 2026-04-06 18:09 (KST) - Reworked message feedback toggles in `ChatWindow.MessageInteractions.cs`. `좋아요/싫어요` no longer keep separate local state with sibling reset callbacks; both buttons now derive from one shared `like/dislike/null` state and persist that single value back to the conversation/session. - Document update: 2026-04-06 18:09 (KST) - The feedback buttons now use the same glyph in both idle/active states and express activation through color plus rounded chip background/border, which avoids cases where the like filled-glyph was visually missing and ensures pressing `싫어요` again properly returns the message to an unselected state. +- Document update: 2026-04-06 18:24 (KST) - Reworked launcher indexing in `IndexService.cs` from a “full rebuild on every change” structure to a persisted cache plus incremental watcher model. Launcher file-system entries are now saved to `%APPDATA%\AxCopilot\index\launcher-index.json`, loaded immediately at startup, and merged with aliases/built-in app entries before the first search. +- Document update: 2026-04-06 18:24 (KST) - `FileSystemWatcher` changes are now handled incrementally where possible. File create/delete/rename events update only the affected launcher index entries and debounce cache persistence, while high-impact directory rename/delete cases still fall back to a full rebuild for correctness. +- Document update: 2026-04-06 18:24 (KST) - `App.xaml.cs` now restores eager cache availability by calling `LoadCachedIndex()` and `StartWatchers()` at startup, while keeping the heavier full scan on the existing `EnsureIndexWarmupStarted()` path. This keeps the launcher responsive from the first open without reintroducing continuous full rescans as idle background work. diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 97a658a..1027d0d 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -115,6 +115,8 @@ public partial class App : System.Windows.Application _indexService = new IndexService(settings); var indexService = _indexService; + indexService.LoadCachedIndex(); + indexService.StartWatchers(); var fuzzyEngine = new FuzzyEngine(indexService); var commandResolver = new CommandResolver(fuzzyEngine, settings); var contextManager = new ContextManager(settings); @@ -288,8 +290,9 @@ public partial class App : System.Windows.Application () => _clipboardHistory?.Initialize(), System.Windows.Threading.DispatcherPriority.ApplicationIdle); - // ─── 인덱스 빌드/감시는 실제 런처를 사용할 때 시작 ─────────────────── - // 앱 시작 직후 전체 스캔과 감시 훅을 붙이지 않아 유휴 체감 부하를 줄임 + // ─── 런처 인덱스 캐시/감시 초기화 ───────────────────────────────────── + // 앱 시작 시엔 저장된 캐시를 즉시 로드하고, 파일 감시는 증분 반영 위주로 유지합니다. + // 무거운 전체 재색인은 실제 검색이 시작될 때 한 번만 보강 실행합니다. // ─── 글로벌 훅 + 스니펫 확장기 ─────────────────────────────────────── _inputListener = new InputListener(); diff --git a/src/AxCopilot/Services/IndexService.cs b/src/AxCopilot/Services/IndexService.cs index ac44b04..eb5ec97 100644 --- a/src/AxCopilot/Services/IndexService.cs +++ b/src/AxCopilot/Services/IndexService.cs @@ -1,36 +1,92 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO; +using System.Text; +using System.Text.Json; using AxCopilot.Models; namespace AxCopilot.Services; /// /// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다. -/// FileSystemWatcher로 변경 시 자동 재빌드(3초 디바운스)합니다. +/// 파일 시스템 결과는 디스크 캐시에 영속 저장하고, FileSystemWatcher 이벤트는 +/// 가능한 범위에서 증분 반영하여 전체 재색인 비용을 줄입니다. /// public class IndexService : IDisposable { private readonly SettingsService _settings; - private List _index = new(); private readonly List _watchers = new(); - private System.Threading.Timer? _debounceTimer; private readonly object _timerLock = new(); - private const int DebounceMs = 3000; + private readonly object _indexLock = new(); private readonly SemaphoreSlim _rebuildLock = new(1, 1); + private System.Threading.Timer? _rebuildTimer; + private System.Threading.Timer? _persistTimer; + private List _fileSystemEntries = new(); + private List _index = new(); + private const int RebuildDebounceMs = 3000; + private const int PersistDebounceMs = 800; + private static readonly JsonSerializerOptions CacheJsonOptions = new() + { + WriteIndented = false, + PropertyNameCaseInsensitive = true + }; public IReadOnlyList Entries => _index; public event EventHandler? IndexRebuilt; public TimeSpan LastIndexDuration { get; private set; } public int LastIndexCount { get; private set; } + public bool HasCachedIndexLoaded { get; private set; } public IndexService(SettingsService settings) { _settings = settings; } + public void LoadCachedIndex() + { + try + { + var cachePath = GetCachePath(); + if (!File.Exists(cachePath)) + return; + + var cache = JsonSerializer.Deserialize(File.ReadAllText(cachePath), CacheJsonOptions); + if (cache == null) + return; + + var currentSignature = BuildCacheSignature(); + if (!string.Equals(cache.Signature, currentSignature, StringComparison.Ordinal)) + { + LogService.Info("런처 인덱스 캐시 무효화: 설정 서명이 변경되었습니다."); + return; + } + + var entries = cache.Entries + .Select(FromCacheEntry) + .Where(static e => !string.IsNullOrWhiteSpace(e.Name) && !string.IsNullOrWhiteSpace(e.Path)) + .ToList(); + + ComputeAllSearchCaches(entries); + + lock (_indexLock) + { + _fileSystemEntries = entries; + PublishIndexSnapshot_NoLock(); + } + + HasCachedIndexLoaded = entries.Count > 0; + LastIndexDuration = TimeSpan.Zero; + LastIndexCount = _index.Count; + LogService.Info($"런처 인덱스 캐시 로드: {entries.Count}개 파일 시스템 항목"); + } + catch (Exception ex) + { + LogService.Warn($"런처 인덱스 캐시 로드 실패: {ex.Message}"); + } + } + /// - /// 앱 시작 시 전체 인덱스 빌드 후 FileSystemWatcher 시작. + /// 전체 파일 시스템 항목을 다시 스캔한 뒤 디스크 캐시를 갱신합니다. /// public async Task BuildAsync(CancellationToken ct = default) { @@ -38,52 +94,33 @@ public class IndexService : IDisposable var sw = Stopwatch.StartNew(); try { - var entries = new List(); - var paths = _settings.Settings.IndexPaths - .Select(p => Environment.ExpandEnvironmentVariables(p)); - var allowedExts = new HashSet( - _settings.Settings.IndexExtensions - .Select(e => e.ToLowerInvariant().StartsWith(".") ? e.ToLowerInvariant() : "." + e.ToLowerInvariant()), - StringComparer.OrdinalIgnoreCase); - + var fileSystemEntries = new List(); + var paths = GetExpandedIndexPaths(); + var allowedExts = GetAllowedExtensions(); var indexSpeed = _settings.Settings.IndexSpeed ?? "normal"; foreach (var dir in paths) { - if (!Directory.Exists(dir)) continue; - await ScanDirectoryAsync(dir, entries, allowedExts, indexSpeed, ct); + if (!Directory.Exists(dir)) + continue; + + await ScanDirectoryAsync(dir, fileSystemEntries, allowedExts, indexSpeed, ct); } - // Alias도 인덱스에 포함 (type에 따라 IndexEntryType 구분) - foreach (var alias in _settings.Settings.Aliases) + ComputeAllSearchCaches(fileSystemEntries); + + lock (_indexLock) { - entries.Add(new IndexEntry - { - Name = alias.Key, - DisplayName = alias.Description ?? alias.Key, - Path = alias.Target, - AliasType = alias.Type, - Type = alias.Type switch - { - "app" => IndexEntryType.App, - "folder" => IndexEntryType.Folder, - _ => IndexEntryType.Alias // url, batch, api, clipboard - }, - Score = 100 // Alias는 최우선 - }); + _fileSystemEntries = fileSystemEntries; + PublishIndexSnapshot_NoLock(); } - // Built-in 앱 별칭 (한글+영문 이름으로 즉시 실행) - RegisterBuiltInApps(entries); - - // 검색 가속 캐시 일괄 계산 (ToLower·자모·초성을 빌드 시 1회만 수행) - ComputeAllSearchCaches(entries); - - _index = entries; sw.Stop(); LastIndexDuration = sw.Elapsed; - LastIndexCount = entries.Count; - LogService.Info($"인덱싱 완료: {entries.Count}개 항목 ({sw.Elapsed.TotalSeconds:F1}초)"); + LastIndexCount = _index.Count; + HasCachedIndexLoaded = fileSystemEntries.Count > 0; + PersistCache(); + LogService.Info($"런처 인덱싱 완료: {fileSystemEntries.Count}개 파일 시스템 항목 ({sw.Elapsed.TotalSeconds:F1}초)"); IndexRebuilt?.Invoke(this, EventArgs.Empty); } finally @@ -94,30 +131,31 @@ public class IndexService : IDisposable /// /// 인덱스 경로에 대한 FileSystemWatcher를 시작합니다. - /// BuildAsync() 완료 후 호출하세요. + /// 캐시 로드 후에도 바로 호출해 증분 반영을 유지합니다. /// public void StartWatchers() { StopWatchers(); - foreach (var rawPath in _settings.Settings.IndexPaths) + foreach (var dir in GetExpandedIndexPaths()) { - var dir = Environment.ExpandEnvironmentVariables(rawPath); - if (!Directory.Exists(dir)) continue; + if (!Directory.Exists(dir)) + continue; try { - var w = new FileSystemWatcher(dir) + var watcher = new FileSystemWatcher(dir) { - Filter = "*.*", + Filter = "*", IncludeSubdirectories = true, - NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName, - EnableRaisingEvents = true + NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName, + EnableRaisingEvents = true }; - w.Created += OnWatcherEvent; - w.Deleted += OnWatcherEvent; - w.Renamed += OnWatcherRenamed; - _watchers.Add(w); + + watcher.Created += OnWatcherEvent; + watcher.Deleted += OnWatcherEvent; + watcher.Renamed += OnWatcherRenamed; + _watchers.Add(watcher); } catch (Exception ex) { @@ -125,45 +163,293 @@ public class IndexService : IDisposable } } - LogService.Info($"파일 감시 시작: {_watchers.Count}개 경로"); + LogService.Info($"런처 파일 감시 시작: {_watchers.Count}개 경로"); } + private IEnumerable GetExpandedIndexPaths() => + _settings.Settings.IndexPaths + .Select(path => Environment.ExpandEnvironmentVariables(path)) + .Distinct(StringComparer.OrdinalIgnoreCase); + + private HashSet GetAllowedExtensions() => + new( + _settings.Settings.IndexExtensions + .Select(ext => ext.ToLowerInvariant().StartsWith(".") ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant()), + StringComparer.OrdinalIgnoreCase); + private void StopWatchers() { - foreach (var w in _watchers) + foreach (var watcher in _watchers) { - w.EnableRaisingEvents = false; - w.Dispose(); + watcher.EnableRaisingEvents = false; + watcher.Dispose(); } + _watchers.Clear(); } private void OnWatcherEvent(object sender, FileSystemEventArgs e) { + var rootPath = (sender as FileSystemWatcher)?.Path; + if (TryApplyIncrementalChange(e.ChangeType, e.FullPath, rootPath)) + return; + ScheduleRebuild(e.FullPath); } private void OnWatcherRenamed(object sender, RenamedEventArgs e) { + var rootPath = (sender as FileSystemWatcher)?.Path; + if (TryApplyIncrementalRename(e.OldFullPath, e.FullPath, rootPath)) + return; + ScheduleRebuild(e.FullPath); } + private bool TryApplyIncrementalChange(WatcherChangeTypes changeType, string fullPath, string? rootPath) + { + try + { + if (string.IsNullOrWhiteSpace(rootPath)) + return false; + + // 디렉터리 삭제/이름변경은 하위 파일 경로까지 바뀔 수 있어 전체 재색인으로 폴백합니다. + if (changeType == WatcherChangeTypes.Deleted && Path.GetExtension(fullPath).Length == 0) + return false; + + var changed = changeType switch + { + WatcherChangeTypes.Created => AddPathEntryIncrementally(fullPath, rootPath), + WatcherChangeTypes.Deleted => RemovePathEntriesIncrementally(fullPath), + _ => false + }; + + if (!changed) + return false; + + AfterIncrementalUpdate(fullPath); + return true; + } + catch (Exception ex) + { + LogService.Warn($"런처 인덱스 증분 반영 실패: {fullPath} - {ex.Message}"); + return false; + } + } + + private bool TryApplyIncrementalRename(string oldFullPath, string newFullPath, string? rootPath) + { + try + { + if (string.IsNullOrWhiteSpace(rootPath)) + return false; + + if (Directory.Exists(newFullPath)) + return false; + + var removed = RemovePathEntriesIncrementally(oldFullPath); + var added = AddPathEntryIncrementally(newFullPath, rootPath); + + if (!removed && !added) + return false; + + AfterIncrementalUpdate(newFullPath); + return true; + } + catch (Exception ex) + { + LogService.Warn($"런처 인덱스 이름변경 증분 반영 실패: {newFullPath} - {ex.Message}"); + return false; + } + } + + private void AfterIncrementalUpdate(string triggerPath) + { + lock (_indexLock) + PublishIndexSnapshot_NoLock(); + + PersistCacheDeferred(); + LastIndexCount = _index.Count; + IndexRebuilt?.Invoke(this, EventArgs.Empty); + LogService.Info($"런처 인덱스 증분 갱신: {triggerPath}"); + } + + private bool AddPathEntryIncrementally(string fullPath, string rootPath) + { + var updated = false; + + lock (_indexLock) + { + var snapshot = CloneEntries(_fileSystemEntries); + + if (Directory.Exists(fullPath)) + { + if (IsTopLevelDirectory(rootPath, fullPath)) + updated = UpsertEntry(snapshot, CreateFolderEntry(fullPath)); + } + else if (File.Exists(fullPath)) + { + var ext = Path.GetExtension(fullPath).ToLowerInvariant(); + var allowedExts = GetAllowedExtensions(); + if (allowedExts.Count == 0 || allowedExts.Contains(ext)) + updated = UpsertEntry(snapshot, CreateFileEntry(fullPath)); + } + + if (!updated) + return false; + + ComputeAllSearchCaches(snapshot); + _fileSystemEntries = snapshot; + } + + return true; + } + + private bool RemovePathEntriesIncrementally(string fullPath) + { + var normalizedPath = NormalizePath(fullPath); + lock (_indexLock) + { + var snapshot = CloneEntries(_fileSystemEntries); + var removed = snapshot.RemoveAll(entry => + string.Equals(NormalizePath(entry.Path), normalizedPath, StringComparison.OrdinalIgnoreCase)) > 0; + + if (!removed) + return false; + + _fileSystemEntries = snapshot; + return true; + } + } + + private static bool UpsertEntry(List entries, IndexEntry newEntry) + { + var normalizedPath = NormalizePath(newEntry.Path); + var index = entries.FindIndex(entry => + string.Equals(NormalizePath(entry.Path), normalizedPath, StringComparison.OrdinalIgnoreCase)); + + if (index >= 0) + { + entries[index] = newEntry; + return true; + } + + entries.Add(newEntry); + return true; + } + private void ScheduleRebuild(string triggerPath) { - LogService.Info($"파일 변경 감지: {triggerPath} — {DebounceMs}ms 후 재빌드 예약"); + LogService.Info($"파일 변경 감지: {triggerPath} — {RebuildDebounceMs}ms 후 전체 재빌드 예약"); lock (_timerLock) { - _debounceTimer?.Dispose(); - _debounceTimer = new System.Threading.Timer(__ => - { - _ = BuildAsync(); - }, null, DebounceMs, System.Threading.Timeout.Infinite); + _rebuildTimer?.Dispose(); + _rebuildTimer = new System.Threading.Timer(_ => _ = BuildAsync(), null, RebuildDebounceMs, System.Threading.Timeout.Infinite); } } + private void PersistCacheDeferred() + { + lock (_timerLock) + { + _persistTimer?.Dispose(); + _persistTimer = new System.Threading.Timer(_ => PersistCache(), null, PersistDebounceMs, System.Threading.Timeout.Infinite); + } + } + + private void PersistCache() + { + try + { + List cacheEntries; + lock (_indexLock) + { + cacheEntries = _fileSystemEntries + .Select(ToCacheEntry) + .ToList(); + } + + var cache = new LauncherIndexCache + { + Signature = BuildCacheSignature(), + SavedAtUtc = DateTime.UtcNow, + Entries = cacheEntries + }; + + var cachePath = GetCachePath(); + Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); + File.WriteAllText(cachePath, JsonSerializer.Serialize(cache, CacheJsonOptions)); + } + catch (Exception ex) + { + LogService.Warn($"런처 인덱스 캐시 저장 실패: {ex.Message}"); + } + } + + private string GetCachePath() => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", + "index", + "launcher-index.json"); + + private string BuildCacheSignature() + { + var pathPart = string.Join("|", GetExpandedIndexPaths() + .Select(path => NormalizePath(path)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)); + + var extPart = string.Join("|", GetAllowedExtensions() + .OrderBy(ext => ext, StringComparer.OrdinalIgnoreCase)); + + return $"{pathPart}::{extPart}"; + } + + private void PublishIndexSnapshot_NoLock() + { + var entries = CloneEntries(_fileSystemEntries); + + foreach (var alias in _settings.Settings.Aliases) + { + entries.Add(new IndexEntry + { + Name = alias.Key, + DisplayName = alias.Description ?? alias.Key, + Path = alias.Target, + AliasType = alias.Type, + Type = alias.Type switch + { + "app" => IndexEntryType.App, + "folder" => IndexEntryType.Folder, + _ => IndexEntryType.Alias + }, + Score = 100 + }); + } + + RegisterBuiltInApps(entries); + ComputeAllSearchCaches(entries); + _index = entries; + } + + private static List CloneEntries(IEnumerable entries) => + entries.Select(entry => new IndexEntry + { + Name = entry.Name, + DisplayName = entry.DisplayName, + Path = entry.Path, + Type = entry.Type, + Score = entry.Score, + AliasType = entry.AliasType, + NameLower = entry.NameLower, + NameJamo = entry.NameJamo, + NameChosung = entry.NameChosung + }).ToList(); + public void Dispose() { - _debounceTimer?.Dispose(); + _rebuildTimer?.Dispose(); + _persistTimer?.Dispose(); StopWatchers(); } @@ -176,153 +462,57 @@ public class IndexService : IDisposable // (displayName, keywords[], exePath) var builtIns = new (string Display, string[] Names, string? Exe)[] { - // 메모장 - ("메모장 (Notepad)", new[] { "메모장", "notepad", "note", "txt" }, - @"C:\Windows\notepad.exe"), - // 계산기 - ("계산기 (Calculator)", new[] { "계산기", "calc", "calculator" }, - "calculator:"), // UWP protocol - // 캡처 도구 (Snipping Tool) - ("캡처 도구 (Snipping Tool)", new[] { "캡처", "캡처도구", "snippingtool", "snip", "스크린샷" }, - @"C:\Windows\System32\SnippingTool.exe"), - // 그림판 - ("그림판 (Paint)", new[] { "그림판", "mspaint", "paint" }, - @"C:\Windows\System32\mspaint.exe"), - // 탐색기 - ("파일 탐색기 (Explorer)", new[] { "탐색기", "explorer", "파일탐색기" }, - @"C:\Windows\explorer.exe"), - // 명령 프롬프트 - ("명령 프롬프트 (CMD)", new[] { "cmd", "명령프롬프트", "커맨드", "터미널" }, - @"C:\Windows\System32\cmd.exe"), - // PowerShell - ("PowerShell", new[] { "powershell", "파워쉘" }, - @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"), - // 제어판 - ("제어판 (Control Panel)", new[] { "제어판", "control", "controlpanel" }, - @"C:\Windows\System32\control.exe"), - // 작업 관리자 - ("작업 관리자 (Task Manager)", new[] { "작업관리자", "taskmgr", "taskmanager" }, - @"C:\Windows\System32\Taskmgr.exe"), - // 원격 데스크톱 - ("원격 데스크톱 (Remote Desktop)", new[] { "원격", "mstsc", "rdp", "원격데스크톱" }, - @"C:\Windows\System32\mstsc.exe"), - // 레지스트리 편집기 - ("레지스트리 편집기", new[] { "regedit", "레지스트리" }, - @"C:\Windows\regedit.exe"), - // 장치 관리자 - ("장치 관리자", new[] { "장치관리자", "devmgmt", "devicemanager" }, - @"C:\Windows\System32\devmgmt.msc"), - // Microsoft Edge - ("Microsoft Edge", new[] { "edge", "엣지", "브라우저" }, - @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"), - // Excel — 확장자 + MS prefix + 한글 - ("Microsoft Excel", new[] { "엑셀", "excel", "msexcel", "xlsx", "xls", "csv" }, - FindOfficeApp("EXCEL.EXE")), - // PowerPoint - ("Microsoft PowerPoint", new[] { "파워포인트", "powerpoint", "mspowerpoint", "pptx", "ppt" }, - FindOfficeApp("POWERPNT.EXE")), - // Word - ("Microsoft Word", new[] { "워드", "word", "msword", "docx", "doc" }, - FindOfficeApp("WINWORD.EXE")), - // Outlook - ("Microsoft Outlook", new[] { "아웃룩", "outlook", "메일" }, - FindOfficeApp("OUTLOOK.EXE")), - // Teams - ("Microsoft Teams", new[] { "팀즈", "teams" }, - FindTeams()), - // OneNote - ("Microsoft OneNote", new[] { "원노트", "onenote" }, - FindOfficeApp("ONENOTE.EXE")), - // Access - ("Microsoft Access", new[] { "액세스", "access", "msaccess" }, - FindOfficeApp("MSACCESS.EXE")), - // VS Code - ("Visual Studio Code", new[] { "vscode", "비주얼스튜디오코드", "코드에디터", "code" }, - FindInPath("code.cmd") ?? FindInLocalAppData(@"Programs\Microsoft VS Code\Code.exe")), - // Visual Studio + ("메모장 (Notepad)", new[] { "메모장", "notepad", "note", "txt" }, @"C:\Windows\notepad.exe"), + ("계산기 (Calculator)", new[] { "계산기", "calc", "calculator" }, "calculator:"), + ("캡처 도구 (Snipping Tool)", new[] { "캡처", "캡처도구", "snippingtool", "snip", "스크린샷" }, @"C:\Windows\System32\SnippingTool.exe"), + ("그림판 (Paint)", new[] { "그림판", "mspaint", "paint" }, @"C:\Windows\System32\mspaint.exe"), + ("파일 탐색기 (Explorer)", new[] { "탐색기", "explorer", "파일탐색기" }, @"C:\Windows\explorer.exe"), + ("명령 프롬프트 (CMD)", new[] { "cmd", "명령프롬프트", "커맨드", "터미널" }, @"C:\Windows\System32\cmd.exe"), + ("PowerShell", new[] { "powershell", "파워쉘" }, @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"), + ("제어판 (Control Panel)", new[] { "제어판", "control", "controlpanel" }, @"C:\Windows\System32\control.exe"), + ("작업 관리자 (Task Manager)", new[] { "작업관리자", "taskmgr", "taskmanager" }, @"C:\Windows\System32\Taskmgr.exe"), + ("원격 데스크톱 (Remote Desktop)", new[] { "원격", "mstsc", "rdp", "원격데스크톱" }, @"C:\Windows\System32\mstsc.exe"), + ("레지스트리 편집기", new[] { "regedit", "레지스트리" }, @"C:\Windows\regedit.exe"), + ("장치 관리자", new[] { "장치관리자", "devmgmt", "devicemanager" }, @"C:\Windows\System32\devmgmt.msc"), + ("Microsoft Edge", new[] { "edge", "엣지", "브라우저" }, @"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"), + ("Microsoft Excel", new[] { "엑셀", "excel", "msexcel", "xlsx", "xls", "csv" }, FindOfficeApp("EXCEL.EXE")), + ("Microsoft PowerPoint", new[] { "파워포인트", "powerpoint", "mspowerpoint", "pptx", "ppt" }, FindOfficeApp("POWERPNT.EXE")), + ("Microsoft Word", new[] { "워드", "word", "msword", "docx", "doc" }, FindOfficeApp("WINWORD.EXE")), + ("Microsoft Outlook", new[] { "아웃룩", "outlook", "메일" }, FindOfficeApp("OUTLOOK.EXE")), + ("Microsoft Teams", new[] { "팀즈", "teams" }, FindTeams()), + ("Microsoft OneNote", new[] { "원노트", "onenote" }, FindOfficeApp("ONENOTE.EXE")), + ("Microsoft Access", new[] { "액세스", "access", "msaccess" }, FindOfficeApp("MSACCESS.EXE")), + ("Visual Studio Code", new[] { "vscode", "비주얼스튜디오코드", "코드에디터", "code" }, FindInPath("code.cmd") ?? FindInLocalAppData(@"Programs\Microsoft VS Code\Code.exe")), ("Visual Studio", new[] { "비주얼스튜디오", "devenv", "vs2022", "vs2019", "visualstudio" }, FindInProgramFiles(@"Microsoft Visual Studio\2022\Community\Common7\IDE\devenv.exe") ?? FindInProgramFiles(@"Microsoft Visual Studio\2022\Professional\Common7\IDE\devenv.exe") ?? FindInProgramFiles(@"Microsoft Visual Studio\2022\Enterprise\Common7\IDE\devenv.exe")), - // Windows Terminal - ("Windows Terminal", new[] { "윈도우터미널", "wt", "windowsterminal", "터미널" }, - FindInLocalAppData(@"Microsoft\WindowsApps\wt.exe")), - // OneDrive - ("OneDrive", new[] { "원드라이브", "onedrive" }, - FindInLocalAppData(@"Microsoft\OneDrive\OneDrive.exe")), - // Google Chrome - ("Google Chrome", new[] { "크롬", "구글크롬", "chrome", "google" }, - FindInProgramFiles(@"Google\Chrome\Application\chrome.exe") - ?? FindInLocalAppData(@"Google\Chrome\Application\chrome.exe")), - // Firefox - ("Mozilla Firefox", new[] { "파이어폭스", "불여우", "firefox" }, - FindInProgramFiles(@"Mozilla Firefox\firefox.exe")), - // Naver Whale - ("Naver Whale", new[] { "웨일", "네이버웨일", "whale" }, - FindInLocalAppData(@"Naver\Naver Whale\Application\whale.exe")), - // KakaoTalk - ("KakaoTalk", new[] { "카카오톡", "카톡", "kakaotalk", "kakao" }, - FindInLocalAppData(@"Kakao\KakaoTalk\KakaoTalk.exe")), - // KakaoWork - ("KakaoWork", new[] { "카카오워크", "카워크", "kakaowork" }, - FindInLocalAppData(@"Kakao\KakaoWork\KakaoWork.exe")), - // Zoom - ("Zoom", new[] { "줌", "zoom" }, - FindInRoaming(@"Zoom\bin\Zoom.exe") - ?? FindInLocalAppData(@"Zoom\bin\Zoom.exe")), - // Slack - ("Slack", new[] { "슬랙", "slack" }, - FindInLocalAppData(@"slack\slack.exe")), - // Figma - ("Figma", new[] { "피그마", "figma" }, - FindInLocalAppData(@"Figma\Figma.exe")), - // Notepad++ - ("Notepad++", new[] { "노트패드++", "npp", "notepad++" }, - FindInProgramFiles(@"Notepad++\notepad++.exe")), - // 7-Zip - ("7-Zip", new[] { "7zip", "7집", "세븐집", "7z" }, - FindInProgramFiles(@"7-Zip\7zFM.exe")), - // Bandizip - ("Bandizip", new[] { "반디집", "bandizip" }, - FindInProgramFiles(@"Bandizip\Bandizip.exe") - ?? FindInLocalAppData(@"Bandizip\Bandizip.exe")), - // ALZip - ("ALZip", new[] { "알집", "alzip", "이스트소프트" }, - FindInProgramFiles(@"ESTsoft\ALZip\ALZip.exe")), - // PotPlayer - ("PotPlayer", new[] { "팟플레이어", "팟플", "potplayer" }, - FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini64.exe") - ?? FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini.exe")), - // GOM Player - ("GOM Player", new[] { "곰플레이어", "곰플", "gomplayer", "gom" }, - FindInProgramFiles(@"GRETECH\GomPlayer\GOM.EXE")), - // Adobe Photoshop - ("Adobe Photoshop", new[] { "포토샵", "포샵", "photoshop", "ps" }, - FindInProgramFiles(@"Adobe\Adobe Photoshop 2025\Photoshop.exe") - ?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2024\Photoshop.exe") - ?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2023\Photoshop.exe")), - // Adobe Acrobat - ("Adobe Acrobat", new[] { "아크로뱃", "acrobat", "아도비pdf" }, - FindInProgramFiles(@"Adobe\Acrobat DC\Acrobat\Acrobat.exe")), - // 한컴 한글 - ("한컴 한글", new[] { "한글", "한컴한글", "hwp", "hangul", "아래아한글" }, - FindInProgramFiles(@"HNC\Hwp\Hwp.exe") - ?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hwp.exe") - ?? FindInProgramFiles(@"Hnc\Hwp80\Hwp.exe")), - // 한컴 한셀 - ("한컴 한셀", new[] { "한셀", "hcell", "한컴스프레드" }, - FindInProgramFiles(@"HNC\Hwp\Hcell.exe") - ?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hcell.exe")), - // 한컴 한쇼 - ("한컴 한쇼", new[] { "한쇼", "hshow", "한컴프레젠테이션" }, - FindInProgramFiles(@"HNC\Hwp\Show.exe") - ?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Show.exe")), + ("Windows Terminal", new[] { "윈도우터미널", "wt", "windowsterminal", "터미널" }, FindInLocalAppData(@"Microsoft\WindowsApps\wt.exe")), + ("OneDrive", new[] { "원드라이브", "onedrive" }, FindInLocalAppData(@"Microsoft\OneDrive\OneDrive.exe")), + ("Google Chrome", new[] { "크롬", "구글크롬", "chrome", "google" }, FindInProgramFiles(@"Google\Chrome\Application\chrome.exe") ?? FindInLocalAppData(@"Google\Chrome\Application\chrome.exe")), + ("Mozilla Firefox", new[] { "파이어폭스", "불여우", "firefox" }, FindInProgramFiles(@"Mozilla Firefox\firefox.exe")), + ("Naver Whale", new[] { "웨일", "네이버웨일", "whale" }, FindInLocalAppData(@"Naver\Naver Whale\Application\whale.exe")), + ("KakaoTalk", new[] { "카카오톡", "카톡", "kakaotalk", "kakao" }, FindInLocalAppData(@"Kakao\KakaoTalk\KakaoTalk.exe")), + ("KakaoWork", new[] { "카카오워크", "카워크", "kakaowork" }, FindInLocalAppData(@"Kakao\KakaoWork\KakaoWork.exe")), + ("Zoom", new[] { "줌", "zoom" }, FindInRoaming(@"Zoom\bin\Zoom.exe") ?? FindInLocalAppData(@"Zoom\bin\Zoom.exe")), + ("Slack", new[] { "슬랙", "slack" }, FindInLocalAppData(@"slack\slack.exe")), + ("Figma", new[] { "피그마", "figma" }, FindInLocalAppData(@"Figma\Figma.exe")), + ("Notepad++", new[] { "노트패드++", "npp", "notepad++" }, FindInProgramFiles(@"Notepad++\notepad++.exe")), + ("7-Zip", new[] { "7zip", "7집", "세븐집", "7z" }, FindInProgramFiles(@"7-Zip\7zFM.exe")), + ("Bandizip", new[] { "반디집", "bandizip" }, FindInProgramFiles(@"Bandizip\Bandizip.exe") ?? FindInLocalAppData(@"Bandizip\Bandizip.exe")), + ("ALZip", new[] { "알집", "alzip", "이스트소프트" }, FindInProgramFiles(@"ESTsoft\ALZip\ALZip.exe")), + ("PotPlayer", new[] { "팟플레이어", "팟플", "potplayer" }, FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini64.exe") ?? FindInProgramFiles(@"DAUM\PotPlayer\PotPlayerMini.exe")), + ("GOM Player", new[] { "곰플레이어", "곰플", "gomplayer", "gom" }, FindInProgramFiles(@"GRETECH\GomPlayer\GOM.EXE")), + ("Adobe Photoshop", new[] { "포토샵", "포샵", "photoshop", "ps" }, FindInProgramFiles(@"Adobe\Adobe Photoshop 2025\Photoshop.exe") ?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2024\Photoshop.exe") ?? FindInProgramFiles(@"Adobe\Adobe Photoshop 2023\Photoshop.exe")), + ("Adobe Acrobat", new[] { "아크로뱃", "acrobat", "아도비pdf" }, FindInProgramFiles(@"Adobe\Acrobat DC\Acrobat\Acrobat.exe")), + ("한컴 한글", new[] { "한글", "한컴한글", "hwp", "hangul", "아래아한글" }, FindInProgramFiles(@"HNC\Hwp\Hwp.exe") ?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hwp.exe") ?? FindInProgramFiles(@"Hnc\Hwp80\Hwp.exe")), + ("한컴 한셀", new[] { "한셀", "hcell", "한컴스프레드" }, FindInProgramFiles(@"HNC\Hwp\Hcell.exe") ?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Hcell.exe")), + ("한컴 한쇼", new[] { "한쇼", "hshow", "한컴프레젠테이션" }, FindInProgramFiles(@"HNC\Hwp\Show.exe") ?? FindInProgramFiles(@"HNC\Office NEO\HOffice NEO\Bin\Show.exe")), }; - // 기존 항목의 이름 → 인덱스 매핑 + Path 기반 중복 방지 var nameToIdx = new Dictionary(StringComparer.OrdinalIgnoreCase); var pathToIdx = new Dictionary(StringComparer.OrdinalIgnoreCase); - for (int i = 0; i < entries.Count; i++) + for (var i = 0; i < entries.Count; i++) { nameToIdx.TryAdd(entries[i].Name, i); pathToIdx.TryAdd(entries[i].Path, i); @@ -330,45 +520,39 @@ public class IndexService : IDisposable foreach (var (display, names, exe) in builtIns) { - if (string.IsNullOrEmpty(exe)) continue; - // UWP protocol은 파일 존재 체크 불필요 - if (!exe.Contains(":") && !File.Exists(exe) && !exe.EndsWith(".msc")) continue; + if (string.IsNullOrEmpty(exe)) + continue; + + if (!exe.Contains(":") && !File.Exists(exe) && !exe.EndsWith(".msc", StringComparison.OrdinalIgnoreCase)) + continue; - // 같은 경로가 이미 인덱스에 있으면 DisplayName과 Score만 업데이트 if (pathToIdx.TryGetValue(exe, out var existingIdx)) { entries[existingIdx].DisplayName = display; - if (entries[existingIdx].Score < 95) entries[existingIdx].Score = 95; + if (entries[existingIdx].Score < 95) + entries[existingIdx].Score = 95; } - // ── 키워드별 IndexEntry 등록 ───────────────────────────────────────── - // - 한글 이름: 항상 IndexEntry로 추가 (앱이 이미 스캔됐어도) - // → "엑" 입력 시 "엑셀" IndexEntry의 StartsWith("엑")으로 매칭 - // - 영문/약어: 앱이 미스캔 상태일 때 첫 키워드만 대표 항목으로 등록 - // - 경로 중복은 CommandResolver 레이어에서 seenPaths로 최고점만 표시 - bool mainAdded = pathToIdx.ContainsKey(exe); + var mainAdded = pathToIdx.ContainsKey(exe); foreach (var name in names) { if (nameToIdx.ContainsKey(name)) { - // 이미 같은 이름 존재 → Score만 부스트 if (nameToIdx.TryGetValue(name, out var idx) && entries[idx].Score < 95) entries[idx].Score = 95; continue; } - // 한글 음절이 포함된 이름은 항상 IndexEntry로 추가 - bool isKoreanName = name.Any(c => c >= '\uAC00' && c <= '\uD7A3'); - + var isKoreanName = name.Any(static c => c is >= '\uAC00' and <= '\uD7A3'); if (!mainAdded || isKoreanName) { entries.Add(new IndexEntry { - Name = name, + Name = name, DisplayName = display, - Path = exe, - Type = IndexEntryType.App, - Score = 95 + Path = exe, + Type = IndexEntryType.App, + Score = 95 }); var newIdx = entries.Count - 1; nameToIdx[name] = newIdx; @@ -380,7 +564,6 @@ public class IndexService : IDisposable } else { - // 영문/약어는 pathToIdx 참조만 (FuzzyEngine은 영문 앱명 직접 매칭) nameToIdx[name] = pathToIdx[exe]; } } @@ -394,13 +577,16 @@ public class IndexService : IDisposable @"C:\Program Files\Microsoft Office\root\Office16", @"C:\Program Files (x86)\Microsoft Office\root\Office16", @"C:\Program Files\Microsoft Office\Office16", - @"C:\Program Files (x86)\Microsoft Office\Office16", + @"C:\Program Files (x86)\Microsoft Office\Office16" }; + foreach (var root in roots) { var path = Path.Combine(root, exeName); - if (File.Exists(path)) return path; + if (File.Exists(path)) + return path; } + return null; } @@ -408,8 +594,9 @@ public class IndexService : IDisposable { var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var path = Path.Combine(localApp, @"Microsoft\Teams\current\Teams.exe"); - if (File.Exists(path)) return path; - // New Teams (MSIX) + if (File.Exists(path)) + return path; + var msTeams = Path.Combine(localApp, @"Microsoft\WindowsApps\ms-teams.exe"); return File.Exists(msTeams) ? msTeams : null; } @@ -419,10 +606,14 @@ public class IndexService : IDisposable var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; foreach (var dir in pathEnv.Split(';')) { - if (string.IsNullOrWhiteSpace(dir)) continue; + if (string.IsNullOrWhiteSpace(dir)) + continue; + var full = Path.Combine(dir.Trim(), fileName); - if (File.Exists(full)) return full; + if (File.Exists(full)) + return full; } + return null; } @@ -433,18 +624,18 @@ public class IndexService : IDisposable return File.Exists(path) ? path : null; } - /// ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다. private static string? FindInProgramFiles(string relativePath) { - var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); var pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); - var p1 = Path.Combine(pf, relativePath); - if (File.Exists(p1)) return p1; - var p2 = Path.Combine(pf86, relativePath); - return File.Exists(p2) ? p2 : null; + var path1 = Path.Combine(pf, relativePath); + if (File.Exists(path1)) + return path1; + + var path2 = Path.Combine(pf86, relativePath); + return File.Exists(path2) ? path2 : null; } - /// AppData\Roaming 경로를 탐색합니다. private static string? FindInRoaming(string relativePath) { var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); @@ -452,41 +643,41 @@ public class IndexService : IDisposable return File.Exists(path) ? path : null; } - // ─── 검색 가속 캐시 계산 ────────────────────────────────────────────────── - - /// 빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다. private static void ComputeAllSearchCaches(List entries) { - foreach (var e in entries) - ComputeSearchCache(e); + foreach (var entry in entries) + ComputeSearchCache(entry); } - /// 항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다. private static void ComputeSearchCache(IndexEntry entry) { entry.NameLower = entry.Name.ToLowerInvariant(); - // 자모 분리 (FuzzyEngine static 메서드 — 동일 어셈블리 internal 접근) entry.NameJamo = AxCopilot.Core.FuzzyEngine.DecomposeToJamo(entry.NameLower); - // 초성 문자열 (예: "엑셀" → "ㅇㅅ", "메모장" → "ㅁㅁㅈ") - var sb = new System.Text.StringBuilder(entry.NameLower.Length); + + var sb = new StringBuilder(entry.NameLower.Length); foreach (var c in entry.NameLower) { var cho = AxCopilot.Core.FuzzyEngine.GetChosung(c); - if (cho != '\0') sb.Append(cho); + if (cho != '\0') + sb.Append(cho); } + entry.NameChosung = sb.ToString(); } - /// 인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield. - private static (int batchSize, int delayMs) GetThrottle(string speed) => speed switch + private static (int BatchSize, int DelayMs) GetThrottle(string speed) => speed switch { - "fast" => (500, 0), // 최대 속도, CPU 양보 없음 - "slow" => (50, 15), // 50개마다 15ms 양보 → PC 부하 최소 - _ => (150, 5), // normal: 150개마다 5ms → 적정 균형 + "fast" => (500, 0), + "slow" => (50, 15), + _ => (150, 5) }; - private static async Task ScanDirectoryAsync(string dir, List entries, - HashSet allowedExts, string indexSpeed, CancellationToken ct) + private static async Task ScanDirectoryAsync( + string dir, + List entries, + HashSet allowedExts, + string indexSpeed, + CancellationToken ct) { var (batchSize, delayMs) = GetThrottle(indexSpeed); @@ -494,48 +685,29 @@ public class IndexService : IDisposable { try { - int count = 0; - - // 파일 인덱싱 (확장자 필터 적용) + var count = 0; foreach (var file in Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories)) { ct.ThrowIfCancellationRequested(); - var ext = Path.GetExtension(file).ToLowerInvariant(); - if (allowedExts.Count > 0 && !allowedExts.Contains(ext)) continue; - var name = Path.GetFileNameWithoutExtension(file); - if (string.IsNullOrEmpty(name)) continue; - var type = ext switch - { - ".exe" => IndexEntryType.App, - ".lnk" or ".url" => IndexEntryType.File, - _ => IndexEntryType.File - }; - entries.Add(new IndexEntry - { - Name = name, - DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext, - Path = file, - Type = type - }); - // 속도 조절: batchSize개마다 CPU 양보 + var ext = Path.GetExtension(file).ToLowerInvariant(); + if (allowedExts.Count > 0 && !allowedExts.Contains(ext)) + continue; + + entries.Add(CreateFileEntry(file)); + if (delayMs > 0 && ++count % batchSize == 0) await Task.Delay(delayMs, ct); } - // 폴더 인덱싱 (1단계 하위 폴더) foreach (var subDir in Directory.EnumerateDirectories(dir, "*", SearchOption.TopDirectoryOnly)) { ct.ThrowIfCancellationRequested(); var name = Path.GetFileName(subDir); - if (name.StartsWith(".")) continue; - entries.Add(new IndexEntry - { - Name = name, - DisplayName = name, - Path = subDir, - Type = IndexEntryType.Folder - }); + if (name.StartsWith(".", StringComparison.Ordinal)) + continue; + + entries.Add(CreateFolderEntry(subDir)); } } catch (UnauthorizedAccessException ex) @@ -544,25 +716,111 @@ public class IndexService : IDisposable } }, ct); } + + private static IndexEntry CreateFileEntry(string filePath) + { + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + var name = Path.GetFileNameWithoutExtension(filePath); + var type = ext switch + { + ".exe" => IndexEntryType.App, + ".lnk" or ".url" => IndexEntryType.File, + _ => IndexEntryType.File + }; + + return new IndexEntry + { + Name = name, + DisplayName = ext is ".exe" or ".lnk" or ".url" ? name : name + ext, + Path = filePath, + Type = type + }; + } + + private static IndexEntry CreateFolderEntry(string folderPath) + { + var name = Path.GetFileName(folderPath); + return new IndexEntry + { + Name = name, + DisplayName = name, + Path = folderPath, + Type = IndexEntryType.Folder + }; + } + + private static bool IsTopLevelDirectory(string rootPath, string directoryPath) + { + var normalizedRoot = NormalizePath(rootPath).TrimEnd(Path.DirectorySeparatorChar); + var parent = Directory.GetParent(directoryPath)?.FullName; + if (parent == null) + return false; + + return string.Equals( + NormalizePath(parent).TrimEnd(Path.DirectorySeparatorChar), + normalizedRoot, + StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePath(string path) => + Path.GetFullPath(path) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + private static LauncherIndexCacheEntry ToCacheEntry(IndexEntry entry) => new() + { + Name = entry.Name, + DisplayName = entry.DisplayName, + Path = entry.Path, + Type = entry.Type, + Score = entry.Score, + AliasType = entry.AliasType + }; + + private static IndexEntry FromCacheEntry(LauncherIndexCacheEntry entry) => new() + { + Name = entry.Name, + DisplayName = entry.DisplayName, + Path = entry.Path, + Type = entry.Type, + Score = entry.Score, + AliasType = entry.AliasType + }; + + private sealed class LauncherIndexCache + { + public string Signature { get; set; } = ""; + public DateTime SavedAtUtc { get; set; } + public List Entries { get; set; } = new(); + } + + private sealed class LauncherIndexCacheEntry + { + public string Name { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Path { get; set; } = ""; + public IndexEntryType Type { get; set; } + public int Score { get; set; } + public string? AliasType { get; set; } + } } public class IndexEntry { - public string Name { get; set; } = ""; + public string Name { get; set; } = ""; public string DisplayName { get; set; } = ""; - public string Path { get; set; } = ""; + public string Path { get; set; } = ""; public IndexEntryType Type { get; set; } - public int Score { get; set; } = 0; - /// Alias 항목의 원본 type 문자열 (url | folder | app | batch | api | clipboard) - public string? AliasType { get; set; } - - // ─ 검색 가속 캐시 (BuildAsync 시 1회 계산, 검색마다 재계산 방지) ────── - /// ToLowerInvariant() 결과 캐시 - public string NameLower { get; set; } = ""; - /// 자모 분리 문자열 캐시 (DecomposeToJamo 결과) - public string NameJamo { get; set; } = ""; - /// 초성만 연결한 문자열 캐시 (예: "엑셀" → "ㅇㅅ") + public int Score { get; set; } + public string? AliasType { get; set; } + public string NameLower { get; set; } = ""; + public string NameJamo { get; set; } = ""; public string NameChosung { get; set; } = ""; } -public enum IndexEntryType { App, File, Alias, Folder } +public enum IndexEntryType +{ + App, + File, + Alias, + Folder +}