[Phase L4-4/L4-6] 검색 히스토리 + 계산기 단위변환 단축 문법

L4-4 검색 히스토리 (↑/↓ 키 탐색):
- Services/SearchHistoryService.cs (신규 100줄): 50개 FIFO JSON 저장
  Add() · GetAll() · Clear(). 2자 미만/중복 최상단 무시
- ViewModels/LauncherViewModel.cs:
  _historyIndex·_isHistoryNavigation 필드 추가
  NavigateHistoryPrev() / NavigateHistoryNext() / SetInputFromHistory()
  InputText setter: 직접 입력 시 _historyIndex 초기화
  ExecuteSelectedAsync: 실행 전 히스토리 저장 (2자 이상)
  OnShown: _historyIndex = -1 초기화
- Views/LauncherWindow.Keyboard.cs:
  Key.Up/Down — Results.Count==0 분기: 히스토리 탐색 / 목록 탐색 분기

L4-3 클립보드 핀/카테고리: 기존 완전 구현 확인 (IsPinned, Category,
  TogglePin, Ctrl+P, #pin/#url/#코드/#경로 필터)

L4-6 계산기 단위 변환 단축 문법:
- Handlers/UnitConverter.cs:
  AutoSuggest(): "20km", "100f", "5lb" 등 목표 없이 주요 단위 자동 제안
  _suggestions 테이블: 길이/무게/속도/데이터/온도 40개 단위 매핑
  DateShortcut: "today+30d", "today-7w" → = 접두어에서 날짜 계산
- Handlers/CalculatorHandler.cs:
  DateShortcut.TryMatch 분기 추가 (통화 감지 전)
  UnitConverter.AutoSuggest 분기 추가 (명시 변환 후)
- 힌트 텍스트: "20km · 100°F · today+30d" 추가

docs/LAUNCHER_ROADMAP.md: Phase L4 계획 테이블 추가

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 11:15:11 +09:00
parent fc881124b9
commit 75cb4ba6e9
6 changed files with 330 additions and 1 deletions

View File

@@ -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 해결) ## 기술 부채 (v1.5.0 해결)
| 항목 | 상태 | | 항목 | 상태 |

View File

@@ -40,13 +40,17 @@ public class CalculatorHandler : IActionHandler
var trimmed = query.Trim(); var trimmed = query.Trim();
// ─── today+Nd / today-Nd 날짜 단축 계산 (= 접두어에서 date 핸들러 위임) ─
if (DateShortcut.TryMatch(trimmed, out var dateItems))
return dateItems!;
// ─── 통화 변환 우선 감지 ────────────────────────────────────────────── // ─── 통화 변환 우선 감지 ──────────────────────────────────────────────
if (CurrencyConverter.IsCurrencyQuery(trimmed)) if (CurrencyConverter.IsCurrencyQuery(trimmed))
{ {
return await CurrencyConverter.ConvertAsync(trimmed, ct); return await CurrencyConverter.ConvertAsync(trimmed, ct);
} }
// ─── 단위 변환 우선 감지 ────────────────────────────────────────────── // ─── 단위 변환 (명시적 목표: 100km in miles) ─────────────────────────
if (UnitConverter.TryConvert(trimmed, out var convertResult)) if (UnitConverter.TryConvert(trimmed, out var convertResult))
{ {
return return
@@ -58,6 +62,11 @@ public class CalculatorHandler : IActionHandler
]; ];
} }
// ─── 단위 단축 자동 제안 (20km, 100f, 5lb …) ──────────────────────────
var autoSuggest = UnitConverter.AutoSuggest(trimmed);
if (autoSuggest.Count > 0)
return autoSuggest;
// ─── 수식 계산 ──────────────────────────────────────────────────────── // ─── 수식 계산 ────────────────────────────────────────────────────────
try try
{ {

View File

@@ -151,4 +151,131 @@ internal static class UnitConverter
return ((long)v).ToString("N0", System.Globalization.CultureInfo.CurrentCulture); return ((long)v).ToString("N0", System.Globalization.CultureInfo.CurrentCulture);
return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture); 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<string, string[]> _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"],
};
/// <summary>
/// "20km", "100f" 처럼 목표 단위 없이 입력하면 주요 변환 대상을 자동 제안합니다.
/// </summary>
public static List<LauncherItem> 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<LauncherItem>();
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 날짜 단축 계산 ────────────────────────────────────────
/// <summary>
/// "today+30d", "today-7d", "today+2w" 등 날짜 단축 패턴을 = 핸들러에서 처리합니다.
/// DateCalcHandler의 로직을 재사용합니다.
/// </summary>
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<LauncherItem>? 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<LauncherItem>
{
new LauncherItem(
$"{dateStr} ({dayName})",
$"{input} → {diffText} · Enter로 복사",
null, dateStr, Symbol: Symbols.Calculator),
};
return true;
}
} }

