런처 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:
2026-04-05 11:51:43 +09:00
parent 0336904258
commit f7cafe0cfc
17 changed files with 2518 additions and 24 deletions

View File

@@ -0,0 +1,14 @@
using System.Windows.Media;
namespace AxCopilot.Models;
/// <summary>
/// 런처 입력창 아래에 표시되는 빠른 실행 칩 모델입니다.
/// 최근 자주 실행한 경로를 한 번 더 검색하지 않고 바로 열 수 있습니다.
/// </summary>
public record QuickActionChip(
string Title,
string Symbol,
string Path,
Brush Background
);

View File

@@ -0,0 +1,133 @@
using System.IO;
using System.Runtime.InteropServices;
namespace AxCopilot.Services;
internal sealed class PerformanceMonitorService
{
public static readonly PerformanceMonitorService Instance = new();
[StructLayout(LayoutKind.Sequential)]
private struct FILETIME
{
public uint Low;
public uint High;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MEMORYSTATUSEX
{
public uint dwLength;
public uint dwMemoryLoad;
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetSystemTimes(out FILETIME idle, out FILETIME kernel, out FILETIME user);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
public double CpuPercent { get; private set; }
public double RamPercent { get; private set; }
public string RamText { get; private set; } = "";
public double DiskCPercent { get; private set; }
public string DiskCText { get; private set; } = "";
private System.Threading.Timer? _timer;
private FILETIME _prevIdle;
private FILETIME _prevKernel;
private FILETIME _prevUser;
private bool _hasPrev;
private PerformanceMonitorService() { }
public void StartPolling()
{
if (_timer != null)
return;
_timer = new System.Threading.Timer(_ => Sample(), null, 0, 2000);
}
public void StopPolling()
{
_timer?.Dispose();
_timer = null;
_hasPrev = false;
}
private void Sample()
{
SampleCpu();
SampleRam();
SampleDisk();
}
private void SampleCpu()
{
try
{
if (!GetSystemTimes(out var idle, out var kernel, out var user))
return;
if (_hasPrev)
{
var idleDelta = ToUlong(idle) - ToUlong(_prevIdle);
var kernelDelta = ToUlong(kernel) - ToUlong(_prevKernel);
var userDelta = ToUlong(user) - ToUlong(_prevUser);
var total = kernelDelta + userDelta;
var busy = total - idleDelta;
CpuPercent = total > 0 ? Math.Clamp(100.0 * busy / total, 0, 100) : 0;
}
_prevIdle = idle;
_prevKernel = kernel;
_prevUser = user;
_hasPrev = true;
}
catch { }
}
private void SampleRam()
{
try
{
var mem = new MEMORYSTATUSEX { dwLength = (uint)Marshal.SizeOf<MEMORYSTATUSEX>() };
if (!GlobalMemoryStatusEx(ref mem))
return;
RamPercent = mem.dwMemoryLoad;
var usedGb = (mem.ullTotalPhys - mem.ullAvailPhys) / (1024.0 * 1024 * 1024);
var totalGb = mem.ullTotalPhys / (1024.0 * 1024 * 1024);
RamText = $"{usedGb:F1}/{totalGb:F0}GB";
}
catch { }
}
private void SampleDisk()
{
try
{
var drive = DriveInfo.GetDrives()
.FirstOrDefault(d => d.IsReady && d.Name.StartsWith("C", StringComparison.OrdinalIgnoreCase));
if (drive == null)
return;
var usedBytes = drive.TotalSize - drive.AvailableFreeSpace;
DiskCPercent = 100.0 * usedBytes / drive.TotalSize;
var usedGb = usedBytes / (1024.0 * 1024 * 1024);
var totalGb = drive.TotalSize / (1024.0 * 1024 * 1024);
DiskCText = $"C:{usedGb:F0}/{totalGb:F0}GB";
}
catch { }
}
private static ulong ToUlong(FILETIME ft) => ((ulong)ft.High << 32) | ft.Low;
}

View File

@@ -0,0 +1,108 @@
using System.IO;
using System.Text.Json;
namespace AxCopilot.Services;
/// <summary>
/// 런처 검색 히스토리를 로컬 파일로 관리합니다.
/// 위/아래 키로 이전 검색어를 다시 불러올 때 사용됩니다.
/// </summary>
internal static class SearchHistoryService
{
private const int MaxItems = 50;
private static readonly string HistoryFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot",
"search_history.json");
private static readonly object SyncLock = new();
private static List<string> _history = new();
private static bool _loaded;
public static void Add(string query)
{
if (string.IsNullOrWhiteSpace(query))
return;
var trimmed = query.Trim();
if (trimmed.Length < 2)
return;
EnsureLoaded();
lock (SyncLock)
{
if (_history.Count > 0 && string.Equals(_history[0], trimmed, StringComparison.Ordinal))
return;
_history.Remove(trimmed);
_history.Insert(0, trimmed);
if (_history.Count > MaxItems)
_history.RemoveAt(_history.Count - 1);
}
_ = SaveAsync();
}
public static IReadOnlyList<string> GetAll()
{
EnsureLoaded();
lock (SyncLock)
return _history.AsReadOnly();
}
public static void Clear()
{
lock (SyncLock)
_history.Clear();
_ = SaveAsync();
}
private static void EnsureLoaded()
{
if (_loaded)
return;
lock (SyncLock)
{
if (_loaded)
return;
try
{
if (File.Exists(HistoryFile))
{
var json = File.ReadAllText(HistoryFile);
_history = JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
}
}
catch (Exception ex)
{
LogService.Warn($"search_history.json 로드 실패: {ex.Message}");
_history = new List<string>();
}
_loaded = true;
}
}
private static async Task SaveAsync()
{
try
{
List<string> snapshot;
lock (SyncLock)
snapshot = new List<string>(_history);
Directory.CreateDirectory(Path.GetDirectoryName(HistoryFile)!);
var json = JsonSerializer.Serialize(snapshot);
await File.WriteAllTextAsync(HistoryFile, json);
}
catch (Exception ex)
{
LogService.Warn($"search_history.json 저장 실패: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,115 @@
using System.Net.Http;
using AxCopilot.Models;
namespace AxCopilot.Services;
internal sealed class ServerStatusService
{
public static readonly ServerStatusService Instance = new();
private static readonly HttpClient Http = new()
{
Timeout = TimeSpan.FromMilliseconds(1500)
};
public bool OllamaOnline { get; private set; }
public bool LlmOnline { get; private set; }
public bool McpOnline { get; private set; }
public string McpName { get; private set; } = "MCP";
public event EventHandler? StatusChanged;
private System.Threading.Timer? _timer;
private string _ollamaEndpoint = "http://localhost:11434";
private string _llmEndpoint = "";
private string _llmService = "Ollama";
private string _mcpEndpoint = "";
private ServerStatusService() { }
public void Start(AppSettings? settings = null)
{
LoadEndpoints(settings);
if (_timer != null)
return;
_timer = new System.Threading.Timer(async _ => await CheckAllAsync(), null, 0, 15000);
}
public void Stop()
{
_timer?.Dispose();
_timer = null;
}
public void Refresh(AppSettings? settings = null)
{
LoadEndpoints(settings);
_ = CheckAllAsync();
}
private void LoadEndpoints(AppSettings? settings)
{
var llm = settings?.Llm;
if (llm == null)
return;
_ollamaEndpoint = llm.OllamaEndpoint?.TrimEnd('/') ?? "http://localhost:11434";
_llmService = llm.Service ?? "Ollama";
_llmEndpoint = string.Equals(_llmService, "vLLM", StringComparison.OrdinalIgnoreCase)
? (llm.VllmEndpoint?.TrimEnd('/') ?? "")
: _ollamaEndpoint;
var mcp = llm.McpServers?.FirstOrDefault(s => s.Enabled && !string.IsNullOrWhiteSpace(s.Url));
if (mcp != null)
{
McpName = mcp.Name;
_mcpEndpoint = mcp.Url?.TrimEnd('/') ?? "";
}
else
{
McpName = "MCP";
_mcpEndpoint = "";
}
}
private async Task CheckAllAsync()
{
var ollamaTask = PingAsync(_ollamaEndpoint + "/api/version");
var llmTask = string.IsNullOrEmpty(_llmEndpoint) || _llmEndpoint == _ollamaEndpoint
? ollamaTask
: PingAsync(_llmEndpoint);
var mcpTask = string.IsNullOrEmpty(_mcpEndpoint)
? Task.FromResult(false)
: PingAsync(_mcpEndpoint);
await Task.WhenAll(ollamaTask, llmTask, mcpTask).ConfigureAwait(false);
var changed = OllamaOnline != ollamaTask.Result ||
LlmOnline != llmTask.Result ||
McpOnline != mcpTask.Result;
OllamaOnline = ollamaTask.Result;
LlmOnline = llmTask.Result;
McpOnline = mcpTask.Result;
if (changed)
StatusChanged?.Invoke(this, EventArgs.Empty);
}
private static async Task<bool> PingAsync(string url)
{
if (string.IsNullOrWhiteSpace(url))
return false;
try
{
var resp = await Http.GetAsync(url).ConfigureAwait(false);
return resp.IsSuccessStatusCode || (int)resp.StatusCode < 500;
}
catch
{
return false;
}
}
}

View File

@@ -60,6 +60,23 @@ internal static class UsageRankingService
.Select(x => x.item);
}
/// <summary>
/// 실행 횟수 상위 항목을 반환합니다.
/// 빠른 실행 칩처럼 경로 자체 목록이 필요한 화면에서 사용합니다.
/// </summary>
public static IReadOnlyList<KeyValuePair<string, int>> GetTopItems(int maxCount)
{
EnsureLoaded();
lock (_lock)
{
return _counts
.OrderByDescending(x => x.Value)
.ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.Take(Math.Max(0, maxCount))
.ToList();
}
}
// ─── 내부 ──────────────────────────────────────────────────────────────────
private static void EnsureLoaded()

View File

@@ -0,0 +1,52 @@
using System.Net.Http;
namespace AxCopilot.Services;
internal static class WeatherWidgetService
{
private static string? _cached;
private static DateTime _cacheTime = DateTime.MinValue;
private static bool _fetching;
private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(30);
public static string CachedText => _cached ?? "--";
public static async Task RefreshAsync(bool internalMode)
{
if (internalMode)
{
_cached = "--";
return;
}
if (_cached != null && DateTime.Now - _cacheTime < Ttl)
return;
if (_fetching)
return;
_fetching = true;
try
{
using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(6);
var raw = await client.GetStringAsync("https://wttr.in/?format=%c+%t");
_cached = raw.Trim().Replace("+", " ");
_cacheTime = DateTime.Now;
}
catch (Exception ex)
{
LogService.Warn($"날씨 위젯 갱신 실패: {ex.Message}");
_cached ??= "--";
}
finally
{
_fetching = false;
}
}
public static void Invalidate()
{
_cached = null;
_cacheTime = DateTime.MinValue;
}
}

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

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

View File

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

View File

@@ -0,0 +1,67 @@
using System.Windows;
using System.Windows.Input;
using AxCopilot.Models;
namespace AxCopilot.Views;
public partial class LauncherWindow
{
private QuickLookWindow? _quickLookWindow;
private async void QuickActionChip_Click(object sender, MouseButtonEventArgs e)
{
if (((FrameworkElement)sender).DataContext is not QuickActionChip chip)
return;
var expanded = Environment.ExpandEnvironmentVariables(chip.Path);
Hide();
try
{
await Task.Run(() =>
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(expanded)
{
UseShellExecute = true
}));
_ = Task.Run(() => Services.UsageRankingService.RecordExecution(chip.Path));
}
catch (Exception ex)
{
Services.LogService.Error($"빠른 실행 칩 열기 실패: {expanded} - {ex.Message}");
}
}
internal void ToggleQuickLook()
{
if (_quickLookWindow != null)
{
_quickLookWindow.Close();
_quickLookWindow = null;
return;
}
if (_vm.SelectedItem?.Data is not Services.IndexEntry indexEntry)
return;
var path = Environment.ExpandEnvironmentVariables(indexEntry.Path);
if (!System.IO.File.Exists(path) && !System.IO.Directory.Exists(path))
return;
var qlLeft = Left + ActualWidth + 8;
var qlTop = Top;
var screen = System.Windows.Forms.Screen.FromHandle(
new System.Windows.Interop.WindowInteropHelper(this).Handle);
if (qlLeft + 400 > screen.WorkingArea.Right)
qlLeft = Left - 408;
_quickLookWindow = new QuickLookWindow(path, this)
{
Left = qlLeft,
Top = qlTop
};
_quickLookWindow.Closed += (_, _) => _quickLookWindow = null;
_quickLookWindow.Show();
}
}

View File

@@ -0,0 +1,209 @@
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
using AxCopilot.Services;
namespace AxCopilot.Views;
public partial class LauncherWindow
{
private DispatcherTimer? _widgetTimer;
private static readonly SolidColorBrush DotOnline = new(Color.FromRgb(0x10, 0xB9, 0x81));
private static readonly SolidColorBrush DotOffline = new(Color.FromRgb(0x9E, 0x9E, 0x9E));
private int _widgetBatteryTick;
private int _widgetWeatherTick;
internal void StartWidgetUpdates()
{
var settings = CurrentApp?.SettingsService?.Settings;
PerformanceMonitorService.Instance.StartPolling();
ServerStatusService.Instance.Start(settings);
PomodoroService.Instance.StateChanged -= OnPomoStateChanged;
PomodoroService.Instance.StateChanged += OnPomoStateChanged;
ServerStatusService.Instance.StatusChanged -= OnServerStatusChanged;
ServerStatusService.Instance.StatusChanged += OnServerStatusChanged;
_vm.UpdateWidgets();
UpdateServerDots();
UpdateBatteryWidget();
_ = RefreshWeatherAsync();
if (_widgetTimer == null)
{
_widgetTimer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromSeconds(1)
};
_widgetTimer.Tick += (_, _) =>
{
_vm.UpdateWidgets();
UpdateServerDots();
if (_vm.Widget_PerfText.Length > 0 && _widgetBatteryTick++ % 30 == 0)
UpdateBatteryWidget();
if (_widgetWeatherTick++ % 120 == 0)
_ = RefreshWeatherAsync();
};
}
_widgetTimer.Start();
UpdatePomoWidgetStyle();
}
internal void StopWidgetUpdates()
{
_widgetTimer?.Stop();
PerformanceMonitorService.Instance.StopPolling();
PomodoroService.Instance.StateChanged -= OnPomoStateChanged;
ServerStatusService.Instance.StatusChanged -= OnServerStatusChanged;
}
private void OnPomoStateChanged(object? sender, EventArgs e)
{
Dispatcher.InvokeAsync(() =>
{
_vm.UpdateWidgets();
UpdatePomoWidgetStyle();
});
}
private void OnServerStatusChanged(object? sender, EventArgs e)
=> Dispatcher.InvokeAsync(UpdateServerDots);
private void UpdateServerDots()
{
var server = ServerStatusService.Instance;
if (OllamaStatusDot != null)
OllamaStatusDot.Fill = server.OllamaOnline ? DotOnline : DotOffline;
if (LlmStatusDot != null)
LlmStatusDot.Fill = server.LlmOnline ? DotOnline : DotOffline;
if (McpStatusDot != null)
McpStatusDot.Fill = server.McpOnline ? DotOnline : DotOffline;
}
private void UpdatePomoWidgetStyle()
{
if (WgtPomo == null)
return;
var running = PomodoroService.Instance.IsRunning;
WgtPomo.Background = running
? new SolidColorBrush(Color.FromArgb(0x1E, 0xF5, 0x9E, 0x0B))
: new SolidColorBrush(Color.FromArgb(0x0D, 0xF5, 0x9E, 0x0B));
}
private void WgtPerf_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
_vm.InputText = "info ";
InputBox?.Focus();
}
private void WgtPomo_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
_vm.InputText = "pomo ";
InputBox?.Focus();
}
private void WgtNote_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
_vm.InputText = "note ";
InputBox?.Focus();
}
private void WgtServer_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
var settings = CurrentApp?.SettingsService?.Settings;
ServerStatusService.Instance.Refresh(settings);
_vm.InputText = "port";
InputBox?.Focus();
}
private void WgtWeather_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
var internalMode = CurrentApp?.SettingsService?.Settings.InternalModeEnabled ?? true;
if (!internalMode)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("https://wttr.in") { UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"날씨 페이지 열기 실패: {ex.Message}");
}
}
else
{
WeatherWidgetService.Invalidate();
_ = RefreshWeatherAsync();
}
}
private void WgtCal_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("ms-clock:") { UseShellExecute = true });
}
catch
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("outlookcal:") { UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"달력 열기 실패: {ex.Message}");
}
}
}
private void WgtBattery_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("ms-settings:powersleep") { UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"전원 설정 열기 실패: {ex.Message}");
}
}
private void UpdateBatteryWidget()
{
try
{
var power = System.Windows.Forms.SystemInformation.PowerStatus;
var pct = power.BatteryLifePercent;
if (pct > 1.0f || pct < 0f)
{
_vm.Widget_BatteryVisible = false;
return;
}
_vm.Widget_BatteryVisible = true;
var pctInt = (int)(pct * 100);
var charging = power.PowerLineStatus == System.Windows.Forms.PowerLineStatus.Online;
_vm.Widget_BatteryText = charging ? $"{pctInt}% 충전" : $"{pctInt}%";
_vm.Widget_BatteryIcon = charging ? "\uE83E"
: pctInt >= 85 ? "\uEBA7"
: pctInt >= 70 ? "\uEBA5"
: pctInt >= 50 ? "\uEBA3"
: pctInt >= 25 ? "\uEBA1"
: "\uEBA0";
}
catch (Exception ex)
{
LogService.Warn($"배터리 위젯 갱신 실패: {ex.Message}");
_vm.Widget_BatteryVisible = false;
}
}
private async Task RefreshWeatherAsync()
{
var internalMode = CurrentApp?.SettingsService?.Settings.InternalModeEnabled ?? true;
await WeatherWidgetService.RefreshAsync(internalMode);
await Dispatcher.InvokeAsync(() => { _vm.Widget_WeatherText = WeatherWidgetService.CachedText; });
}
}

