런처 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:
14
src/AxCopilot/Models/QuickActionChip.cs
Normal file
14
src/AxCopilot/Models/QuickActionChip.cs
Normal 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
|
||||
);
|
||||
133
src/AxCopilot/Services/PerformanceMonitorService.cs
Normal file
133
src/AxCopilot/Services/PerformanceMonitorService.cs
Normal 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;
|
||||
}
|
||||
108
src/AxCopilot/Services/SearchHistoryService.cs
Normal file
108
src/AxCopilot/Services/SearchHistoryService.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/AxCopilot/Services/ServerStatusService.cs
Normal file
115
src/AxCopilot/Services/ServerStatusService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
52
src/AxCopilot/Services/WeatherWidgetService.cs
Normal file
52
src/AxCopilot/Services/WeatherWidgetService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
|
||||
67
src/AxCopilot/Views/LauncherWindow.Shell.cs
Normal file
67
src/AxCopilot/Views/LauncherWindow.Shell.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
209
src/AxCopilot/Views/LauncherWindow.Widgets.cs
Normal file
209
src/AxCopilot/Views/LauncherWindow.Widgets.cs
Normal 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; });
|
||||
}
|
||||
}
|
||||
@@ -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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
161
src/AxCopilot/Views/QuickLookWindow.xaml
Normal file
161
src/AxCopilot/Views/QuickLookWindow.xaml
Normal 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=""
|
||||
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=""
|
||||
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=""
|
||||
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>
|
||||
143
src/AxCopilot/Views/QuickLookWindow.xaml.cs
Normal file
143
src/AxCopilot/Views/QuickLookWindow.xaml.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user