View File

@@ -0,0 +1,101 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services;
/// <summary>
/// 런처 검색 히스토리를 로컬 파일로 관리합니다.
/// ↑/↓ 키로 이전 검색어를 탐색할 수 있습니다.
/// 최대 50개 FIFO — 가장 최근 항목이 index 0.
/// 저장 위치: %APPDATA%\AxCopilot\search_history.json
/// </summary>
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<string> _history = new();
private static bool _loaded;
private static readonly object _lock = new();
// ─── 공개 API ─────────────────────────────────────────────────────────────
/// <summary>
/// 검색어를 히스토리에 추가합니다.
/// 공백·짧은 쿼리·중복(최근 항목)은 추가하지 않습니다.
/// </summary>
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();
}
/// <summary>전체 히스토리 목록 반환 (최신 → 오래된 순)</summary>
public static IReadOnlyList<string> GetAll()
{
EnsureLoaded();
lock (_lock) return _history.AsReadOnly();
}
/// <summary>히스토리 전체 삭제</summary>
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<List<string>>(json) ?? new();
}
}
catch (Exception ex)
{
LogService.Warn($"search_history.json 로드 실패: {ex.Message}");
_history = new();
}
_loaded = true;
}
}
private static async Task SaveAsync()
{
try
{
List<string> snapshot;
lock (_lock) snapshot = new List<string>(_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}");
}
}
}

View File