View File

@@ -18,6 +18,8 @@
WindowStartupLocation="Manual"
Loaded="Window_Loaded"
Deactivated="Window_Deactivated"
LocationChanged="Window_LocationChanged"
IsVisibleChanged="Window_IsVisibleChanged"
PreviewKeyDown="Window_PreviewKeyDown"
KeyDown="Window_KeyDown">
@@ -201,10 +203,16 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ─── 입력 영역 ─── -->
<Grid Grid.Row="0" Margin="20,16,20,16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
@@ -413,8 +421,57 @@
Foreground="{DynamicResource HintText}"
Margin="3,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<ItemsControl Grid.Row="1"
Grid.ColumnSpan="3"
ItemsSource="{Binding QuickActionItems}"
Margin="0,10,0,0"
Visibility="{Binding ShowQuickActions, Converter={StaticResource BoolToVisibilityConverter}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,0,6,6"
Padding="9,5"
CornerRadius="10"
Cursor="Hand"
Background="{Binding Background}"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
MouseLeftButtonUp="QuickActionChip_Click">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="BorderBrush" Value="{DynamicResource AccentColor}"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Symbol}"
FontFamily="Segoe MDL2 Assets"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"
Margin="0,0,5,0"/>
<TextBlock Text="{Binding Title}"
FontFamily="Segoe UI, Malgun Gothic"
FontSize="11"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"
MaxWidth="100"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
<!-- ─── 파일 액션 모드 breadcrumb 바 ─── -->
@@ -691,9 +748,31 @@
</ListView.ItemTemplate>
</ListView>
<Border Grid.Row="5"
x:Name="PreviewPanel"
Visibility="{Binding HasPreview, Converter={StaticResource BoolToVisibilityConverter}}"
Background="{DynamicResource ItemBackground}"
CornerRadius="8"
Margin="10,0,10,8"
Padding="12,8"
MaxHeight="100">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ScrollViewer.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBar}"/>
</ScrollViewer.Resources>
<TextBlock Text="{Binding PreviewText}"
FontFamily="Segoe UI Mono, Consolas, Malgun Gothic"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
TextWrapping="Wrap"
LineHeight="16"/>
</ScrollViewer>
</Border>
<!-- ─── 인덱싱 상태 바 ─── -->
<TextBlock x:Name="IndexStatusText"
Grid.Row="5"
Grid.Row="6"
Visibility="Collapsed"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
@@ -701,9 +780,179 @@
Margin="0,0,0,8"
Opacity="0.7"/>
<Border x:Name="WidgetBar"
Grid.Row="7"
BorderBrush="{DynamicResource SeparatorColor}"
BorderThickness="0,1,0,0"
Padding="10,7,10,9">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border x:Name="WgtPerf" Grid.Column="0"
CornerRadius="5" Padding="8,5"
Background="#0D60A5FA"
Cursor="Hand"
MouseLeftButtonUp="WgtPerf_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE950;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#60A5FA"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_PerfText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="WgtPomo" Grid.Column="2"
CornerRadius="5" Padding="8,5"
Background="#0DF59E0B"
Cursor="Hand"
MouseLeftButtonUp="WgtPomo_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE916;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#F59E0B"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock x:Name="WgtPomoText"
Text="{Binding Widget_PomoText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="WgtNote" Grid.Column="4"
CornerRadius="5" Padding="8,5"
Background="#0D8B5CF6"
Cursor="Hand"
MouseLeftButtonUp="WgtNote_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE70B;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#8B5CF6"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_NoteText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="WgtServer" Grid.Column="6"
CornerRadius="5" Padding="8,5"
Background="#0D10B981"
Cursor="Hand"
MouseLeftButtonUp="WgtServer_Click">
<StackPanel Orientation="Horizontal" x:Name="WgtServerContent">
<Ellipse x:Name="OllamaStatusDot"
Width="6" Height="6"
Fill="#9E9E9E"
VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock Text="Ollama"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<Ellipse x:Name="LlmStatusDot"
Width="6" Height="6"
Fill="#9E9E9E"
VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock Text="API"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<Ellipse x:Name="McpStatusDot"
Width="6" Height="6"
Fill="#9E9E9E"
VerticalAlignment="Center" Margin="0,0,3,0"/>
<TextBlock x:Name="McpNameText"
Text="{Binding Widget_McpName}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
<Grid Margin="0,4,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border x:Name="WgtWeather" Grid.Column="0"
CornerRadius="5" Padding="8,5"
Background="#0D3B82F6"
Cursor="Hand"
MouseLeftButtonUp="WgtWeather_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE708;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#60A5FA"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_WeatherText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="100"/>
</StackPanel>
</Border>
<Border x:Name="WgtCal" Grid.Column="2"
CornerRadius="5" Padding="8,5"
Background="#0DEC4899"
Cursor="Hand"
MouseLeftButtonUp="WgtCal_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE8BF;"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#EC4899"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_CalText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="WgtBattery" Grid.Column="4"
CornerRadius="5" Padding="8,5"
Background="#0D10B981"
Cursor="Hand"
MouseLeftButtonUp="WgtBattery_Click"
Visibility="{Binding Widget_BatteryVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Widget_BatteryIcon}"
FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#10B981"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="{Binding Widget_BatteryText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</StackPanel>
</Border>
<!-- ─── 토스트 오버레이 ─── -->
<Border x:Name="ToastOverlay"
Grid.Row="4" Grid.RowSpan="2"
Grid.Row="4" Grid.RowSpan="4"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,0,0,12"

