Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View File

@@ -0,0 +1,568 @@
using System.Diagnostics;
using System.IO;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 파일/앱 인덱싱 서비스. Fuzzy 검색의 데이터 소스를 관리합니다.
/// FileSystemWatcher로 변경 시 자동 재빌드(3초 디바운스)합니다.
/// </summary>
public class IndexService : IDisposable
{
private readonly SettingsService _settings;
private List<IndexEntry> _index = new();
private readonly List<FileSystemWatcher> _watchers = new();
private System.Threading.Timer? _debounceTimer;
private readonly object _timerLock = new();
private const int DebounceMs = 3000;
private readonly SemaphoreSlim _rebuildLock = new(1, 1);
public IReadOnlyList<IndexEntry> Entries => _index;
public event EventHandler? IndexRebuilt;
public TimeSpan LastIndexDuration { get; private set; }
public int LastIndexCount { get; private set; }
public IndexService(SettingsService settings)
{
_settings = settings;
}
/// <summary>
/// 앱 시작 시 전체 인덱스 빌드 후 FileSystemWatcher 시작.
/// </summary>
public async Task BuildAsync(CancellationToken ct = default)
{
await _rebuildLock.WaitAsync(ct);
var sw = Stopwatch.StartNew();
try
{
var entries = new List<IndexEntry>();
var paths = _settings.Settings.IndexPaths
.Select(p => Environment.ExpandEnvironmentVariables(p));
var allowedExts = new HashSet<string>(
_settings.Settings.IndexExtensions
.Select(e => e.ToLowerInvariant().StartsWith(".") ? e.ToLowerInvariant() : "." + e.ToLowerInvariant()),
StringComparer.OrdinalIgnoreCase);
var indexSpeed = _settings.Settings.IndexSpeed ?? "normal";
foreach (var dir in paths)
{
if (!Directory.Exists(dir)) continue;
await ScanDirectoryAsync(dir, entries, allowedExts, indexSpeed, ct);
}
// Alias도 인덱스에 포함 (type에 따라 IndexEntryType 구분)
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 // url, batch, api, clipboard
},
Score = 100 // Alias는 최우선
});
}
// 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}초)");
IndexRebuilt?.Invoke(this, EventArgs.Empty);
}
finally
{
_rebuildLock.Release();
}
}
/// <summary>
/// 인덱스 경로에 대한 FileSystemWatcher를 시작합니다.
/// BuildAsync() 완료 후 호출하세요.
/// </summary>
public void StartWatchers()
{
StopWatchers();
foreach (var rawPath in _settings.Settings.IndexPaths)
{
var dir = Environment.ExpandEnvironmentVariables(rawPath);
if (!Directory.Exists(dir)) continue;
try
{
var w = new FileSystemWatcher(dir)
{
Filter = "*.*",
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName,
EnableRaisingEvents = true
};
w.Created += OnWatcherEvent;
w.Deleted += OnWatcherEvent;
w.Renamed += OnWatcherRenamed;
_watchers.Add(w);
}
catch (Exception ex)
{
LogService.Warn($"FileSystemWatcher 생성 실패: {dir} - {ex.Message}");
}
}
LogService.Info($"파일 감시 시작: {_watchers.Count}개 경로");
}
private void StopWatchers()
{
foreach (var w in _watchers)
{
w.EnableRaisingEvents = false;
w.Dispose();
}
_watchers.Clear();
}
private void OnWatcherEvent(object sender, FileSystemEventArgs e)
{
ScheduleRebuild(e.FullPath);
}
private void OnWatcherRenamed(object sender, RenamedEventArgs e)
{
ScheduleRebuild(e.FullPath);
}
private void ScheduleRebuild(string triggerPath)
{
LogService.Info($"파일 변경 감지: {triggerPath} — {DebounceMs}ms 후 재빌드 예약");
lock (_timerLock)
{
_debounceTimer?.Dispose();
_debounceTimer = new System.Threading.Timer(__ =>
{
_ = BuildAsync();
}, null, DebounceMs, System.Threading.Timeout.Infinite);
}
}
public void Dispose()
{
_debounceTimer?.Dispose();
StopWatchers();
}
/// <summary>
/// 자주 사용하는 Windows 기본 앱을 한글+영문 이름으로 등록합니다.
/// 실제로 존재하는 경우에만 인덱스에 추가됩니다.
/// </summary>
private static void RegisterBuiltInApps(List<IndexEntry> entries)
{
// (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
("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")),
};
// 기존 항목의 이름 → 인덱스 매핑 + Path 기반 중복 방지
var nameToIdx = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var pathToIdx = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < entries.Count; i++)
{
nameToIdx.TryAdd(entries[i].Name, i);
pathToIdx.TryAdd(entries[i].Path, i);
}
foreach (var (display, names, exe) in builtIns)
{
if (string.IsNullOrEmpty(exe)) continue;
// UWP protocol은 파일 존재 체크 불필요
if (!exe.Contains(":") && !File.Exists(exe) && !exe.EndsWith(".msc")) continue;
// 같은 경로가 이미 인덱스에 있으면 DisplayName과 Score만 업데이트
if (pathToIdx.TryGetValue(exe, out var existingIdx))
{
entries[existingIdx].DisplayName = display;
if (entries[existingIdx].Score < 95) entries[existingIdx].Score = 95;
}
// ── 키워드별 IndexEntry 등록 ─────────────────────────────────────────
// - 한글 이름: 항상 IndexEntry로 추가 (앱이 이미 스캔됐어도)
// → "엑" 입력 시 "엑셀" IndexEntry의 StartsWith("엑")으로 매칭
// - 영문/약어: 앱이 미스캔 상태일 때 첫 키워드만 대표 항목으로 등록
// - 경로 중복은 CommandResolver 레이어에서 seenPaths로 최고점만 표시
bool 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');
if (!mainAdded || isKoreanName)
{
entries.Add(new IndexEntry
{
Name = name,
DisplayName = display,
Path = exe,
Type = IndexEntryType.App,
Score = 95
});
var newIdx = entries.Count - 1;
nameToIdx[name] = newIdx;
if (!mainAdded)
{
pathToIdx[exe] = newIdx;
mainAdded = true;
}
}
else
{
// 영문/약어는 pathToIdx 참조만 (FuzzyEngine은 영문 앱명 직접 매칭)
nameToIdx[name] = pathToIdx[exe];
}
}
}
}
private static string? FindOfficeApp(string exeName)
{
var roots = new[]
{
@"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",
};
foreach (var root in roots)
{
var path = Path.Combine(root, exeName);
if (File.Exists(path)) return path;
}
return null;
}
private static string? FindTeams()
{
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)
var msTeams = Path.Combine(localApp, @"Microsoft\WindowsApps\ms-teams.exe");
return File.Exists(msTeams) ? msTeams : null;
}
private static string? FindInPath(string fileName)
{
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in pathEnv.Split(';'))
{
if (string.IsNullOrWhiteSpace(dir)) continue;
var full = Path.Combine(dir.Trim(), fileName);
if (File.Exists(full)) return full;
}
return null;
}
private static string? FindInLocalAppData(string relativePath)
{
var localApp = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var path = Path.Combine(localApp, relativePath);
return File.Exists(path) ? path : null;
}
/// <summary>ProgramFiles / ProgramFiles(x86) 두 곳을 탐색합니다.</summary>
private static string? FindInProgramFiles(string relativePath)
{
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;
}
/// <summary>AppData\Roaming 경로를 탐색합니다.</summary>
private static string? FindInRoaming(string relativePath)
{
var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var path = Path.Combine(roaming, relativePath);
return File.Exists(path) ? path : null;
}
// ─── 검색 가속 캐시 계산 ──────────────────────────────────────────────────
/// <summary>빌드 완료 후 전체 항목의 검색 캐시를 한 번에 계산합니다.</summary>
private static void ComputeAllSearchCaches(List<IndexEntry> entries)
{
foreach (var e in entries)
ComputeSearchCache(e);
}
/// <summary>항목 1개의 NameLower / NameJamo / NameChosung 캐시를 계산합니다.</summary>
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);
foreach (var c in entry.NameLower)
{
var cho = AxCopilot.Core.FuzzyEngine.GetChosung(c);
if (cho != '\0') sb.Append(cho);
}
entry.NameChosung = sb.ToString();
}
/// <summary>인덱싱 속도에 따른 throttle 간격(ms). N개 파일마다 yield.</summary>
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 → 적정 균형
};
private static async Task ScanDirectoryAsync(string dir, List<IndexEntry> entries,
HashSet<string> allowedExts, string indexSpeed, CancellationToken ct)
{
var (batchSize, delayMs) = GetThrottle(indexSpeed);
await Task.Run(async () =>
{
try
{
int 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 양보
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
});
}
}
catch (UnauthorizedAccessException ex)
{
LogService.Warn($"폴더 접근 불가 (건너뜀): {dir} - {ex.Message}");
}
}, ct);
}
}
public class IndexEntry
{
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; } = 0;
/// <summary>Alias 항목의 원본 type 문자열 (url | folder | app | batch | api | clipboard)</summary>
public string? AliasType { get; set; }
// ─ 검색 가속 캐시 (BuildAsync 시 1회 계산, 검색마다 재계산 방지) ──────
/// <summary>ToLowerInvariant() 결과 캐시</summary>
public string NameLower { get; set; } = "";
/// <summary>자모 분리 문자열 캐시 (DecomposeToJamo 결과)</summary>
public string NameJamo { get; set; } = "";
/// <summary>초성만 연결한 문자열 캐시 (예: "엑셀" → "ㅇㅅ")</summary>
public string NameChosung { get; set; } = "";
}
public enum IndexEntryType { App, File, Alias, Folder }