@@ -29,6 +29,10 @@ public partial class LauncherViewModel : INotifyPropertyChanged
private const int DebounceMs = 30; // 30ms 디바운스 — 연속 입력 시 중간 검색 스킵 private const int DebounceMs = 30; // 30ms 디바운스 — 연속 입력 시 중간 검색 스킵
private string _lastSearchQuery = ""; // IME 조합 완성 후 동일 쿼리 재검색 방지용 private string _lastSearchQuery = ""; // IME 조합 완성 후 동일 쿼리 재검색 방지용
// ─── 검색 히스토리 탐색 ──────────────────────────────────────────────────
private bool _isHistoryNavigation; // InputText setter에서 히스토리 인덱스 리셋 방지
private int _historyIndex = -1; // -1 = 탐색 중 아님
// ─── 파일 액션 모드 ─────────────────────────────────────────────────────── // ─── 파일 액션 모드 ───────────────────────────────────────────────────────
private bool _isActionMode; private bool _isActionMode;
private LauncherItem? _actionSourceItem; private LauncherItem? _actionSourceItem;
@@ -66,6 +70,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
{ {
if (_inputText == value) return; if (_inputText == value) return;
_inputText = value; _inputText = value;
// 사용자 직접 입력 시 히스토리 탐색 위치 초기화
if (!_isHistoryNavigation) _historyIndex = -1;
OnPropertyChanged(); OnPropertyChanged();
OnPropertyChanged(nameof(HasActivePrefix)); OnPropertyChanged(nameof(HasActivePrefix));
OnPropertyChanged(nameof(ActivePrefixLabel)); OnPropertyChanged(nameof(ActivePrefixLabel));
@@ -276,10 +282,49 @@ public partial class LauncherViewModel : INotifyPropertyChanged
if (IsActionMode) { IsActionMode = false; _actionSourceItem = null; } if (IsActionMode) { IsActionMode = false; _actionSourceItem = null; }
Results.Clear(); Results.Clear();
_lastSearchQuery = ""; _lastSearchQuery = "";
_historyIndex = -1;
ClearMerge(); ClearMerge();
LoadQuickActions(); LoadQuickActions();
} }
// ─── 검색 히스토리 탐색 ──────────────────────────────────────────────────
/// <summary>
/// ↑ 키 — 이전(오래된) 히스토리 항목으로 이동.
/// null이면 히스토리 없음.
/// </summary>
public string? NavigateHistoryPrev()
{
var history = SearchHistoryService.GetAll();
if (history.Count == 0) return null;
_historyIndex = Math.Min(_historyIndex + 1, history.Count - 1);
return history[_historyIndex];
}
/// <summary>
/// ↓ 키 — 최신 히스토리 항목으로 이동.
/// _historyIndex가 0 이하면 빈 문자열(현재 입력으로 복귀).
/// </summary>
public string? NavigateHistoryNext()
{
if (_historyIndex <= 0) { _historyIndex = -1; return ""; }
_historyIndex--;
var history = SearchHistoryService.GetAll();
return _historyIndex >= 0 && _historyIndex < history.Count
? history[_historyIndex] : "";
}
/// <summary>
/// 히스토리에서 탐색한 텍스트를 입력창에 설정합니다.
/// InputText setter의 히스토리 인덱스 리셋을 억제합니다.
/// </summary>
public void SetInputFromHistory(string text)
{
_isHistoryNavigation = true;
try { InputText = text; }
finally { _isHistoryNavigation = false; }
}
/// <summary> /// <summary>
/// UsageRankingService 상위 항목에서 퀵 액션 칩을 생성합니다. /// UsageRankingService 상위 항목에서 퀵 액션 칩을 생성합니다.
/// 실제로 존재하는 파일/폴더만 표시하며 최대 8개로 제한합니다. /// 실제로 존재하는 파일/폴더만 표시하며 최대 8개로 제한합니다.
@@ -405,6 +450,10 @@ public partial class LauncherViewModel : INotifyPropertyChanged
if (SelectedItem == null) return; if (SelectedItem == null) return;
// 실행 전 검색어 히스토리 저장 (2자 이상, prefix 포함)
if (InputText.Trim().Length >= 2)
SearchHistoryService.Add(InputText.Trim());
// 창을 먼저 닫아 체감 속도 확보 → 실행은 백그라운드 // 창을 먼저 닫아 체감 속도 확보 → 실행은 백그라운드
CloseRequested?.Invoke(this, EventArgs.Empty); CloseRequested?.Invoke(this, EventArgs.Empty);

View File

@@ -108,6 +108,16 @@ public partial class LauncherWindow
_vm.ToggleMergeItem(_vm.SelectedItem); _vm.ToggleMergeItem(_vm.SelectedItem);
_vm.SelectNext(); _vm.SelectNext();
} }
else if (_vm.Results.Count == 0)
{
// 결과 없음 → 검색 히스토리 탐색 (최신 방향)
var histNext = _vm.NavigateHistoryNext();
if (histNext != null)
{
_vm.SetInputFromHistory(histNext);
InputBox.CaretIndex = InputBox.Text.Length;
}
}
else else
{ {
_vm.SelectNext(); _vm.SelectNext();
@@ -122,6 +132,16 @@ public partial class LauncherWindow
_vm.ToggleMergeItem(_vm.SelectedItem); _vm.ToggleMergeItem(_vm.SelectedItem);
_vm.SelectPrev(); _vm.SelectPrev();
} }
else if (_vm.Results.Count == 0)
{
// 결과 없음 → 검색 히스토리 탐색 (이전 방향)
var histPrev = _vm.NavigateHistoryPrev();
if (histPrev != null)
{
_vm.SetInputFromHistory(histPrev);
InputBox.CaretIndex = InputBox.Text.Length;
}
}
else else
{ {
_vm.SelectPrev(); _vm.SelectPrev();
@@ -441,6 +461,14 @@ public partial class LauncherWindow
return; return;
} }
// ─── F3 → 파일 빠른 미리보기 (QuickLook 토글) ────────────────────────
if (e.Key == Key.F3)
{
ToggleQuickLook();
e.Handled = true;
return;
}
// ─── Ctrl+1~9 → n번째 결과 즉시 실행 ─────────────────────────────── // ─── Ctrl+1~9 → n번째 결과 즉시 실행 ───────────────────────────────
if (mod == ModifierKeys.Control) if (mod == ModifierKeys.Control)
{ {