[Phase L4-5] 고급 검색 필터 문법 구현 (ext/type/in/size/modified)

Core/SearchFilterParser.cs (285줄) — 신규:
- Parse(): 쿼리에서 필터 토큰 추출 후 순수 텍스트 쿼리 반환
- 지원 필터: ext:.pdf,.docx / type:file|folder|app / in:경로조각
  size:>1mb|<500kb / modified:today|week|month|year|>날짜
- Matches(): 8가지 조건(ext,type,in,size,modified) 순차 검사
  size/modified는 파일시스템 접근이 필요해 마지막에 체크
- Describe(): UI 힌트용 필터 요약 텍스트 생성
- ParsedFilters 클래스: 파싱된 필터 상태 컨테이너

Core/FuzzyEngine.cs (+23줄):
- SearchWithFilter(query, predicate, max): 텍스트 없으면 전체, 있으면 ×15 후보→필터

Core/CommandResolver.cs (+91줄):
- ResolveAsync(): 경로 쿼리 감지 다음에 필터 감지 단계 추가
- BuildFilteredResults(): 필터 힌트 항목(상단) + 필터 적용 결과 목록
  파일 항목에 size/수정일 부가 정보 subtitle 표시
- FormatBytes(): 파일 크기 포맷 (B/KB/MB/GB)
- 빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 11:34:37 +09:00
parent d4a1532d81
commit d70e6357d0
3 changed files with 398 additions and 1 deletions

View File

@@ -82,9 +82,16 @@ public class CommandResolver
return await fb.GetItemsAsync(input, ct);
}
// 3. Fuzzy 검색 폴백 + null-prefix 핸들러 병렬 실행
// 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회 제거
@@ -204,6 +211,88 @@ public class CommandResolver
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)
{