Files
AX-Copilot/src/AxCopilot/ViewModels/LauncherViewModel.cs
lacvet 679de30f68 [Phase L2-6] 검색 결과 그룹핑 (앱/폴더/파일/단축키)
AxCopilot.SDK/IActionHandler.cs:
- LauncherItem 레코드에 Group? 선택 매개변수 추가 (기존 호출 코드 무변경)

Core/CommandResolver.cs:
- Fuzzy 검색 결과에 Group 태깅: 앱/폴더/단축키/파일

ViewModels/LauncherViewModel.cs:
- ICollectionView GroupedResults 프로퍼티 추가
- CollectionViewSource + PropertyGroupDescription(nameof(LauncherItem.Group)) 초기화

Views/LauncherWindow.xaml:
- ItemsSource: Results → GroupedResults 변경
- ListView.GroupStyle 추가: 그룹 이름 헤더(10px, SemiBold, 흐린 보조 텍스트)
- GroupItem ContainerStyle로 헤더+아이템 수직 배치
- Group=null 항목은 NullToCollapsedConverter로 헤더 숨김

빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:36:26 +09:00

421 lines
20 KiB
C#

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
using AxCopilot.Core;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.ViewModels;
public partial class LauncherViewModel : INotifyPropertyChanged
{
private static App? CurrentApp => System.Windows.Application.Current as App;
private readonly CommandResolver _resolver;
private readonly SettingsService _settings;
private string _inputText = "";
private LauncherItem? _selectedItem;
private bool _isLoading;
private CancellationTokenSource? _searchCts;
private System.Threading.Timer? _debounceTimer;
private const int DebounceMs = 30; // 30ms 디바운스 — 연속 입력 시 중간 검색 스킵
private string _lastSearchQuery = ""; // IME 조합 완성 후 동일 쿼리 재검색 방지용
// ─── 파일 액션 모드 ───────────────────────────────────────────────────────
private bool _isActionMode;
private LauncherItem? _actionSourceItem;
private string _savedQuery = "";
// ─── 클립보드 병합 ────────────────────────────────────────────────────────
private readonly HashSet<ClipboardEntry> _mergeQueue = new();
/// <summary>
/// 검색 결과 컬렉션. ReplaceAll() 호출 시 단일 Reset 이벤트로 WPF 레이아웃 1회만 갱신.
/// </summary>
public BulkObservableCollection<LauncherItem> Results { get; } = new();
/// <summary>
/// 그룹핑이 적용된 결과 뷰. prefix 없는 일반 검색에서 앱/폴더/파일/단축키 섹션 구분 표시.
/// Group == null 항목은 별도 그룹 헤더 없이 표시됩니다.
/// </summary>
public ICollectionView GroupedResults { get; }
// ─── 기본 프로퍼티 ────────────────────────────────────────────────────────
public string InputText
{
get => _inputText;
set
{
if (_inputText == value) return;
_inputText = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasActivePrefix));
OnPropertyChanged(nameof(ActivePrefixLabel));
OnPropertyChanged(nameof(ActivePrefixSymbol));
OnPropertyChanged(nameof(ActivePrefixBrush));
OnPropertyChanged(nameof(IsClipboardMode));
OnPropertyChanged(nameof(ShowMergeHint));
OnPropertyChanged(nameof(MergeHintText));
// 연속 입력 시 이전 검색 즉시 취소 + 50ms 디바운스 후 실제 검색 시작
_searchCts?.Cancel();
_debounceTimer?.Dispose();
if (string.IsNullOrWhiteSpace(value))
{
Results.Clear();
}
else
{
var captured = value;
_debounceTimer = new System.Threading.Timer(
_ => Application.Current?.Dispatcher.InvokeAsync(() => _ = SearchAsync(captured)),
null, DebounceMs, System.Threading.Timeout.Infinite);
}
}
}
public LauncherItem? SelectedItem
{
get => _selectedItem;
set { _selectedItem = value; OnPropertyChanged(); }
}
public bool IsLoading
{
get => _isLoading;
set { _isLoading = value; OnPropertyChanged(); }
}
private string _placeholderText = AxCopilot.Services.L10n.Get("placeholder");
public string PlaceholderText
{
get => _placeholderText;
private set { _placeholderText = value; OnPropertyChanged(); }
}
/// <summary>런처가 열릴 때마다 호출 — 설정에 따라 랜덤 또는 고정 문구로 교체합니다.</summary>
public void RefreshPlaceholder() =>
PlaceholderText = _settings.Settings.Launcher.EnableRandomPlaceholder
? AxCopilot.Services.L10n.GetRandomPlaceholder()
: AxCopilot.Services.L10n.Get("placeholder");
/// <summary>아이콘 애니메이션 설정값</summary>
public bool EnableIconAnimation => _settings.Settings.Launcher.EnableIconAnimation;
/// <summary>런처 무지개 글로우 설정값</summary>
public bool EnableRainbowGlow => _settings.Settings.Launcher.EnableRainbowGlow;
/// <summary>선택 아이템 글로우 설정값</summary>
public bool EnableSelectionGlow => _settings.Settings.Launcher.EnableSelectionGlow;
/// <summary>런처 창 테두리 표시 설정값</summary>
public bool ShowLauncherBorder => _settings.Settings.Launcher.ShowLauncherBorder;
public string ThemeSetting => _settings.Settings.Launcher.Theme;
public CustomThemeColors? CustomThemeColors => _settings.Settings.Launcher.CustomTheme;
public string WindowPosition => _settings.Settings.Launcher.Position;
// ─── Prefix 배지 ─────────────────────────────────────────────────────────
private static readonly Dictionary<string, (string Label, string Symbol, string ColorHex)> PrefixMap = new()
{
// ─── 기본 프리픽스 ──────────────────────────────────────────────────────
{ "@", ("URL", Symbols.Globe, "#0078D4") },
{ "~", ("워크", Symbols.Workspace, "#C50F1F") },
{ ">", ("명령", Symbols.Terminal, "#323130") },
{ "$", ("클립", Symbols.Clipboard, "#8764B8") },
{ "cd", ("폴더", Symbols.Folder, "#107C10") },
{ "#", ("히스", Symbols.History, "#B7791F") },
{ ";", ("스닛", Symbols.Snippet, "#0F6CBD") },
{ "=", ("계산", Symbols.Calculator, "#4B5EFC") },
{ "!", ("AI", "\uE8BD", "#8B2FC9") },
{ "?", ("검색", Symbols.Globe, "#006EAF") },
{ "/", ("시스템", Symbols.Power, "#4A4A4A") },
// ─── 시스템 도구 ─────────────────────────────────────────────────────────
{ "kill ", ("킬", Symbols.Error, "#CC2222") },
{ "media ", ("미디어", Symbols.MediaPlay, "#1A6B3C") },
{ "info ", ("시스템", Symbols.Computer, "#5B4E7E") },
{ "port", ("포트", Symbols.Network, "#006699") },
// ─── v1.5 핸들러 ─────────────────────────────────────────────────────────
{ "emoji", ("이모지", Symbols.Emoji, "#F59E0B") },
{ "color", ("색상", Symbols.ColorPicker, "#EC4899") },
{ "recent", ("최근", Symbols.RecentFiles, "#059669") },
{ "note", ("메모", Symbols.Note, "#7C3AED") },
{ "uninstall", ("제거", Symbols.Uninstall, "#DC2626") },
// ─── v1.6 핸들러 ─────────────────────────────────────────────────────────
{ "env", ("환경변수", Symbols.EnvVar, "#0D9488") },
{ "json", ("JSON", Symbols.JsonValid, "#D97706") },
{ "encode ", ("인코딩", Symbols.EncodeIcon, "#6366F1") },
{ "snap", ("스냅", Symbols.SnapLayout, "#B45309") },
{ "cap", ("캡처", Symbols.CaptureIcon, "#BE185D") },
{ "help", ("도움말", Symbols.Info, "#6B7280") },
// ─── Phase L3-5 파일 태그 ──────────────────────────────────────────────
{ "tag", ("태그", Symbols.Tag, "#6366F1") },
// ─── Phase L3-8 알림 센터 ─────────────────────────────────────────────
{ "notif", ("알림", Symbols.ReminderBell, "#F59E0B") },
// ─── Phase L3-9 위젯 핸들러 ──────────────────────────────────────────
{ "pomo", ("타이머", Symbols.Timer, "#F59E0B") },
};
// ─── 설정 기능 토글 (런처 실동작 연결) ──────────────────────────────────
/// <summary>번호 배지(1~9) 표시 여부 — LauncherWindow.xaml 번호 뱃지 Visibility 바인딩</summary>
public bool ShowNumberBadges => _settings.Settings.Launcher.ShowNumberBadges;
/// <summary>포커스 잃으면 런처 닫기 — Window_Deactivated에서 읽음</summary>
public bool CloseOnFocusLost => _settings.Settings.Launcher.CloseOnFocusLost;
/// <summary>액션 모드(→) 허용 여부</summary>
public bool EnableActionMode => _settings.Settings.Launcher.EnableActionMode;
/// <summary>AI 기능 활성화 여부 (AiEnabled 설정). false이면 ! prefix 숨김.</summary>
private static bool IsAiEnabled() =>
CurrentApp?.SettingsService?.Settings.AiEnabled ?? true;
public bool HasActivePrefix =>
_settings.Settings.Launcher.ShowPrefixBadge &&
_inputText.Length > 0 && PrefixMap.Keys.Any(k =>
(k != "!" || IsAiEnabled()) &&
_inputText.StartsWith(k, StringComparison.OrdinalIgnoreCase));
public string? ActivePrefix =>
PrefixMap.Keys.FirstOrDefault(k =>
(k != "!" || IsAiEnabled()) &&
_inputText.StartsWith(k, StringComparison.OrdinalIgnoreCase));
public string ActivePrefixLabel =>
ActivePrefix != null && PrefixMap.TryGetValue(ActivePrefix, out var info) ? info.Label : "";
public string ActivePrefixSymbol =>
ActivePrefix != null && PrefixMap.TryGetValue(ActivePrefix, out var info) ? info.Symbol : Symbols.Search;
public SolidColorBrush ActivePrefixBrush
{
get
{
if (ActivePrefix != null && PrefixMap.TryGetValue(ActivePrefix, out var info))
{
var color = (Color)ColorConverter.ConvertFromString(info.ColorHex);
return new SolidColorBrush(color);
}
return new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
}
}
// ─── 파일 액션 모드 프로퍼티 ─────────────────────────────────────────────
public bool IsActionMode
{
get => _isActionMode;
private set
{
_isActionMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ShowActionModeBar));
OnPropertyChanged(nameof(ActionModeBreadcrumb));
}
}
public bool ShowActionModeBar => IsActionMode;
public string ActionModeBreadcrumb => _actionSourceItem?.Title ?? "";
/// <summary>현재 선택 항목이 파일 액션 가능 여부</summary>
public bool CanEnterActionMode() =>
!IsActionMode && SelectedItem?.Data is IndexEntry;
// ─── 클립보드 병합 모드 프로퍼티 ─────────────────────────────────────────
public bool IsClipboardMode => _inputText.StartsWith("#", StringComparison.Ordinal);
public int MergeCount => _mergeQueue.Count;
public bool ShowMergeHint => _mergeQueue.Count > 0 && IsClipboardMode;
public string MergeHintText =>
_mergeQueue.Count > 0
? $"{_mergeQueue.Count}개 선택됨 · Shift+Enter로 합치기 · Esc로 취소"
: "";
public bool IsItemMarkedForMerge(LauncherItem item) =>
item.Data is ClipboardEntry e && _mergeQueue.Contains(e);
// ─── 이벤트 ───────────────────────────────────────────────────────────────
public event EventHandler? CloseRequested;
public event EventHandler<string>? NotificationRequested;
public LauncherViewModel(CommandResolver resolver, SettingsService settings)
{
_resolver = resolver;
_settings = settings;
// 그룹핑 뷰 초기화 (UI 스레드에서 생성 보장)
GroupedResults = CollectionViewSource.GetDefaultView(Results);
GroupedResults.GroupDescriptions.Add(
new PropertyGroupDescription(nameof(LauncherItem.Group)));
}
// ─── 런처 표시 시 초기화 ──────────────────────────────────────────────────
/// <summary>런처가 표시될 때 호출 — 이전 상태 초기화</summary>
public void OnShown()
{
if (IsActionMode) { IsActionMode = false; _actionSourceItem = null; }
Results.Clear();
_lastSearchQuery = "";
ClearMerge();
}
// ─── 검색 ────────────────────────────────────────────────────────────────
/// <summary>
/// IME 조합 중 코드비하인드에서 직접 호출하는 검색 트리거.
/// InputText 바인딩을 건드리지 않으므로 한글 조합 상태가 유지됩니다.
/// 동일 쿼리로 재호출 시 (IME 조합 완성 이벤트 중복) 검색을 건너뜁니다.
/// </summary>
internal Task TriggerImeSearchAsync(string text)
{
var key = text.Trim().ToLowerInvariant();
// IME 조합 완성으로 인한 동일 쿼리 재검색이면 선택 위치 유지 (건너뜀)
if (key == _lastSearchQuery && Results.Count > 0) return Task.CompletedTask;
return SearchAsync(text);
}
private async Task SearchAsync(string query)
{
// CTS 취소는 setter에서 이미 처리됨. 새 토큰만 발급.
_searchCts = new CancellationTokenSource();
var ct = _searchCts.Token;
var queryKey = query.Trim().ToLowerInvariant();
// 동일 쿼리 재검색(IME 커밋 후 WPF 바인딩 갱신 등)이면 결과를 일단 유지
var isSameQuery = queryKey == _lastSearchQuery && Results.Count > 0;
if (!isSameQuery) Results.Clear();
if (string.IsNullOrWhiteSpace(query)) { _lastSearchQuery = ""; return; }
// 기능 비활성화 시 해당 프리픽스 쿼리는 빈 결과 반환
if (!_settings.Settings.Launcher.EnableFavorites &&
query.StartsWith("fav", StringComparison.OrdinalIgnoreCase)) return;
if (!_settings.Settings.Launcher.EnableRecent &&
query.StartsWith("recent", StringComparison.OrdinalIgnoreCase)) return;
// 동일 쿼리면 현재 선택 항목을 기억해 복원 시도
var prevSelected = isSameQuery ? SelectedItem : null;
IsLoading = true;
try
{
var items = await _resolver.ResolveAsync(query, ct);
if (ct.IsCancellationRequested) return;
// ReplaceAll: Clear+AddRange를 단일 Reset 이벤트로 → WPF 레이아웃 1회만 갱신
Results.ReplaceAll(items);
_lastSearchQuery = queryKey;
if (isSameQuery && prevSelected != null)
{
// 결과 목록이 같으면 이전 선택 복원 (ReferenceEquals: 동일 데이터 객체 비교)
var restored = Results.FirstOrDefault(r =>
ReferenceEquals(r.Data, prevSelected.Data) || r.Title == prevSelected.Title);
SelectedItem = restored ?? Results.FirstOrDefault();
}
else
{
SelectedItem = Results.FirstOrDefault();
}
}
catch (OperationCanceledException) { /* 정상 취소 */ }
catch (Exception ex)
{
LogService.Error($"검색 오류: {ex.Message}");
}
finally
{
if (!ct.IsCancellationRequested) IsLoading = false;
}
}
// ─── 실행 ────────────────────────────────────────────────────────────────
public async Task ExecuteSelectedAsync()
{
// 파일 액션 모드: 선택한 액션 실행
if (IsActionMode && SelectedItem?.Data is FileActionData fileAction)
{
ExecuteFileAction(fileAction);
ExitActionMode();
CloseRequested?.Invoke(this, EventArgs.Empty);
return;
}
if (SelectedItem == null) return;
// 창을 먼저 닫아 체감 속도 확보 → 실행은 백그라운드
CloseRequested?.Invoke(this, EventArgs.Empty);
try
{
await _resolver.ExecuteAsync(SelectedItem, InputText, CancellationToken.None);
}
catch (Exception ex)
{
NotificationRequested?.Invoke(this, $"실행 실패: {ex.Message}");
LogService.Error($"Execute 오류: {ex.Message}");
}
}
/// <summary>
/// 캡처 모드에서 Shift+Enter 시 지연 캡처 타이머 선택 목록을 표시합니다.
/// </summary>
public bool ShowDelayTimerItems()
{
if (SelectedItem?.Data is not string mode) return false;
// 이미 delay 아이템이면 실행으로 넘기기
if (mode.StartsWith("delay:")) return false;
// ScreenCaptureHandler 찾기
if (!_resolver.RegisteredHandlers.TryGetValue(
ActivePrefix ?? "", out var handler) ||
handler is not AxCopilot.Handlers.ScreenCaptureHandler capHandler)
return false;
var items = capHandler.GetDelayItems(mode).ToList();
Results.Clear();
foreach (var item in items) Results.Add(item);
SelectedItem = Results.FirstOrDefault();
return true;
}
public void SelectNext()
{
if (Results.Count == 0) return;
var idx = SelectedItem != null ? Results.IndexOf(SelectedItem) : -1;
SelectedItem = Results[(idx + 1) % Results.Count];
}
public void SelectPrev()
{
if (Results.Count == 0) return;
var idx = SelectedItem != null ? Results.IndexOf(SelectedItem) : 0;
SelectedItem = Results[(idx - 1 + Results.Count) % Results.Count];
}
// ─── Large Type ───────────────────────────────────────────────────────────
/// <summary>Shift+Enter 시 Large Type으로 표시할 텍스트 반환</summary>
public string GetLargeTypeText()
{
if (SelectedItem == null) return "";
// 계산기: 결과값 문자열
if (SelectedItem.Data is string s && !string.IsNullOrWhiteSpace(s)) return s;
// 클립보드 히스토리: 전체 텍스트
if (SelectedItem.Data is ClipboardEntry entry && entry.IsText) return entry.Text;
// 기본: 제목
return SelectedItem.Title;
}
}