런처 Agent Compare 기능 1차 이식 및 현재 런처 구조 연결
- Agent Compare 기준으로 런처 빠른 실행 칩, 검색 히스토리 탐색, 선택 항목 미리보기 패널을 현재 런처에 이식 - 하단 위젯 바, QuickLook(F3), 화면 OCR(F4), 관련 서비스/partial 파일을 현재 LauncherWindow/LauncherViewModel 구조에 연결 - UsageRankingService 상위 항목 조회와 SearchHistoryService를 추가해 실행 상위 경로/검색 기록이 실제 런처 동작에 반영되도록 정리 - README.md, docs/DEVELOPMENT.md에 이식 범위와 검증 결과를 2026-04-05 11:58 (KST) 기준으로 기록 검증 결과 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0
This commit is contained in:
322
src/AxCopilot/ViewModels/LauncherViewModel.LauncherExtras.cs
Normal file
322
src/AxCopilot/ViewModels/LauncherViewModel.LauncherExtras.cs
Normal file
@@ -0,0 +1,322 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace AxCopilot.ViewModels;
|
||||
|
||||
public partial class LauncherViewModel
|
||||
{
|
||||
private string _previewText = "";
|
||||
private bool _hasPreview;
|
||||
private CancellationTokenSource? _previewCts;
|
||||
|
||||
public ObservableCollection<QuickActionChip> QuickActionItems { get; } = new();
|
||||
public bool ShowQuickActions => string.IsNullOrEmpty(_inputText) && QuickActionItems.Count > 0;
|
||||
|
||||
public string PreviewText
|
||||
{
|
||||
get => _previewText;
|
||||
private set
|
||||
{
|
||||
_previewText = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPreview
|
||||
{
|
||||
get => _hasPreview;
|
||||
private set
|
||||
{
|
||||
_hasPreview = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string? NavigateHistoryPrev()
|
||||
{
|
||||
var history = SearchHistoryService.GetAll();
|
||||
if (history.Count == 0)
|
||||
return null;
|
||||
|
||||
_historyIndex = Math.Min(_historyIndex + 1, history.Count - 1);
|
||||
return history[_historyIndex];
|
||||
}
|
||||
|
||||
public string? NavigateHistoryNext()
|
||||
{
|
||||
if (_historyIndex <= 0)
|
||||
{
|
||||
_historyIndex = -1;
|
||||
return "";
|
||||
}
|
||||
|
||||
_historyIndex--;
|
||||
var history = SearchHistoryService.GetAll();
|
||||
return _historyIndex >= 0 && _historyIndex < history.Count
|
||||
? history[_historyIndex]
|
||||
: "";
|
||||
}
|
||||
|
||||
public void SetInputFromHistory(string text)
|
||||
{
|
||||
_isHistoryNavigation = true;
|
||||
try
|
||||
{
|
||||
InputText = text;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isHistoryNavigation = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void LoadQuickActions()
|
||||
{
|
||||
QuickActionItems.Clear();
|
||||
|
||||
var topItems = UsageRankingService.GetTopItems(16);
|
||||
var added = 0;
|
||||
foreach (var (path, _) in topItems)
|
||||
{
|
||||
if (added >= 8)
|
||||
break;
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(path);
|
||||
var isFolder = Directory.Exists(expanded);
|
||||
var isFile = !isFolder && File.Exists(expanded);
|
||||
if (!isFolder && !isFile)
|
||||
continue;
|
||||
|
||||
var ext = Path.GetExtension(expanded).ToLowerInvariant();
|
||||
var title = Path.GetFileNameWithoutExtension(expanded);
|
||||
if (string.IsNullOrEmpty(title))
|
||||
title = Path.GetFileName(expanded);
|
||||
|
||||
var symbol = isFolder ? Symbols.Folder
|
||||
: ext == ".exe" ? Symbols.App
|
||||
: ext is ".lnk" or ".url" ? Symbols.App
|
||||
: Symbols.File;
|
||||
|
||||
var color = isFolder ? Color.FromRgb(0x10, 0x7C, 0x10)
|
||||
: ext == ".exe" ? Color.FromRgb(0x4B, 0x5E, 0xFC)
|
||||
: ext is ".lnk" or ".url" ? Color.FromRgb(0x4B, 0x5E, 0xFC)
|
||||
: Color.FromRgb(0x5B, 0x4E, 0x7E);
|
||||
|
||||
var bg = new SolidColorBrush(Color.FromArgb(0x26, color.R, color.G, color.B));
|
||||
QuickActionItems.Add(new QuickActionChip(title, symbol, path, bg));
|
||||
added++;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ShowQuickActions));
|
||||
}
|
||||
|
||||
private async Task UpdatePreviewAsync(LauncherItem? item)
|
||||
{
|
||||
_previewCts?.Cancel();
|
||||
_previewCts = new CancellationTokenSource();
|
||||
var ct = _previewCts.Token;
|
||||
|
||||
if (item == null)
|
||||
{
|
||||
HasPreview = false;
|
||||
PreviewText = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(80, ct);
|
||||
|
||||
if (item.Data is ClipboardEntry clipEntry && clipEntry.IsText)
|
||||
{
|
||||
var text = clipEntry.Text ?? string.Empty;
|
||||
PreviewText = text.Length > 400 ? text[..400] + "…" : text;
|
||||
HasPreview = !string.IsNullOrEmpty(PreviewText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Data is IndexEntry indexEntry)
|
||||
{
|
||||
var path = Environment.ExpandEnvironmentVariables(indexEntry.Path);
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
|
||||
if (IsPreviewableTextFile(ext) && File.Exists(path))
|
||||
{
|
||||
var lines = await ReadFirstLinesAsync(path, 6, ct);
|
||||
if (ct.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
PreviewText = string.Join("\n", lines);
|
||||
HasPreview = !string.IsNullOrWhiteSpace(PreviewText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsImageFile(ext) && File.Exists(path))
|
||||
{
|
||||
var meta = await Task.Run(() => GetImageMeta(path), ct);
|
||||
if (ct.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrEmpty(meta))
|
||||
{
|
||||
PreviewText = meta;
|
||||
HasPreview = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ext == ".pdf" && File.Exists(path))
|
||||
{
|
||||
var meta = await Task.Run(() => GetPdfMeta(path), ct);
|
||||
if (ct.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrEmpty(meta))
|
||||
{
|
||||
PreviewText = meta;
|
||||
HasPreview = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (IsMediaFile(ext) && File.Exists(path))
|
||||
{
|
||||
var meta = GetFileSizeMeta(path, ext);
|
||||
if (!string.IsNullOrEmpty(meta))
|
||||
{
|
||||
PreviewText = meta;
|
||||
HasPreview = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PreviewText = string.Empty;
|
||||
HasPreview = false;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch
|
||||
{
|
||||
PreviewText = string.Empty;
|
||||
HasPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPreviewableTextFile(string ext) => ext is
|
||||
".txt" or ".md" or ".log" or ".csv" or ".json" or ".xml"
|
||||
or ".yaml" or ".yml" or ".ini" or ".cfg" or ".conf"
|
||||
or ".cs" or ".py" or ".js" or ".ts" or ".html" or ".css";
|
||||
|
||||
private static bool IsImageFile(string ext) => ext is
|
||||
".jpg" or ".jpeg" or ".png" or ".gif" or ".bmp" or ".webp"
|
||||
or ".svg" or ".ico" or ".tiff" or ".tif";
|
||||
|
||||
private static bool IsMediaFile(string ext) => ext is
|
||||
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma"
|
||||
or ".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm";
|
||||
|
||||
private static string? GetImageMeta(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fi = new FileInfo(path);
|
||||
var size = FormatFileSize(fi.Length);
|
||||
var ext = fi.Extension.TrimStart('.').ToUpperInvariant();
|
||||
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var decoder = BitmapDecoder.Create(
|
||||
stream,
|
||||
BitmapCreateOptions.DelayCreation,
|
||||
BitmapCacheOption.None);
|
||||
var frame = decoder.Frames[0];
|
||||
|
||||
return $"이미지 {ext} · {frame.PixelWidth}x{frame.PixelHeight} · {size}\n수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetPdfMeta(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fi = new FileInfo(path);
|
||||
var size = FormatFileSize(fi.Length);
|
||||
|
||||
using var doc = PdfDocument.Open(path);
|
||||
var pages = doc.NumberOfPages;
|
||||
var firstPageText = string.Empty;
|
||||
if (pages > 0)
|
||||
{
|
||||
firstPageText = doc.GetPage(1).Text;
|
||||
if (firstPageText.Length > 200)
|
||||
firstPageText = firstPageText[..200] + "…";
|
||||
firstPageText = firstPageText.Replace("\r\n", " ").Replace("\n", " ");
|
||||
}
|
||||
|
||||
var meta = $"PDF · {pages}페이지 · {size}";
|
||||
if (!string.IsNullOrWhiteSpace(firstPageText))
|
||||
meta += $"\n{firstPageText}";
|
||||
return meta;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetFileSizeMeta(string path, string ext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fi = new FileInfo(path);
|
||||
var size = FormatFileSize(fi.Length);
|
||||
var type = ext switch
|
||||
{
|
||||
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma" => "오디오",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm" => "동영상",
|
||||
_ => "파일"
|
||||
};
|
||||
|
||||
return $"{type} · {ext.TrimStart('.').ToUpperInvariant()} · {size}\n수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => $"{bytes} B",
|
||||
< 1048576 => $"{bytes / 1024.0:F1} KB",
|
||||
< 1073741824 => $"{bytes / 1048576.0:F1} MB",
|
||||
_ => $"{bytes / 1073741824.0:F2} GB"
|
||||
};
|
||||
|
||||
private static async Task<IEnumerable<string>> ReadFirstLinesAsync(string path, int maxLines, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
using var reader = new StreamReader(path, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
for (var i = 0; i < maxLines && !reader.EndOfStream; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var line = await reader.ReadLineAsync(ct);
|
||||
if (line != null)
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
114
src/AxCopilot/ViewModels/LauncherViewModel.Widgets.cs
Normal file
114
src/AxCopilot/ViewModels/LauncherViewModel.Widgets.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using AxCopilot.Handlers;
|
||||
using AxCopilot.Services;
|
||||
using System.IO;
|
||||
|
||||
namespace AxCopilot.ViewModels;
|
||||
|
||||
public partial class LauncherViewModel
|
||||
{
|
||||
public string Widget_PerfText
|
||||
{
|
||||
get
|
||||
{
|
||||
var perf = PerformanceMonitorService.Instance;
|
||||
return $"CPU {perf.CpuPercent:F0}% RAM {perf.RamPercent:F0}% {perf.DiskCText}";
|
||||
}
|
||||
}
|
||||
|
||||
public string Widget_PomoText
|
||||
{
|
||||
get
|
||||
{
|
||||
var pomo = PomodoroService.Instance;
|
||||
var remain = pomo.Remaining;
|
||||
var clock = $"{(int)remain.TotalMinutes:D2}:{remain.Seconds:D2}";
|
||||
return pomo.Mode switch
|
||||
{
|
||||
PomodoroMode.Focus => $"집중 {clock}",
|
||||
PomodoroMode.Break => $"휴식 {clock}",
|
||||
_ => $"대기 {clock}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool Widget_PomoRunning => PomodoroService.Instance.IsRunning;
|
||||
|
||||
private int _widgetNoteCount;
|
||||
public string Widget_NoteText => _widgetNoteCount > 0 ? $"메모 {_widgetNoteCount}건" : "메모 없음";
|
||||
|
||||
public bool Widget_OllamaOnline => ServerStatusService.Instance.OllamaOnline;
|
||||
public bool Widget_LlmOnline => ServerStatusService.Instance.LlmOnline;
|
||||
public bool Widget_McpOnline => ServerStatusService.Instance.McpOnline;
|
||||
public string Widget_McpName => ServerStatusService.Instance.McpName;
|
||||
|
||||
private string _widgetWeatherText = "--";
|
||||
public string Widget_WeatherText
|
||||
{
|
||||
get => _widgetWeatherText;
|
||||
internal set { _widgetWeatherText = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
public string Widget_CalText =>
|
||||
DateTime.Now.ToString("M/d (ddd)", System.Globalization.CultureInfo.GetCultureInfo("ko-KR"));
|
||||
|
||||
private string _widgetBatteryText = "--";
|
||||
private string _widgetBatteryIcon = "\uE83F";
|
||||
private bool _widgetBatteryVisible;
|
||||
|
||||
public string Widget_BatteryText
|
||||
{
|
||||
get => _widgetBatteryText;
|
||||
internal set { _widgetBatteryText = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
public string Widget_BatteryIcon
|
||||
{
|
||||
get => _widgetBatteryIcon;
|
||||
internal set { _widgetBatteryIcon = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
public bool Widget_BatteryVisible
|
||||
{
|
||||
get => _widgetBatteryVisible;
|
||||
internal set { _widgetBatteryVisible = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private int _widgetRefreshTick;
|
||||
|
||||
public void UpdateWidgets()
|
||||
{
|
||||
_widgetRefreshTick++;
|
||||
if (_widgetRefreshTick % 5 == 0)
|
||||
_widgetNoteCount = GetNoteCount();
|
||||
|
||||
OnPropertyChanged(nameof(Widget_PerfText));
|
||||
OnPropertyChanged(nameof(Widget_PomoText));
|
||||
OnPropertyChanged(nameof(Widget_PomoRunning));
|
||||
OnPropertyChanged(nameof(Widget_NoteText));
|
||||
OnPropertyChanged(nameof(Widget_OllamaOnline));
|
||||
OnPropertyChanged(nameof(Widget_LlmOnline));
|
||||
OnPropertyChanged(nameof(Widget_McpOnline));
|
||||
OnPropertyChanged(nameof(Widget_McpName));
|
||||
OnPropertyChanged(nameof(Widget_CalText));
|
||||
}
|
||||
|
||||
private static int GetNoteCount()
|
||||
{
|
||||
try
|
||||
{
|
||||
var notesFile = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot",
|
||||
"notes.txt");
|
||||
if (!File.Exists(notesFile))
|
||||
return 0;
|
||||
|
||||
return File.ReadLines(notesFile, System.Text.Encoding.UTF8)
|
||||
.Count(line => !string.IsNullOrWhiteSpace(line));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.ViewModels;
|
||||
|
||||
public class LauncherViewModel : INotifyPropertyChanged
|
||||
public partial class LauncherViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private readonly CommandResolver _resolver;
|
||||
private readonly SettingsService _settings;
|
||||
@@ -21,6 +21,8 @@ public class LauncherViewModel : INotifyPropertyChanged
|
||||
private string _inputText = "";
|
||||
private LauncherItem? _selectedItem;
|
||||
private bool _isLoading;
|
||||
private bool _isHistoryNavigation;
|
||||
private int _historyIndex = -1;
|
||||
private CancellationTokenSource? _searchCts;
|
||||
private System.Threading.Timer? _debounceTimer;
|
||||
private const int DebounceMs = 30; // 30ms 디바운스 — 연속 입력 시 중간 검색 스킵
|
||||
@@ -48,6 +50,8 @@ public class LauncherViewModel : INotifyPropertyChanged
|
||||
{
|
||||
if (_inputText == value) return;
|
||||
_inputText = value;
|
||||
if (!_isHistoryNavigation)
|
||||
_historyIndex = -1;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasActivePrefix));
|
||||
OnPropertyChanged(nameof(ActivePrefixLabel));
|
||||
@@ -56,6 +60,7 @@ public class LauncherViewModel : INotifyPropertyChanged
|
||||
OnPropertyChanged(nameof(IsClipboardMode));
|
||||
OnPropertyChanged(nameof(ShowMergeHint));
|
||||
OnPropertyChanged(nameof(MergeHintText));
|
||||
OnPropertyChanged(nameof(ShowQuickActions));
|
||||
|
||||
// 연속 입력 시 이전 검색 즉시 취소 + 50ms 디바운스 후 실제 검색 시작
|
||||
_searchCts?.Cancel();
|
||||
@@ -77,7 +82,12 @@ public class LauncherViewModel : INotifyPropertyChanged
|
||||
public LauncherItem? SelectedItem
|
||||
{
|
||||
get => _selectedItem;
|
||||
set { _selectedItem = value; OnPropertyChanged(); }
|
||||
set
|
||||
{
|
||||
_selectedItem = value;
|
||||
OnPropertyChanged();
|
||||
_ = UpdatePreviewAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoading
|
||||
@@ -245,7 +255,9 @@ public class LauncherViewModel : INotifyPropertyChanged
|
||||
if (IsActionMode) { IsActionMode = false; _actionSourceItem = null; }
|
||||
Results.Clear();
|
||||
_lastSearchQuery = "";
|
||||
_historyIndex = -1;
|
||||
ClearMerge();
|
||||
LoadQuickActions();
|
||||
}
|
||||
|
||||
// ─── 검색 ────────────────────────────────────────────────────────────────
|
||||
@@ -333,6 +345,9 @@ public class LauncherViewModel : INotifyPropertyChanged
|
||||
|
||||
if (SelectedItem == null) return;
|
||||
|
||||
if (InputText.Trim().Length >= 2)
|
||||
SearchHistoryService.Add(InputText.Trim());
|
||||
|
||||
// 창을 먼저 닫아 체감 속도 확보 → 실행은 백그라운드
|
||||
CloseRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user