Initial commit to new repository
This commit is contained in:
805
src/AxCopilot/ViewModels/LauncherViewModel.cs
Normal file
805
src/AxCopilot/ViewModels/LauncherViewModel.cs
Normal file
@@ -0,0 +1,805 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using AxCopilot.Core;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.ViewModels;
|
||||
|
||||
public 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();
|
||||
|
||||
// ─── 기본 프로퍼티 ────────────────────────────────────────────────────────
|
||||
|
||||
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") },
|
||||
};
|
||||
|
||||
// ─── 설정 기능 토글 (런처 실동작 연결) ──────────────────────────────────
|
||||
/// <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;
|
||||
}
|
||||
|
||||
// ─── 런처 표시 시 초기화 ──────────────────────────────────────────────────
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
// ─── 파일 액션 서브메뉴 ───────────────────────────────────────────────────
|
||||
|
||||
public void EnterActionMode(LauncherItem item)
|
||||
{
|
||||
if (!_settings.Settings.Launcher.EnableActionMode) return;
|
||||
if (item.Data is not IndexEntry entry) return;
|
||||
|
||||
_actionSourceItem = item;
|
||||
_savedQuery = _inputText;
|
||||
IsActionMode = true;
|
||||
|
||||
var path = Environment.ExpandEnvironmentVariables(entry.Path);
|
||||
var isDir = Directory.Exists(path);
|
||||
var name = Path.GetFileName(path);
|
||||
|
||||
Results.Clear();
|
||||
Results.Add(MakeAction("경로 복사",
|
||||
path, FileAction.CopyPath, Symbols.Clipboard, "#8764B8"));
|
||||
|
||||
Results.Add(MakeAction("전체 경로 복사",
|
||||
path, FileAction.CopyFullPath, Symbols.Clipboard, "#C55A11"));
|
||||
|
||||
Results.Add(MakeAction("파일 탐색기에서 열기",
|
||||
"Explorer에서 위치 선택됨으로 표시", FileAction.OpenExplorer, Symbols.Folder, "#107C10"));
|
||||
|
||||
if (!isDir)
|
||||
Results.Add(MakeAction("관리자 권한으로 실행",
|
||||
"UAC 권한 상승 후 실행", FileAction.RunAsAdmin, Symbols.Lock, "#C50F1F"));
|
||||
|
||||
Results.Add(MakeAction("터미널에서 열기",
|
||||
isDir ? path : Path.GetDirectoryName(path) ?? path,
|
||||
FileAction.OpenTerminal, Symbols.Terminal, "#323130"));
|
||||
|
||||
if (!isDir)
|
||||
Results.Add(MakeAction("파일 속성 보기",
|
||||
"Windows 속성 대화 상자 열기", FileAction.ShowProperties, Symbols.Info, "#6B2C91"));
|
||||
|
||||
Results.Add(MakeAction("이름 바꾸기",
|
||||
name, FileAction.Rename, Symbols.Rename, "#D97706"));
|
||||
|
||||
Results.Add(MakeAction("휴지통으로 삭제",
|
||||
"복구 가능한 삭제 · 확인 후 실행", FileAction.DeleteToRecycleBin, Symbols.Delete, "#C50F1F"));
|
||||
|
||||
SelectedItem = Results.FirstOrDefault();
|
||||
|
||||
static LauncherItem MakeAction(string title, string subtitle,
|
||||
FileAction action, string symbol, string colorHex)
|
||||
{
|
||||
var data = new FileActionData(subtitle, action);
|
||||
return new LauncherItem(title, subtitle, null, data, Symbol: symbol);
|
||||
}
|
||||
}
|
||||
|
||||
public void ExitActionMode()
|
||||
{
|
||||
IsActionMode = false;
|
||||
_actionSourceItem = null;
|
||||
// 이전 검색 쿼리 복원
|
||||
var q = _savedQuery;
|
||||
_savedQuery = "";
|
||||
_ = SearchAsync(q);
|
||||
}
|
||||
|
||||
private static void ExecuteFileAction(FileActionData data)
|
||||
{
|
||||
var path = data.Path;
|
||||
switch (data.Action)
|
||||
{
|
||||
case FileAction.CopyPath:
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
|
||||
break;
|
||||
|
||||
case FileAction.OpenExplorer:
|
||||
if (File.Exists(path))
|
||||
Process.Start("explorer.exe", $"/select,\"{path}\"");
|
||||
else
|
||||
Process.Start("explorer.exe", $"\"{path}\"");
|
||||
break;
|
||||
|
||||
case FileAction.RunAsAdmin:
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path)
|
||||
{ UseShellExecute = true, Verb = "runas" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"관리자 실행 취소: {ex.Message}");
|
||||
}
|
||||
break;
|
||||
|
||||
case FileAction.CopyFullPath:
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
|
||||
break;
|
||||
|
||||
case FileAction.ShowProperties:
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("explorer.exe")
|
||||
{
|
||||
Arguments = $"/select,\"{path}\"",
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(psi);
|
||||
// Shell property dialog
|
||||
var propInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "rundll32.exe",
|
||||
Arguments = $"shell32.dll,ShellExec_RunDLL \"properties\" \"{path}\"",
|
||||
UseShellExecute = false
|
||||
};
|
||||
// 대안: Shell verb "properties"
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path)
|
||||
{ UseShellExecute = true, Verb = "properties" });
|
||||
}
|
||||
catch (Exception) { /* 일부 파일 형식에서 지원 안됨 */ }
|
||||
}
|
||||
catch (Exception ex) { LogService.Warn($"속성 열기 실패: {ex.Message}"); }
|
||||
break;
|
||||
|
||||
case FileAction.Rename:
|
||||
// 런처에서 rename 핸들러로 전달
|
||||
// ExitActionMode 후 InputText가 rename 프리픽스로 설정됨
|
||||
break;
|
||||
|
||||
case FileAction.DeleteToRecycleBin:
|
||||
// LauncherWindow.xaml.cs의 ExecuteSelected에서 확인 다이얼로그 처리
|
||||
break;
|
||||
|
||||
case FileAction.OpenTerminal:
|
||||
var dir = File.Exists(path) ? Path.GetDirectoryName(path) ?? path : path;
|
||||
try { Process.Start("wt.exe", $"-d \"{dir}\""); }
|
||||
catch (Exception)
|
||||
{
|
||||
try { Process.Start("cmd.exe", $"/k cd /d \"{dir}\""); }
|
||||
catch (Exception ex) { LogService.Warn($"터미널 열기 실패: {ex.Message}"); }
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 단축키 지원 메서드 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>선택된 항목의 경로를 클립보드에 복사</summary>
|
||||
public bool CopySelectedPath()
|
||||
{
|
||||
if (SelectedItem?.Data is IndexEntry entry)
|
||||
{
|
||||
var path = Path.GetFileName(Environment.ExpandEnvironmentVariables(entry.Path));
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목의 전체 경로를 클립보드에 복사</summary>
|
||||
public bool CopySelectedFullPath()
|
||||
{
|
||||
if (SelectedItem?.Data is IndexEntry entry)
|
||||
{
|
||||
var path = Environment.ExpandEnvironmentVariables(entry.Path);
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목을 탐색기에서 열기</summary>
|
||||
public bool OpenSelectedInExplorer()
|
||||
{
|
||||
if (SelectedItem?.Data is IndexEntry entry)
|
||||
{
|
||||
var path = Environment.ExpandEnvironmentVariables(entry.Path);
|
||||
if (File.Exists(path))
|
||||
Process.Start("explorer.exe", $"/select,\"{path}\"");
|
||||
else if (Directory.Exists(path))
|
||||
Process.Start("explorer.exe", $"\"{path}\"");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목을 관리자 권한으로 실행</summary>
|
||||
public bool RunSelectedAsAdmin()
|
||||
{
|
||||
if (SelectedItem?.Data is IndexEntry entry)
|
||||
{
|
||||
var path = Environment.ExpandEnvironmentVariables(entry.Path);
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path)
|
||||
{ UseShellExecute = true, Verb = "runas" });
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { LogService.Warn($"관리자 실행 취소: {ex.Message}"); }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목의 속성 창 열기</summary>
|
||||
public bool ShowSelectedProperties()
|
||||
{
|
||||
if (SelectedItem?.Data is IndexEntry entry)
|
||||
{
|
||||
var path = Environment.ExpandEnvironmentVariables(entry.Path);
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(path)
|
||||
{ UseShellExecute = true, Verb = "properties" });
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// properties verb 미지원 시 탐색기에서 선택
|
||||
Process.Start("explorer.exe", $"/select,\"{path}\"");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>최근 기록에서 항목 삭제 (Delete 키용)</summary>
|
||||
public bool RemoveSelectedFromRecent()
|
||||
{
|
||||
if (SelectedItem == null || Results.Count == 0) return false;
|
||||
|
||||
var idx = Results.IndexOf(SelectedItem);
|
||||
Results.Remove(SelectedItem);
|
||||
|
||||
if (Results.Count > 0)
|
||||
SelectedItem = Results[Math.Min(idx, Results.Count - 1)];
|
||||
else
|
||||
SelectedItem = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>입력창 초기화</summary>
|
||||
public void ClearInput()
|
||||
{
|
||||
InputText = "";
|
||||
}
|
||||
|
||||
/// <summary>첫 번째 결과 항목 선택</summary>
|
||||
public void SelectFirst()
|
||||
{
|
||||
if (Results.Count > 0) SelectedItem = Results[0];
|
||||
}
|
||||
|
||||
/// <summary>마지막 결과 항목 선택</summary>
|
||||
public void SelectLast()
|
||||
{
|
||||
if (Results.Count > 0) SelectedItem = Results[^1];
|
||||
}
|
||||
|
||||
/// <summary>현재 선택된 파일/폴더 항목을 즐겨찾기에 추가하거나 제거합니다.</summary>
|
||||
/// <returns>(추가됐으면 true, 제거됐으면 false, 대상 없으면 null)</returns>
|
||||
public bool? ToggleFavorite()
|
||||
{
|
||||
if (SelectedItem?.Data is not IndexEntry entry) return null;
|
||||
|
||||
var path = Environment.ExpandEnvironmentVariables(entry.Path);
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileName(path);
|
||||
|
||||
var favFile = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "favorites.json");
|
||||
|
||||
try
|
||||
{
|
||||
var opts = new System.Text.Json.JsonSerializerOptions
|
||||
{ WriteIndented = true, PropertyNameCaseInsensitive = true };
|
||||
|
||||
List<FavJson> list = new();
|
||||
if (File.Exists(favFile))
|
||||
list = System.Text.Json.JsonSerializer.Deserialize<List<FavJson>>(
|
||||
File.ReadAllText(favFile), opts) ?? new();
|
||||
|
||||
var existing = list.FirstOrDefault(f =>
|
||||
f.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
list.Remove(existing);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(favFile)!);
|
||||
File.WriteAllText(favFile, System.Text.Json.JsonSerializer.Serialize(list, opts));
|
||||
return false; // 제거됨
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Insert(0, new FavJson { Name = name, Path = path });
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(favFile)!);
|
||||
File.WriteAllText(favFile, System.Text.Json.JsonSerializer.Serialize(list, opts));
|
||||
return true; // 추가됨
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"즐겨찾기 토글 실패: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>선택 항목의 디렉터리에서 터미널을 열기.</summary>
|
||||
/// <returns>성공 여부</returns>
|
||||
public bool OpenSelectedInTerminal()
|
||||
{
|
||||
string dir;
|
||||
if (SelectedItem?.Data is IndexEntry entry)
|
||||
{
|
||||
var path = Environment.ExpandEnvironmentVariables(entry.Path);
|
||||
dir = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path;
|
||||
}
|
||||
else
|
||||
{
|
||||
dir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
}
|
||||
|
||||
try { Process.Start("wt.exe", $"-d \"{dir}\""); return true; }
|
||||
catch (Exception)
|
||||
{
|
||||
try { Process.Start("cmd.exe", $"/k cd /d \"{dir}\""); return true; }
|
||||
catch (Exception ex) { LogService.Warn($"터미널 열기 실패: {ex.Message}"); return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>다운로드 폴더를 cd 프리픽스로 탐색합니다.</summary>
|
||||
public void NavigateToDownloads()
|
||||
{
|
||||
var downloads = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||
InputText = $"cd {downloads}";
|
||||
}
|
||||
|
||||
// 즐겨찾기 직렬화용 내부 레코드
|
||||
private sealed class FavJson
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
[System.Text.Json.Serialization.JsonPropertyName("path")]
|
||||
public string Path { get; set; } = "";
|
||||
}
|
||||
|
||||
// ─── 클립보드 병합 ────────────────────────────────────────────────────────
|
||||
|
||||
public void ToggleMergeItem(LauncherItem? item)
|
||||
{
|
||||
if (item?.Data is not ClipboardEntry entry || !entry.IsText) return;
|
||||
|
||||
if (!_mergeQueue.Remove(entry))
|
||||
_mergeQueue.Add(entry);
|
||||
|
||||
OnPropertyChanged(nameof(MergeCount));
|
||||
OnPropertyChanged(nameof(ShowMergeHint));
|
||||
OnPropertyChanged(nameof(MergeHintText));
|
||||
}
|
||||
|
||||
/// <summary>선택된 항목들을 줄바꿈으로 합쳐 클립보드에 복사</summary>
|
||||
public void ExecuteMerge()
|
||||
{
|
||||
if (_mergeQueue.Count == 0) return;
|
||||
|
||||
// 선택 순서 보존: Results에서 보이는 순서 기준
|
||||
var ordered = Results
|
||||
.Where(r => r.Data is ClipboardEntry e && _mergeQueue.Contains(e))
|
||||
.Select(r => ((ClipboardEntry)r.Data!).Text)
|
||||
.ToList();
|
||||
|
||||
if (ordered.Count == 0)
|
||||
ordered = _mergeQueue.Select(e => e.Text).ToList();
|
||||
|
||||
var merged = string.Join("\n", ordered);
|
||||
try { Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(merged)); }
|
||||
catch (Exception ex) { LogService.Warn($"병합 클립보드 실패: {ex.Message}"); }
|
||||
|
||||
ClearMerge();
|
||||
CloseRequested?.Invoke(this, EventArgs.Empty);
|
||||
LogService.Info($"클립보드 병합: {ordered.Count}개 항목");
|
||||
}
|
||||
|
||||
public void ClearMerge()
|
||||
{
|
||||
_mergeQueue.Clear();
|
||||
OnPropertyChanged(nameof(MergeCount));
|
||||
OnPropertyChanged(nameof(ShowMergeHint));
|
||||
OnPropertyChanged(nameof(MergeHintText));
|
||||
}
|
||||
|
||||
// ─── INotifyPropertyChanged ───────────────────────────────────────────────
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
protected void OnPropertyChanged([CallerMemberName] string? name = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
// ─── 파일 액션 데이터 타입 ────────────────────────────────────────────────────
|
||||
|
||||
public enum FileAction { CopyPath, CopyFullPath, OpenExplorer, RunAsAdmin, OpenTerminal, ShowProperties, Rename, DeleteToRecycleBin }
|
||||
|
||||
public record FileActionData(string Path, FileAction Action);
|
||||
Reference in New Issue
Block a user