Initial commit to new repository

This commit is contained in:
2026-04-03 18:23:52 +09:00
commit deffb33cf9
5248 changed files with 267762 additions and 0 deletions

View 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);