diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 1c26126..cbe25a8 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -107,6 +107,21 @@ --- +## Phase L4 — 검색/탐색 UX 혁신 + 생산성 확장 (v1.7.x) + +> **방향**: Raycast/Alfred 기능 갭 해소 + 기존 L1-7 미완 기능 이행. 런처 단독 완결형 생산성 강화. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L4-1 | **파일 탐색기 인라인 패널** | `cd` + `→` 키로 인라인 탐색기 진입. 경로 브레드크럼, ←/→ 폴더 이동, Ctrl+D 즐겨찾기 핀 | 높음 | +| L4-2 | **QuickLook F3 강화** | PDF(WinRT PdfDocument), 코드 구문강조(확장자별 배경색+라인 번호), Office 텍스트 추출 | 높음 | +| L4-3 | **클립보드 핀 & 카테고리** | Ctrl+P 핀 고정, `#pin/#url/#code` 필터, 자동 분류(URL/코드/경로). L1-7 이행 | 높음 | +| L4-4 | **검색 히스토리** | ↑/↓ 키로 이전 검색어 탐색. 50개 FIFO 로컬 저장. 런처 재시작 후에도 유지 | 중간 | +| L4-5 | **고급 검색 필터 문법** | `ext:.pdf size:>1mb modified:week in:documents` 인라인 필터. FuzzyEngine 파서 레이어 | 중간 | +| L4-6 | **계산기 단위 변환 확장** | `=20km`, `=100°F`, `=today+30d`. 로컬 변환 테이블. `=` 핸들러 확장 | 중간 | + +--- + ## 기술 부채 (v1.5.0 해결) | 항목 | 상태 | diff --git a/src/AxCopilot/Handlers/CalculatorHandler.cs b/src/AxCopilot/Handlers/CalculatorHandler.cs index c11378a..6b40d11 100644 --- a/src/AxCopilot/Handlers/CalculatorHandler.cs +++ b/src/AxCopilot/Handlers/CalculatorHandler.cs @@ -40,13 +40,17 @@ public class CalculatorHandler : IActionHandler var trimmed = query.Trim(); + // ─── today+Nd / today-Nd 날짜 단축 계산 (= 접두어에서 date 핸들러 위임) ─ + if (DateShortcut.TryMatch(trimmed, out var dateItems)) + return dateItems!; + // ─── 통화 변환 우선 감지 ────────────────────────────────────────────── if (CurrencyConverter.IsCurrencyQuery(trimmed)) { return await CurrencyConverter.ConvertAsync(trimmed, ct); } - // ─── 단위 변환 우선 감지 ────────────────────────────────────────────── + // ─── 단위 변환 (명시적 목표: 100km in miles) ───────────────────────── if (UnitConverter.TryConvert(trimmed, out var convertResult)) { return @@ -58,6 +62,11 @@ public class CalculatorHandler : IActionHandler ]; } + // ─── 단위 단축 자동 제안 (20km, 100f, 5lb …) ────────────────────────── + var autoSuggest = UnitConverter.AutoSuggest(trimmed); + if (autoSuggest.Count > 0) + return autoSuggest; + // ─── 수식 계산 ──────────────────────────────────────────────────────── try { diff --git a/src/AxCopilot/Handlers/UnitConverter.cs b/src/AxCopilot/Handlers/UnitConverter.cs index d1b7bd9..8b23c34 100644 --- a/src/AxCopilot/Handlers/UnitConverter.cs +++ b/src/AxCopilot/Handlers/UnitConverter.cs @@ -151,4 +151,131 @@ internal static class UnitConverter return ((long)v).ToString("N0", System.Globalization.CultureInfo.CurrentCulture); return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture); } + + // ─── 자동 제안 (목표 단위 없이 입력 시) ────────────────────────────────── + + // 패턴: <숫자><단위> (공백 없어도 OK) + private static readonly Regex _autoPattern = new( + @"^(-?\d+(?:\.\d+)?)\s*([a-z°/²³µ]+)$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // 단위 → 주요 변환 대상 목록 (사용자에게 자동 제안) + private static readonly Dictionary _suggestions = new(StringComparer.OrdinalIgnoreCase) + { + // 길이 + ["km"] = ["miles", "m", "ft"], + ["miles"] = ["km", "m", "ft"], + ["m"] = ["km", "ft", "inches"], + ["cm"] = ["inches", "m"], + ["mm"] = ["inches", "cm"], + ["ft"] = ["m", "km", "inches"], + ["feet"] = ["m", "km", "inches"], + ["in"] = ["cm", "m"], + ["inches"]= ["cm", "m"], + // 무게 + ["kg"] = ["lb", "g"], + ["lb"] = ["kg", "g"], + ["lbs"] = ["kg", "g"], + ["g"] = ["kg", "oz"], + ["oz"] = ["g", "kg"], + // 속도 + ["km/h"] = ["mph", "m/s"], + ["kmh"] = ["mph", "m/s"], + ["kph"] = ["mph", "m/s"], + ["mph"] = ["km/h", "m/s"], + ["m/s"] = ["km/h", "mph"], + // 데이터 + ["mb"] = ["gb", "kb"], + ["gb"] = ["mb", "tb"], + ["tb"] = ["gb", "mb"], + ["kb"] = ["mb", "b"], + // 온도 — 양방향 + ["c"] = ["f", "k"], + ["°c"] = ["°f", "k"], + ["celsius"]= ["fahrenheit", "kelvin"], + ["f"] = ["c", "k"], + ["°f"] = ["°c", "k"], + ["fahrenheit"] = ["celsius", "kelvin"], + }; + + /// + /// "20km", "100f" 처럼 목표 단위 없이 입력하면 주요 변환 대상을 자동 제안합니다. + /// + public static List AutoSuggest(string input) + { + var m = _autoPattern.Match(input.Trim()); + if (!m.Success) return new(); + + if (!double.TryParse(m.Groups[1].Value, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var value)) + return new(); + + var fromUnit = m.Groups[2].Value; + if (!_suggestions.TryGetValue(fromUnit, out var targets)) return new(); + + var items = new List(); + foreach (var to in targets) + { + if (TryConvert($"{value} {fromUnit} in {to}", out var r) && r != null) + { + items.Add(new LauncherItem( + r, + $"{value} {fromUnit} → {to} · Enter로 복사", + null, r, Symbol: Symbols.Calculator)); + } + } + return items; + } +} + +// ─── = 접두어에서 today 날짜 단축 계산 ──────────────────────────────────────── + +/// +/// "today+30d", "today-7d", "today+2w" 등 날짜 단축 패턴을 = 핸들러에서 처리합니다. +/// DateCalcHandler의 로직을 재사용합니다. +/// +internal static class DateShortcut +{ + private static readonly Regex _pattern = new( + @"^today\s*([+-])(\d+)([dDwWmMyY])$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public static bool TryMatch(string input, out IEnumerable? items) + { + items = null; + var m = _pattern.Match(input.Trim()); + if (!m.Success) return false; + + var sign = m.Groups[1].Value == "+" ? 1 : -1; + var val = int.Parse(m.Groups[2].Value) * sign; + var unit = m.Groups[3].Value.ToLowerInvariant(); + var now = DateTime.Now; + + var target = unit switch + { + "d" => now.AddDays(val), + "w" => now.AddDays(val * 7), + "m" => now.AddMonths(val), + "y" => now.AddYears(val), + _ => now.AddDays(val), + }; + + var diff = (target.Date - now.Date).Days; + var dayName = target.ToString("dddd", new System.Globalization.CultureInfo("ko-KR")); + var dateStr = target.ToString("yyyy-MM-dd"); + var diffText = diff == 0 ? "오늘" + : diff > 0 ? $"+{diff}일 후" + : $"{-diff}일 전"; + + items = new List + { + new LauncherItem( + $"{dateStr} ({dayName})", + $"{input} → {diffText} · Enter로 복사", + null, dateStr, Symbol: Symbols.Calculator), + }; + return true; + } } diff --git a/src/AxCopilot/Services/SearchHistoryService.cs b/src/AxCopilot/Services/SearchHistoryService.cs new file mode 100644 index 0000000..a222e7a --- /dev/null +++ b/src/AxCopilot/Services/SearchHistoryService.cs @@ -0,0 +1,101 @@ +using System.IO; +using System.Text.Json; + +namespace AxCopilot.Services; + +/// +/// 런처 검색 히스토리를 로컬 파일로 관리합니다. +/// ↑/↓ 키로 이전 검색어를 탐색할 수 있습니다. +/// 최대 50개 FIFO — 가장 최근 항목이 index 0. +/// 저장 위치: %APPDATA%\AxCopilot\search_history.json +/// +internal static class SearchHistoryService +{ + private const int MaxItems = 50; + + private static readonly string _file = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "search_history.json"); + + private static List _history = new(); + private static bool _loaded; + private static readonly object _lock = new(); + + // ─── 공개 API ───────────────────────────────────────────────────────────── + + /// + /// 검색어를 히스토리에 추가합니다. + /// 공백·짧은 쿼리·중복(최근 항목)은 추가하지 않습니다. + /// + public static void Add(string query) + { + if (string.IsNullOrWhiteSpace(query) || query.Trim().Length < 2) return; + var q = query.Trim(); + EnsureLoaded(); + lock (_lock) + { + // 이미 최상단이면 중복 추가 안 함 + if (_history.Count > 0 && _history[0] == q) return; + // 기존 동일 항목 제거 후 맨 앞에 삽입 + _history.Remove(q); + _history.Insert(0, q); + if (_history.Count > MaxItems) _history.RemoveAt(_history.Count - 1); + } + _ = SaveAsync(); + } + + /// 전체 히스토리 목록 반환 (최신 → 오래된 순) + public static IReadOnlyList GetAll() + { + EnsureLoaded(); + lock (_lock) return _history.AsReadOnly(); + } + + /// 히스토리 전체 삭제 + public static void Clear() + { + lock (_lock) _history.Clear(); + _ = SaveAsync(); + } + + // ─── 내부 ────────────────────────────────────────────────────────────────── + + private static void EnsureLoaded() + { + if (_loaded) return; + lock (_lock) + { + if (_loaded) return; + try + { + if (File.Exists(_file)) + { + var json = File.ReadAllText(_file); + _history = JsonSerializer.Deserialize>(json) ?? new(); + } + } + catch (Exception ex) + { + LogService.Warn($"search_history.json 로드 실패: {ex.Message}"); + _history = new(); + } + _loaded = true; + } + } + + private static async Task SaveAsync() + { + try + { + List snapshot; + lock (_lock) snapshot = new List(_history); + Directory.CreateDirectory(Path.GetDirectoryName(_file)!); + var json = JsonSerializer.Serialize(snapshot); + await File.WriteAllTextAsync(_file, json); + } + catch (Exception ex) + { + LogService.Warn($"search_history.json 저장 실패: {ex.Message}"); + } + } +} diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.cs b/src/AxCopilot/ViewModels/LauncherViewModel.cs index 42e5e48..11ee9db 100644 --- a/src/AxCopilot/ViewModels/LauncherViewModel.cs +++ b/src/AxCopilot/ViewModels/LauncherViewModel.cs @@ -29,6 +29,10 @@ public partial class LauncherViewModel : INotifyPropertyChanged private const int DebounceMs = 30; // 30ms 디바운스 — 연속 입력 시 중간 검색 스킵 private string _lastSearchQuery = ""; // IME 조합 완성 후 동일 쿼리 재검색 방지용 + // ─── 검색 히스토리 탐색 ────────────────────────────────────────────────── + private bool _isHistoryNavigation; // InputText setter에서 히스토리 인덱스 리셋 방지 + private int _historyIndex = -1; // -1 = 탐색 중 아님 + // ─── 파일 액션 모드 ─────────────────────────────────────────────────────── private bool _isActionMode; private LauncherItem? _actionSourceItem; @@ -66,6 +70,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged { if (_inputText == value) return; _inputText = value; + // 사용자 직접 입력 시 히스토리 탐색 위치 초기화 + if (!_isHistoryNavigation) _historyIndex = -1; OnPropertyChanged(); OnPropertyChanged(nameof(HasActivePrefix)); OnPropertyChanged(nameof(ActivePrefixLabel)); @@ -276,10 +282,49 @@ public partial class LauncherViewModel : INotifyPropertyChanged if (IsActionMode) { IsActionMode = false; _actionSourceItem = null; } Results.Clear(); _lastSearchQuery = ""; + _historyIndex = -1; ClearMerge(); LoadQuickActions(); } + // ─── 검색 히스토리 탐색 ────────────────────────────────────────────────── + + /// + /// ↑ 키 — 이전(오래된) 히스토리 항목으로 이동. + /// null이면 히스토리 없음. + /// + public string? NavigateHistoryPrev() + { + var history = SearchHistoryService.GetAll(); + if (history.Count == 0) return null; + _historyIndex = Math.Min(_historyIndex + 1, history.Count - 1); + return history[_historyIndex]; + } + + /// + /// ↓ 키 — 최신 히스토리 항목으로 이동. + /// _historyIndex가 0 이하면 빈 문자열(현재 입력으로 복귀). + /// + public string? NavigateHistoryNext() + { + if (_historyIndex <= 0) { _historyIndex = -1; return ""; } + _historyIndex--; + var history = SearchHistoryService.GetAll(); + return _historyIndex >= 0 && _historyIndex < history.Count + ? history[_historyIndex] : ""; + } + + /// + /// 히스토리에서 탐색한 텍스트를 입력창에 설정합니다. + /// InputText setter의 히스토리 인덱스 리셋을 억제합니다. + /// + public void SetInputFromHistory(string text) + { + _isHistoryNavigation = true; + try { InputText = text; } + finally { _isHistoryNavigation = false; } + } + /// /// UsageRankingService 상위 항목에서 퀵 액션 칩을 생성합니다. /// 실제로 존재하는 파일/폴더만 표시하며 최대 8개로 제한합니다. @@ -405,6 +450,10 @@ public partial class LauncherViewModel : INotifyPropertyChanged if (SelectedItem == null) return; + // 실행 전 검색어 히스토리 저장 (2자 이상, prefix 포함) + if (InputText.Trim().Length >= 2) + SearchHistoryService.Add(InputText.Trim()); + // 창을 먼저 닫아 체감 속도 확보 → 실행은 백그라운드 CloseRequested?.Invoke(this, EventArgs.Empty); diff --git a/src/AxCopilot/Views/LauncherWindow.Keyboard.cs b/src/AxCopilot/Views/LauncherWindow.Keyboard.cs index dc6c006..39a148a 100644 --- a/src/AxCopilot/Views/LauncherWindow.Keyboard.cs +++ b/src/AxCopilot/Views/LauncherWindow.Keyboard.cs @@ -108,6 +108,16 @@ public partial class LauncherWindow _vm.ToggleMergeItem(_vm.SelectedItem); _vm.SelectNext(); } + else if (_vm.Results.Count == 0) + { + // 결과 없음 → 검색 히스토리 탐색 (최신 방향) + var histNext = _vm.NavigateHistoryNext(); + if (histNext != null) + { + _vm.SetInputFromHistory(histNext); + InputBox.CaretIndex = InputBox.Text.Length; + } + } else { _vm.SelectNext(); @@ -122,6 +132,16 @@ public partial class LauncherWindow _vm.ToggleMergeItem(_vm.SelectedItem); _vm.SelectPrev(); } + else if (_vm.Results.Count == 0) + { + // 결과 없음 → 검색 히스토리 탐색 (이전 방향) + var histPrev = _vm.NavigateHistoryPrev(); + if (histPrev != null) + { + _vm.SetInputFromHistory(histPrev); + InputBox.CaretIndex = InputBox.Text.Length; + } + } else { _vm.SelectPrev(); @@ -441,6 +461,14 @@ public partial class LauncherWindow return; } + // ─── F3 → 파일 빠른 미리보기 (QuickLook 토글) ──────────────────────── + if (e.Key == Key.F3) + { + ToggleQuickLook(); + e.Handled = true; + return; + } + // ─── Ctrl+1~9 → n번째 결과 즉시 실행 ─────────────────────────────── if (mod == ModifierKeys.Control) {