View File

@@ -14,6 +14,8 @@ namespace AxCopilot.Views;
public partial class LauncherWindow : Window
{
private static App? CurrentApp => System.Windows.Application.Current as App;
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
@@ -34,6 +36,7 @@ public partial class LauncherWindow : Window
private readonly LauncherViewModel _vm;
private System.Windows.Threading.DispatcherTimer? _indexStatusTimer;
private System.Windows.Threading.DispatcherTimer? _toastTimer;
/// <summary>Ctrl+, 단축키로 설정 창을 여는 콜백 (App.xaml.cs에서 주입)</summary>
public Action? OpenSettingsAction { get; set; }
@@ -71,16 +74,9 @@ public partial class LauncherWindow : Window
Dispatcher.BeginInvoke(() =>
{
var svc = app.IndexService;
IndexStatusText.Text = $"✓ {svc.LastIndexCount:N0}개 항목 색인됨 ({svc.LastIndexDuration.TotalSeconds:F1}초)";
IndexStatusText.Visibility = Visibility.Visible;
// 기존 타이머 정리 후 5초 후 자동 숨기기
_indexStatusTimer?.Stop();
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromSeconds(5)
};
_indexStatusTimer.Tick += (_, _) => { IndexStatusText.Visibility = Visibility.Collapsed; _indexStatusTimer.Stop(); };
_indexStatusTimer.Start();
ShowIndexStatus(
$"✓ {svc.LastIndexCount:N0}개 항목 색인됨 ({svc.LastIndexDuration.TotalSeconds:F1}초)",
TimeSpan.FromSeconds(5));
});
};
}
@@ -88,7 +84,7 @@ public partial class LauncherWindow : Window
private void Window_Loaded(object sender, RoutedEventArgs e)
{
CenterOnScreen();
ApplyInitialPlacement();
ApplyTheme();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input, () =>
{
@@ -151,7 +147,7 @@ public partial class LauncherWindow : Window
_vm.OnShown();
_vm.InputText = "";
base.Show();
CenterOnScreen();
ApplyInitialPlacement();
AnimateIn();
// 포그라운드 강제 + 포커스를 3단계로 보장
@@ -695,6 +691,64 @@ public partial class LauncherWindow : Window
};
}
private void ApplyInitialPlacement()
{
if (!TryRestoreRememberedPosition())
CenterOnScreen();
UpdateRememberedPositionCache();
}
private bool TryRestoreRememberedPosition()
{
var launcher = CurrentApp?.SettingsService?.Settings?.Launcher;
if (launcher == null || !launcher.RememberPosition) return false;
if (launcher.LastLeft < 0 || launcher.LastTop < 0) return false;
var rememberPoint = new Point(launcher.LastLeft, launcher.LastTop);
if (!IsVisibleOnAnyScreen(rememberPoint)) return false;
Left = launcher.LastLeft;
Top = launcher.LastTop;
return true;
}
private static bool IsVisibleOnAnyScreen(Point point)
{
foreach (var screen in FormsScreen.AllScreens)
{
var bounds = screen.WorkingArea;
if (point.X >= bounds.Left && point.X <= bounds.Right - 40 &&
point.Y >= bounds.Top && point.Y <= bounds.Bottom - 40)
{
return true;
}
}
return false;
}
private void UpdateRememberedPositionCache()
{
var launcher = CurrentApp?.SettingsService?.Settings?.Launcher;
if (launcher == null || !launcher.RememberPosition || !IsLoaded) return;
launcher.LastLeft = Left;
launcher.LastTop = Top;
}
private void SaveRememberedPosition()
{
var app = CurrentApp;
var settingsService = app?.SettingsService;
if (settingsService == null) return;
var launcher = settingsService.Settings.Launcher;
if (launcher == null || !launcher.RememberPosition || !IsLoaded) return;
UpdateRememberedPositionCache();
settingsService.Save();
}
// 지원 테마 이름 목록
private static readonly HashSet<string> KnownThemes =
new(StringComparer.OrdinalIgnoreCase)
@@ -1031,8 +1085,7 @@ public partial class LauncherWindow : Window
{
var app = (App)System.Windows.Application.Current;
_ = app.IndexService?.BuildAsync(CancellationToken.None);
IndexStatusText.Text = "⟳ 인덱스 재구축 중…";
IndexStatusText.Visibility = Visibility.Visible;
ShowIndexStatus("⟳ 인덱스 재구축 중…", TimeSpan.FromSeconds(8));
e.Handled = true;
return;
}
@@ -1256,6 +1309,36 @@ public partial class LauncherWindow : Window
return;
}
// ─── F3 → 파일 빠른 미리보기 (QuickLook 토글) ───────────────────────
if (e.Key == Key.F3)
{
ToggleQuickLook();
e.Handled = true;
return;
}
// ─── F4 → 화면 영역 OCR 즉시 실행 ─────────────────────────────────
if (e.Key == Key.F4)
{
Hide();
_ = Task.Run(async () =>
{
try
{
var handler = new Handlers.OcrHandler();
var item = new SDK.LauncherItem(
"화면 영역 텍스트 추출", "", null, "__ocr_region__");
await handler.ExecuteAsync(item, CancellationToken.None);
}
catch (Exception ex)
{
Services.LogService.Error($"F4 OCR 실행 오류: {ex.Message}");
}
});
e.Handled = true;
return;
}
// ─── Ctrl+1~9 → n번째 결과 즉시 실행 ───────────────────────────────
if (mod == ModifierKeys.Control)
{
@@ -1295,6 +1378,8 @@ public partial class LauncherWindow : Window
"[ 기능 ]",
"F1 도움말",
"F2 파일 이름 바꾸기",
"F3 파일 빠른 미리보기",
"F4 화면 OCR",
"F5 인덱스 새로 고침",
"Delete 항목 제거",
"Ctrl+, 설정",
@@ -1331,14 +1416,14 @@ public partial class LauncherWindow : Window
var fadeIn = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeIn");
fadeIn.Begin(this);
_indexStatusTimer?.Stop();
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
_toastTimer?.Stop();
_toastTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromSeconds(2)
};
_indexStatusTimer.Tick += (_, _) =>
_toastTimer.Tick += (_, _) =>
{
_indexStatusTimer.Stop();
_toastTimer.Stop();
// 페이드아웃 후 Collapsed
var fadeOut = (System.Windows.Media.Animation.Storyboard)FindResource("ToastFadeOut");
EventHandler? onCompleted = null;
@@ -1350,6 +1435,24 @@ public partial class LauncherWindow : Window
fadeOut.Completed += onCompleted;
fadeOut.Begin(this);
};
_toastTimer.Start();
}
private void ShowIndexStatus(string message, TimeSpan duration)
{
IndexStatusText.Text = message;
IndexStatusText.Visibility = Visibility.Visible;
_indexStatusTimer?.Stop();
_indexStatusTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = duration
};
_indexStatusTimer.Tick += (_, _) =>
{
_indexStatusTimer.Stop();
IndexStatusText.Visibility = Visibility.Collapsed;
};
_indexStatusTimer.Start();
}
@@ -1560,6 +1663,28 @@ public partial class LauncherWindow : Window
if (_vm.CloseOnFocusLost) Hide();
}
private void Window_LocationChanged(object sender, EventArgs e)
{
UpdateRememberedPositionCache();
}
private void Window_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.NewValue is not bool isVisible)
return;
if (isVisible)
{
StartWidgetUpdates();
return;
}
_quickLookWindow?.Close();
_quickLookWindow = null;
StopWidgetUpdates();
SaveRememberedPosition();
}
private void ScrollToSelected()
{
if (_vm.SelectedItem != null)

View File

@@ -0,0 +1,161 @@
<Window x:Class="AxCopilot.Views.QuickLookWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Commander 빠른 미리보기"
Width="400"
Height="500"
MinWidth="260"
MinHeight="200"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="Manual"
ResizeMode="CanResizeWithGrip"
Topmost="True">
<Border Background="{DynamicResource LauncherBackground}"
CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
Margin="6">
<Border.Effect>
<DropShadowEffect BlurRadius="22" ShadowDepth="4" Opacity="0.32" Color="Black" Direction="270"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Grid.Row="0"
CornerRadius="12,12,0,0"
Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseDown">
<Grid Margin="14,0,8,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock x:Name="FileTypeIcon"
Text="&#xE7C3;"
FontFamily="Segoe MDL2 Assets"
FontSize="15"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center"
Margin="0,1,10,0"/>
<TextBlock x:Name="FileNameText"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="270"/>
</StackPanel>
<Border HorizontalAlignment="Right"
VerticalAlignment="Center"
CornerRadius="4"
Padding="8,4"
Cursor="Hand"
MouseLeftButtonUp="BtnClose_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#40C05050"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE711;"
FontFamily="Segoe MDL2 Assets"
FontSize="13"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
</Grid>
</Border>
<Grid Grid.Row="1">
<ScrollViewer x:Name="ImageScrollViewer"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed"
Background="{DynamicResource LauncherBackground}">
<Image x:Name="PreviewImage"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="8"/>
</ScrollViewer>
<ScrollViewer x:Name="TextScrollViewer"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed">
<TextBlock x:Name="PreviewText"
FontFamily="Cascadia Code, Consolas, Courier New"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
TextWrapping="Wrap"
Margin="14,12"/>
</ScrollViewer>
<ScrollViewer x:Name="PdfScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
Visibility="Collapsed">
<TextBlock x:Name="PdfPreviewText"
FontFamily="Segoe UI, Malgun Gothic"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
TextWrapping="Wrap"
Margin="14,12"/>
</ScrollViewer>
<StackPanel x:Name="InfoPanel"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Visibility="Collapsed">
<TextBlock x:Name="InfoTypeIcon"
Text="&#xE7C3;"
FontFamily="Segoe MDL2 Assets"
FontSize="52"
Foreground="{DynamicResource AccentColor}"
HorizontalAlignment="Center"/>
<TextBlock x:Name="InfoTypeName"
FontSize="14"
FontWeight="Medium"
Foreground="{DynamicResource PrimaryText}"
HorizontalAlignment="Center"
Margin="0,10,0,0"
TextWrapping="Wrap"
TextAlignment="Center"
MaxWidth="320"/>
<TextBlock x:Name="InfoSubText"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center"
Margin="0,4,0,0"/>
</StackPanel>
</Grid>
<Border Grid.Row="2"
CornerRadius="0,0,12,12"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="0,1,0,0"
Padding="14,7">
<Grid>
<TextBlock x:Name="FooterPath"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
TextTrimming="CharacterEllipsis"
HorizontalAlignment="Left"
VerticalAlignment="Center"/>
<TextBlock x:Name="FooterMeta"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Right"
VerticalAlignment="Center"/>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,143 @@
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using UglyToad.PdfPig;
namespace AxCopilot.Views;
public partial class QuickLookWindow : Window
{
private static readonly HashSet<string> ImageExts = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"
};
private static readonly HashSet<string> TextExts = new(StringComparer.OrdinalIgnoreCase)
{
".txt", ".md", ".cs", ".vb", ".fs", ".py", ".js", ".ts", ".jsx", ".tsx",
".json", ".xml", ".xaml", ".yaml", ".yml", ".toml", ".ini", ".conf",
".log", ".csv", ".html", ".htm", ".css", ".scss", ".less",
".sql", ".sh", ".bash", ".bat", ".cmd", ".ps1",
".config", ".env", ".gitignore", ".editorconfig",
".java", ".cpp", ".c", ".h", ".hpp", ".rs", ".go", ".rb", ".php", ".swift",
".vue", ".svelte", ".dockerfile"
};
public QuickLookWindow(string path, Window owner)
{
InitializeComponent();
Owner = owner;
KeyDown += OnKeyDown;
Loaded += (_, _) => LoadPreview(path);
}
private void OnKeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.Escape or Key.F3)
{
Close();
e.Handled = true;
}
}
private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed)
DragMove();
}
private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close();
private void LoadPreview(string path)
{
try
{
FileNameText.Text = Path.GetFileName(path);
if (Directory.Exists(path))
{
ShowInfo("\uE838", "폴더", path);
return;
}
if (!File.Exists(path))
{
ShowInfo("\uE783", "파일을 찾을 수 없습니다.", path);
return;
}
var info = new FileInfo(path);
var ext = Path.GetExtension(path);
FooterPath.Text = path;
FooterMeta.Text = $"{FormatSize(info.Length)} · {info.LastWriteTime:yyyy-MM-dd HH:mm}";
if (ImageExts.Contains(ext))
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var image = new BitmapImage();
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.StreamSource = fs;
image.EndInit();
image.Freeze();
PreviewImage.Source = image;
ImageScrollViewer.Visibility = Visibility.Visible;
FileTypeIcon.Text = "\uE91B";
return;
}
if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase))
{
using var doc = PdfDocument.Open(path);
var page = doc.NumberOfPages > 0 ? doc.GetPage(1).Text : "";
PdfPreviewText.Text = page.Length > 1200 ? page[..1200] + "…" : page;
PdfScrollViewer.Visibility = Visibility.Visible;
FileTypeIcon.Text = "\uEA90";
return;
}
if (TextExts.Contains(ext))
{
string text;
try
{
text = File.ReadAllText(path, Encoding.UTF8);
}
catch
{
text = File.ReadAllText(path);
}
PreviewText.Text = text.Length > 4000 ? text[..4000] + "\n…" : text;
TextScrollViewer.Visibility = Visibility.Visible;
FileTypeIcon.Text = "\uE8A5";
return;
}
ShowInfo("\uE7C3", $"파일 · {ext.TrimStart('.').ToUpperInvariant()}", path);
}
catch (Exception ex)
{
ShowInfo("\uEA39", "미리보기를 열지 못했습니다.", ex.Message);
}
}
private void ShowInfo(string icon, string title, string detail)
{
InfoTypeIcon.Text = icon;
InfoTypeName.Text = title;
InfoSubText.Text = detail;
InfoPanel.Visibility = Visibility.Visible;
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1048576 => $"{bytes / 1024.0:F1} KB",
< 1073741824 => $"{bytes / 1048576.0:F1} MB",
_ => $"{bytes / 1073741824.0:F2} GB"
};
}