[Phase UX] 창 위치 기억·파일 아이콘·퍼지 랭킹·미리보기 패널 4종 UX 개선
창 위치 기억 (Feature 1):
- AppSettings.cs: LauncherSettings에 RememberPosition, LastLeft, LastTop 프로퍼티 추가
- SettingsViewModel.cs/.Properties.cs/.Methods.cs: RememberPosition 바인딩 프로퍼티 연동
- LauncherWindow.Animations.cs: CenterOnScreen() — RememberPosition ON 시 저장 좌표 복원
- LauncherWindow.Shell.cs: Window_Deactivated — 비활성화 시 현재 위치 비동기 저장
- SettingsWindow.xaml: 런처 탭 › "마지막 위치 기억" 토글 추가
파일 아이콘 표시 (Feature 2):
- Services/IconCacheService.cs (신규, 192줄): Shell32 SHGetFileInfo로 아이콘 추출,
%LOCALAPPDATA%\AxCopilot\IconCache\에 PNG 캐시, WarmUp()으로 앱 시작 시 미리 준비
- Core/CommandResolver.cs: 퍼지 검색 결과에 IconCacheService.GetIconPath() 연결
- Handlers/FileBrowserHandler.cs: 상위폴더·폴더·파일 항목에 IconCacheService 연결
- App.xaml.cs: SystemIdle 시점에 IconCacheService.WarmUp() 호출
퍼지 검색 랭킹 개선 (Feature 3):
- Services/UsageRankingService.cs 전면 개선: 기존 int 횟수 → UsageRecord{Count, LastUsedMs}
- GetScore() 반환형 int → double, 30일 반감기 지수 감쇠(decay=exp(-days/43.3)) 적용
- 구형 usage.json 자동 마이그레이션 (count만 있는 형식 → 신규 형식)
- GetTopItems() / SortByUsage() 점수 기준 정렬로 업데이트
미리보기 패널 (Feature 4):
- ViewModels/LauncherViewModel.cs: PreviewText, HasPreview 프로퍼티 + UpdatePreviewAsync()
클립보드 텍스트(최대 400자) 및 텍스트 파일(최초 6줄) 미리보기, 80ms 디바운스
- Views/LauncherWindow.xaml: RowDefinitions 7→8개, Row5에 PreviewPanel Border 삽입,
IndexStatusText Row5→6, WidgetBar Row6→7, ToastOverlay RowSpan 3→4
빌드: 경고 0, 오류 0
This commit is contained in:
@@ -367,6 +367,11 @@ public partial class App : System.Windows.Application
|
|||||||
() => _clipboardHistory?.Initialize(),
|
() => _clipboardHistory?.Initialize(),
|
||||||
System.Windows.Threading.DispatcherPriority.ApplicationIdle);
|
System.Windows.Threading.DispatcherPriority.ApplicationIdle);
|
||||||
|
|
||||||
|
// ─── 파일 아이콘 캐시 워밍업 (앱 유휴 시점에 자주 쓰는 확장자 미리 추출) ──
|
||||||
|
Dispatcher.BeginInvoke(
|
||||||
|
() => AxCopilot.Services.IconCacheService.WarmUp(),
|
||||||
|
System.Windows.Threading.DispatcherPriority.SystemIdle);
|
||||||
|
|
||||||
// ─── ChatWindow 미리 생성 (앱 유휴 시점에 숨겨진 채로 초기화) ──────────
|
// ─── ChatWindow 미리 생성 (앱 유휴 시점에 숨겨진 채로 초기화) ──────────
|
||||||
// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 열림
|
// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 열림
|
||||||
Dispatcher.BeginInvoke(
|
Dispatcher.BeginInvoke(
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ public class CommandResolver
|
|||||||
"batch" => "명령 단축키",
|
"batch" => "명령 단축키",
|
||||||
_ => r.Entry.Path
|
_ => r.Entry.Path
|
||||||
} : r.Entry.Path + " ⇧ Shift+Enter: 폴더 열기",
|
} : r.Entry.Path + " ⇧ Shift+Enter: 폴더 열기",
|
||||||
null,
|
IconCacheService.GetIconPath(r.Entry.Path, r.Entry.Type == IndexEntryType.Folder),
|
||||||
r.Entry,
|
r.Entry,
|
||||||
Symbol: r.Entry.Type switch
|
Symbol: r.Entry.Type switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using AxCopilot.SDK;
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
using AxCopilot.Themes;
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
namespace AxCopilot.Handlers;
|
namespace AxCopilot.Handlers;
|
||||||
@@ -87,7 +88,7 @@ public class FileBrowserHandler : IActionHandler
|
|||||||
items.Add(new LauncherItem(
|
items.Add(new LauncherItem(
|
||||||
".. (상위 폴더)",
|
".. (상위 폴더)",
|
||||||
parent,
|
parent,
|
||||||
null,
|
IconCacheService.GetIconPath(parent, true),
|
||||||
new FileBrowserEntry(parent, true),
|
new FileBrowserEntry(parent, true),
|
||||||
Symbol: "\uE74A")); // Back 아이콘
|
Symbol: "\uE74A")); // Back 아이콘
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,7 @@ public class FileBrowserHandler : IActionHandler
|
|||||||
items.Add(new LauncherItem(
|
items.Add(new LauncherItem(
|
||||||
name,
|
name,
|
||||||
d,
|
d,
|
||||||
null,
|
IconCacheService.GetIconPath(d, true),
|
||||||
new FileBrowserEntry(d, true),
|
new FileBrowserEntry(d, true),
|
||||||
Symbol: Symbols.Folder));
|
Symbol: Symbols.Folder));
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ public class FileBrowserHandler : IActionHandler
|
|||||||
items.Add(new LauncherItem(
|
items.Add(new LauncherItem(
|
||||||
name,
|
name,
|
||||||
$"{size} · {ext.TrimStart('.')} 파일",
|
$"{size} · {ext.TrimStart('.')} 파일",
|
||||||
null,
|
IconCacheService.GetIconPath(f),
|
||||||
new FileBrowserEntry(f, false),
|
new FileBrowserEntry(f, false),
|
||||||
Symbol: ExtToSymbol(ext)));
|
Symbol: ExtToSymbol(ext)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,6 +283,18 @@ public class LauncherSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("monitorDockPositions")]
|
[JsonPropertyName("monitorDockPositions")]
|
||||||
public Dictionary<string, double[]> MonitorDockPositions { get; set; } = new();
|
public Dictionary<string, double[]> MonitorDockPositions { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>AX Commander 마지막 위치 기억 여부. true이면 닫은 위치에서 다시 열립니다. 기본 false.</summary>
|
||||||
|
[JsonPropertyName("rememberPosition")]
|
||||||
|
public bool RememberPosition { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>AX Commander 마지막 Left 좌표 (-1이면 기본 위치).</summary>
|
||||||
|
[JsonPropertyName("lastLeft")]
|
||||||
|
public double LastLeft { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>AX Commander 마지막 Top 좌표 (-1이면 기본 위치).</summary>
|
||||||
|
[JsonPropertyName("lastTop")]
|
||||||
|
public double LastTop { get; set; } = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
192
src/AxCopilot/Services/IconCacheService.cs
Normal file
192
src/AxCopilot/Services/IconCacheService.cs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
|
||||||
|
namespace AxCopilot.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shell32 SHGetFileInfo를 사용하여 파일/폴더의 Windows 아이콘을 추출·캐시합니다.
|
||||||
|
/// 캐시 위치: %LOCALAPPDATA%\AxCopilot\IconCache\{확장자}.png
|
||||||
|
/// GetIconPath()는 캐시 미스 시 null을 반환하고 백그라운드에서 추출을 시작합니다.
|
||||||
|
/// WarmUp()을 앱 시작 시 호출하면 자주 쓰는 확장자를 미리 준비합니다.
|
||||||
|
/// </summary>
|
||||||
|
internal static class IconCacheService
|
||||||
|
{
|
||||||
|
private static readonly string _cacheDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"AxCopilot", "IconCache");
|
||||||
|
|
||||||
|
/// <summary>key(확장자 또는 "folder") → PNG 캐시 파일 경로 (null = 추출 실패)</summary>
|
||||||
|
private static readonly ConcurrentDictionary<string, string?> _cache =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static volatile bool _warmupDone;
|
||||||
|
|
||||||
|
// ─── Win32 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
|
||||||
|
private struct SHFILEINFO
|
||||||
|
{
|
||||||
|
public IntPtr hIcon;
|
||||||
|
public int iIcon;
|
||||||
|
public uint dwAttributes;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Auto)]
|
||||||
|
private static extern IntPtr SHGetFileInfo(
|
||||||
|
string pszPath, uint dwFileAttributes,
|
||||||
|
ref SHFILEINFO psfi, uint cbFileInfo, uint uFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern bool DestroyIcon(IntPtr hIcon);
|
||||||
|
|
||||||
|
private const uint SHGFI_ICON = 0x000000100;
|
||||||
|
private const uint SHGFI_LARGEICON = 0x000000000;
|
||||||
|
private const uint SHGFI_USEFILEATTRIBUTES = 0x000000010;
|
||||||
|
private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080;
|
||||||
|
private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
|
||||||
|
|
||||||
|
// ─── 공개 API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 파일 확장자 기반 아이콘 PNG 경로를 반환합니다.
|
||||||
|
/// 캐시가 없으면 백그라운드에서 추출을 시작하고 null을 반환합니다.
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetIconPath(string filePath, bool isDirectory = false)
|
||||||
|
{
|
||||||
|
var key = isDirectory ? "folder" : GetExtKey(filePath);
|
||||||
|
if (_cache.TryGetValue(key, out var cached)) return cached;
|
||||||
|
|
||||||
|
_ = ExtractAsync(filePath, key, isDirectory);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 앱 시작 시 자주 쓰는 파일 형식의 아이콘을 미리 추출합니다.
|
||||||
|
/// 중복 실행은 자동으로 건너뜁니다.
|
||||||
|
/// </summary>
|
||||||
|
public static void WarmUp()
|
||||||
|
{
|
||||||
|
if (_warmupDone) return;
|
||||||
|
_warmupDone = true;
|
||||||
|
|
||||||
|
// 캐시 디렉터리에 이미 저장된 PNG 로드 (재시작 시 빠른 복원)
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_cacheDir);
|
||||||
|
foreach (var file in Directory.GetFiles(_cacheDir, "*.png"))
|
||||||
|
{
|
||||||
|
var key = Path.GetFileNameWithoutExtension(file);
|
||||||
|
_cache.TryAdd(key, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자주 쓰는 확장자 사전 추출 (UI 스레드 부하를 분산하기 위해 순차 실행)
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(2000); // 앱 초기화 완료 후 추출 시작
|
||||||
|
var common = new (string Path, string Key, bool IsDir)[]
|
||||||
|
{
|
||||||
|
("dummy.exe", "exe", false),
|
||||||
|
("dummy.lnk", "lnk", false),
|
||||||
|
("dummy.pdf", "pdf", false),
|
||||||
|
("dummy.docx", "docx", false),
|
||||||
|
("dummy.xlsx", "xlsx", false),
|
||||||
|
("dummy.pptx", "pptx", false),
|
||||||
|
("dummy.txt", "txt", false),
|
||||||
|
("dummy.png", "png", false),
|
||||||
|
("dummy.zip", "zip", false),
|
||||||
|
("dummy.mp4", "mp4", false),
|
||||||
|
("C:\\", "folder", true),
|
||||||
|
};
|
||||||
|
foreach (var (path, key, isDir) in common)
|
||||||
|
{
|
||||||
|
if (_cache.ContainsKey(key)) continue;
|
||||||
|
await ExtractAsync(path, key, isDir);
|
||||||
|
await Task.Delay(80); // UI 스레드 부하 분산
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string GetExtKey(string filePath)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
return string.IsNullOrEmpty(ext) ? "noext" : ext.TrimStart('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ExtractAsync(string filePath, string key, bool isDirectory)
|
||||||
|
{
|
||||||
|
if (_cache.ContainsKey(key)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_cacheDir);
|
||||||
|
var cachePath = Path.Combine(_cacheDir, key + ".png");
|
||||||
|
|
||||||
|
// 파일이 이미 캐시에 있으면 메모리 캐시에 등록만
|
||||||
|
if (File.Exists(cachePath))
|
||||||
|
{
|
||||||
|
_cache.TryAdd(key, cachePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHGetFileInfo + HICON → PNG 변환은 반드시 STA(Dispatcher) 스레드에서
|
||||||
|
var dispatcher = Application.Current?.Dispatcher;
|
||||||
|
if (dispatcher == null) return;
|
||||||
|
|
||||||
|
var saved = await dispatcher.InvokeAsync(() =>
|
||||||
|
TryExtractAndSave(filePath, cachePath, isDirectory));
|
||||||
|
|
||||||
|
_cache.TryAdd(key, saved ? cachePath : null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"[IconCache] '{key}' 추출 실패: {ex.Message}");
|
||||||
|
_cache.TryAdd(key, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dispatcher(STA) 스레드에서 HICON을 PNG로 변환하여 저장합니다.</summary>
|
||||||
|
private static bool TryExtractAndSave(string filePath, string cachePath, bool isDirectory)
|
||||||
|
{
|
||||||
|
var info = new SHFILEINFO();
|
||||||
|
uint flags = SHGFI_ICON | SHGFI_LARGEICON | SHGFI_USEFILEATTRIBUTES;
|
||||||
|
uint attrs = isDirectory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL;
|
||||||
|
|
||||||
|
SHGetFileInfo(filePath, attrs, ref info, (uint)Marshal.SizeOf(info), flags);
|
||||||
|
if (info.hIcon == IntPtr.Zero) return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bitmap = Imaging.CreateBitmapSourceFromHIcon(
|
||||||
|
info.hIcon, Int32Rect.Empty,
|
||||||
|
BitmapSizeOptions.FromEmptyOptions());
|
||||||
|
bitmap.Freeze();
|
||||||
|
|
||||||
|
var encoder = new PngBitmapEncoder();
|
||||||
|
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||||
|
using var stream = File.OpenWrite(cachePath);
|
||||||
|
encoder.Save(stream);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DestroyIcon(info.hIcon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace AxCopilot.Services;
|
namespace AxCopilot.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 런처 항목 실행 횟수를 추적하여 퍼지 검색 결과 정렬에 활용합니다.
|
/// 런처 항목 실행 횟수와 최신성을 추적하여 퍼지 검색 결과 정렬에 활용합니다.
|
||||||
/// 저장 위치: %APPDATA%\AxCopilot\usage.json
|
/// 저장 위치: %APPDATA%\AxCopilot\usage.json
|
||||||
/// 형식: { "키": 횟수 } — 키는 IndexEntry.Path (파일/폴더/앱 경로)
|
/// 점수 = 실행횟수 × 최신성감쇠 (30일 반감기: 30일 전 사용 ≈ 현재의 37%)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class UsageRankingService
|
internal static class UsageRankingService
|
||||||
{
|
{
|
||||||
@@ -14,57 +15,61 @@ internal static class UsageRankingService
|
|||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"AxCopilot", "usage.json");
|
"AxCopilot", "usage.json");
|
||||||
|
|
||||||
private static Dictionary<string, int> _counts = new(StringComparer.OrdinalIgnoreCase);
|
private static Dictionary<string, UsageRecord> _data = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private static bool _loaded = false;
|
private static bool _loaded = false;
|
||||||
private static readonly object _lock = new();
|
private static readonly object _lock = new();
|
||||||
|
|
||||||
/// <summary>
|
private sealed class UsageRecord
|
||||||
/// 항목 실행 시 호출하여 카운트를 증가시킵니다.
|
{
|
||||||
/// </summary>
|
[JsonPropertyName("c")] public int Count { get; set; }
|
||||||
|
[JsonPropertyName("t")] public long LastUsedMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 공개 API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>항목 실행 시 호출하여 카운트 및 최근 실행 시각을 갱신합니다.</summary>
|
||||||
public static void RecordExecution(string key)
|
public static void RecordExecution(string key)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(key)) return;
|
if (string.IsNullOrWhiteSpace(key)) return;
|
||||||
EnsureLoaded();
|
EnsureLoaded();
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_counts.TryGetValue(key, out var current);
|
if (!_data.TryGetValue(key, out var rec))
|
||||||
_counts[key] = current + 1;
|
rec = _data[key] = new UsageRecord();
|
||||||
|
rec.Count++;
|
||||||
|
rec.LastUsedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
}
|
}
|
||||||
_ = SaveAsync();
|
_ = SaveAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>실행 횟수 × 최신성 감쇠 합산 점수를 반환합니다. 없으면 0.</summary>
|
||||||
/// 주어진 키의 실행 횟수를 반환합니다. 없으면 0.
|
public static double GetScore(string key)
|
||||||
/// </summary>
|
|
||||||
public static int GetScore(string key)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(key)) return 0;
|
if (string.IsNullOrWhiteSpace(key)) return 0;
|
||||||
EnsureLoaded();
|
EnsureLoaded();
|
||||||
lock (_lock)
|
UsageRecord? rec;
|
||||||
{
|
lock (_lock) { _data.TryGetValue(key, out rec); }
|
||||||
return _counts.TryGetValue(key, out var count) ? count : 0;
|
if (rec == null) return 0;
|
||||||
}
|
|
||||||
|
// 최신성 감쇠: 30일 반감기
|
||||||
|
var daysSince = (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - rec.LastUsedMs) / 86_400_000.0;
|
||||||
|
var decay = Math.Exp(-daysSince / 43.3); // ln(2)/30 ≈ 1/43.3 → 30일에 0.5배
|
||||||
|
return rec.Count * decay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>실행 횟수 기준 상위 N개의 (경로, 횟수) 목록을 반환합니다.</summary>
|
||||||
/// 실행 횟수 기준 상위 N개의 (경로, 횟수) 목록을 반환합니다.
|
|
||||||
/// 퀵 액션 바 표시에 활용됩니다.
|
|
||||||
/// </summary>
|
|
||||||
public static IReadOnlyList<(string Path, int Count)> GetTopItems(int n)
|
public static IReadOnlyList<(string Path, int Count)> GetTopItems(int n)
|
||||||
{
|
{
|
||||||
EnsureLoaded();
|
EnsureLoaded();
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
return _counts
|
return _data
|
||||||
.OrderByDescending(kv => kv.Value)
|
.OrderByDescending(kv => GetScore(kv.Key))
|
||||||
.Take(n)
|
.Take(n)
|
||||||
.Select(kv => (kv.Key, kv.Value))
|
.Select(kv => (kv.Key, kv.Value.Count))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>실행 점수(횟수×최신성) 기준으로 내림차순 정렬합니다. 동점이면 원래 순서 유지.</summary>
|
||||||
/// 실행 횟수 기준으로 내림차순 정렬하는 컴파러를 반환합니다.
|
|
||||||
/// 동점이면 원래 순서 유지 (stable sort).
|
|
||||||
/// </summary>
|
|
||||||
public static IEnumerable<T> SortByUsage<T>(IEnumerable<T> items, Func<T, string?> keySelector)
|
public static IEnumerable<T> SortByUsage<T>(IEnumerable<T> items, Func<T, string?> keySelector)
|
||||||
{
|
{
|
||||||
EnsureLoaded();
|
EnsureLoaded();
|
||||||
@@ -75,7 +80,7 @@ internal static class UsageRankingService
|
|||||||
.Select(x => x.item);
|
.Select(x => x.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 내부 ──────────────────────────────────────────────────────────────────
|
// ─── 내부 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static void EnsureLoaded()
|
private static void EnsureLoaded()
|
||||||
{
|
{
|
||||||
@@ -88,14 +93,39 @@ internal static class UsageRankingService
|
|||||||
if (File.Exists(_dataFile))
|
if (File.Exists(_dataFile))
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(_dataFile);
|
var json = File.ReadAllText(_dataFile);
|
||||||
var data = JsonSerializer.Deserialize<Dictionary<string, int>>(json);
|
// 새 형식(UsageRecord) 먼저 시도
|
||||||
if (data != null)
|
var newData = JsonSerializer.Deserialize<Dictionary<string, UsageRecord>>(json);
|
||||||
_counts = new Dictionary<string, int>(data, StringComparer.OrdinalIgnoreCase);
|
if (newData != null)
|
||||||
|
{
|
||||||
|
_data = new Dictionary<string, UsageRecord>(newData, StringComparer.OrdinalIgnoreCase);
|
||||||
|
_loaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch
|
||||||
{
|
{
|
||||||
LogService.Warn($"usage.json 로드 실패: {ex.Message}");
|
// 구형식 마이그레이션 시도 (키:횟수 형식)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_dataFile))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_dataFile);
|
||||||
|
var oldData = JsonSerializer.Deserialize<Dictionary<string, int>>(json);
|
||||||
|
if (oldData != null)
|
||||||
|
{
|
||||||
|
var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
_data = new Dictionary<string, UsageRecord>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var kv in oldData)
|
||||||
|
_data[kv.Key] = new UsageRecord { Count = kv.Value, LastUsedMs = nowMs };
|
||||||
|
LogService.Info("[UsageRanking] 구형 usage.json 마이그레이션 완료");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"usage.json 로드 실패: {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
}
|
}
|
||||||
@@ -105,8 +135,8 @@ internal static class UsageRankingService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Dictionary<string, int> snapshot;
|
Dictionary<string, UsageRecord> snapshot;
|
||||||
lock (_lock) { snapshot = new Dictionary<string, int>(_counts); }
|
lock (_lock) { snapshot = new Dictionary<string, UsageRecord>(_data); }
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(_dataFile)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(_dataFile)!);
|
||||||
var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = false });
|
var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = false });
|
||||||
|
|||||||
@@ -100,10 +100,32 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 미리보기 패널 ────────────────────────────────────────────────────────
|
||||||
|
private string _previewText = "";
|
||||||
|
private bool _hasPreview;
|
||||||
|
private CancellationTokenSource? _previewCts;
|
||||||
|
|
||||||
|
public string PreviewText
|
||||||
|
{
|
||||||
|
get => _previewText;
|
||||||
|
private set { _previewText = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasPreview
|
||||||
|
{
|
||||||
|
get => _hasPreview;
|
||||||
|
private set { _hasPreview = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
public LauncherItem? SelectedItem
|
public LauncherItem? SelectedItem
|
||||||
{
|
{
|
||||||
get => _selectedItem;
|
get => _selectedItem;
|
||||||
set { _selectedItem = value; OnPropertyChanged(); }
|
set
|
||||||
|
{
|
||||||
|
_selectedItem = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
_ = UpdatePreviewAsync(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsLoading
|
public bool IsLoading
|
||||||
@@ -518,4 +540,67 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
|||||||
// 기본: 제목
|
// 기본: 제목
|
||||||
return SelectedItem.Title;
|
return SelectedItem.Title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 미리보기 패널 업데이트 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task UpdatePreviewAsync(LauncherItem? item)
|
||||||
|
{
|
||||||
|
_previewCts?.Cancel();
|
||||||
|
_previewCts = new CancellationTokenSource();
|
||||||
|
var ct = _previewCts.Token;
|
||||||
|
|
||||||
|
if (item == null) { HasPreview = false; return; }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(80, ct); // 빠른 탐색 시 불필요한 파일 읽기 방지
|
||||||
|
|
||||||
|
// 클립보드 텍스트
|
||||||
|
if (item.Data is ClipboardEntry clipEntry && clipEntry.IsText)
|
||||||
|
{
|
||||||
|
var text = clipEntry.Text ?? "";
|
||||||
|
PreviewText = text.Length > 400 ? text[..400] + "…" : text;
|
||||||
|
HasPreview = !string.IsNullOrEmpty(PreviewText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인덱스 파일 (IndexEntry)
|
||||||
|
if (item.Data is IndexEntry indexEntry)
|
||||||
|
{
|
||||||
|
var ext = System.IO.Path.GetExtension(indexEntry.Path).ToLowerInvariant();
|
||||||
|
if (IsPreviewableTextFile(ext) && System.IO.File.Exists(indexEntry.Path))
|
||||||
|
{
|
||||||
|
var lines = await ReadFirstLinesAsync(indexEntry.Path, 6, ct);
|
||||||
|
if (ct.IsCancellationRequested) return;
|
||||||
|
PreviewText = string.Join("\n", lines);
|
||||||
|
HasPreview = !string.IsNullOrEmpty(PreviewText.Trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HasPreview = false;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { 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 async Task<IEnumerable<string>> ReadFirstLinesAsync(
|
||||||
|
string path, int maxLines, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
using var reader = new System.IO.StreamReader(path,
|
||||||
|
System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||||
|
for (int i = 0; i < maxLines && !reader.EndOfStream; i++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var line = await reader.ReadLineAsync(ct);
|
||||||
|
if (line != null) lines.Add(line);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ public partial class SettingsViewModel
|
|||||||
s.Launcher.EnableRecent = _enableRecent;
|
s.Launcher.EnableRecent = _enableRecent;
|
||||||
s.Launcher.EnableActionMode = _enableActionMode;
|
s.Launcher.EnableActionMode = _enableActionMode;
|
||||||
s.Launcher.CloseOnFocusLost = _closeOnFocusLost;
|
s.Launcher.CloseOnFocusLost = _closeOnFocusLost;
|
||||||
|
s.Launcher.RememberPosition = _rememberPosition;
|
||||||
s.Launcher.ShowPrefixBadge = _showPrefixBadge;
|
s.Launcher.ShowPrefixBadge = _showPrefixBadge;
|
||||||
s.Launcher.EnableIconAnimation = _enableIconAnimation;
|
s.Launcher.EnableIconAnimation = _enableIconAnimation;
|
||||||
s.Launcher.EnableRainbowGlow = _enableRainbowGlow;
|
s.Launcher.EnableRainbowGlow = _enableRainbowGlow;
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ public partial class SettingsViewModel
|
|||||||
set { _closeOnFocusLost = value; OnPropertyChanged(); }
|
set { _closeOnFocusLost = value; OnPropertyChanged(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool RememberPosition
|
||||||
|
{
|
||||||
|
get => _rememberPosition;
|
||||||
|
set { _rememberPosition = value; OnPropertyChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
public bool ShowPrefixBadge
|
public bool ShowPrefixBadge
|
||||||
{
|
{
|
||||||
get => _showPrefixBadge;
|
get => _showPrefixBadge;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
|||||||
private bool _enableRecent;
|
private bool _enableRecent;
|
||||||
private bool _enableActionMode;
|
private bool _enableActionMode;
|
||||||
private bool _closeOnFocusLost;
|
private bool _closeOnFocusLost;
|
||||||
|
private bool _rememberPosition;
|
||||||
private bool _showPrefixBadge;
|
private bool _showPrefixBadge;
|
||||||
private bool _enableIconAnimation;
|
private bool _enableIconAnimation;
|
||||||
private bool _enableRainbowGlow;
|
private bool _enableRainbowGlow;
|
||||||
@@ -86,6 +87,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
|||||||
_enableRecent = s.Launcher.EnableRecent;
|
_enableRecent = s.Launcher.EnableRecent;
|
||||||
_enableActionMode = s.Launcher.EnableActionMode;
|
_enableActionMode = s.Launcher.EnableActionMode;
|
||||||
_closeOnFocusLost = s.Launcher.CloseOnFocusLost;
|
_closeOnFocusLost = s.Launcher.CloseOnFocusLost;
|
||||||
|
_rememberPosition = s.Launcher.RememberPosition;
|
||||||
_showPrefixBadge = s.Launcher.ShowPrefixBadge;
|
_showPrefixBadge = s.Launcher.ShowPrefixBadge;
|
||||||
_enableIconAnimation = s.Launcher.EnableIconAnimation;
|
_enableIconAnimation = s.Launcher.EnableIconAnimation;
|
||||||
_enableRainbowGlow = s.Launcher.EnableRainbowGlow;
|
_enableRainbowGlow = s.Launcher.EnableRainbowGlow;
|
||||||
|
|||||||
@@ -112,18 +112,31 @@ public partial class LauncherWindow
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 마우스 커서가 위치한 모니터 중앙에 런처를 배치합니다.
|
/// 마우스 커서가 위치한 모니터 중앙에 런처를 배치합니다.
|
||||||
/// 듀얼 모니터 환경에서도 핫키를 누른 순간 마우스가 있는 화면에 나타납니다.
|
/// RememberPosition 설정이 켜진 경우 마지막 위치를 복원합니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void CenterOnScreen()
|
private void CenterOnScreen()
|
||||||
{
|
{
|
||||||
|
// 위치 기억 기능: 저장된 위치 복원
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
var launcher = app?.SettingsService?.Settings.Launcher;
|
||||||
|
if (launcher?.RememberPosition == true && launcher.LastLeft >= 0 && launcher.LastTop >= 0)
|
||||||
|
{
|
||||||
|
var wa = GetCurrentMonitorWorkArea();
|
||||||
|
var w = ActualWidth > 0 ? ActualWidth : 640;
|
||||||
|
var h = ActualHeight > 0 ? ActualHeight : 80;
|
||||||
|
Left = Math.Clamp(launcher.LastLeft, wa.Left, Math.Max(wa.Left, wa.Right - w));
|
||||||
|
Top = Math.Clamp(launcher.LastTop, wa.Top, Math.Max(wa.Top, wa.Bottom - h));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var area = GetCurrentMonitorWorkArea();
|
var area = GetCurrentMonitorWorkArea();
|
||||||
// ActualHeight/ActualWidth는 첫 Show() 전 레이아웃 패스 이전에 0일 수 있음 → 기본값으로 보호
|
// ActualHeight/ActualWidth는 첫 Show() 전 레이아웃 패스 이전에 0일 수 있음 → 기본값으로 보호
|
||||||
var w = ActualWidth > 0 ? ActualWidth : 640;
|
var w2 = ActualWidth > 0 ? ActualWidth : 640;
|
||||||
var h = ActualHeight > 0 ? ActualHeight : 80;
|
var h2 = ActualHeight > 0 ? ActualHeight : 80;
|
||||||
Left = (area.Width - w) / 2 + area.Left;
|
Left = (area.Width - w2) / 2 + area.Left;
|
||||||
Top = _vm.WindowPosition switch
|
Top = _vm.WindowPosition switch
|
||||||
{
|
{
|
||||||
"center" => (area.Height - h) / 2 + area.Top,
|
"center" => (area.Height - h2) / 2 + area.Top,
|
||||||
"bottom" => area.Height * 0.75 + area.Top,
|
"bottom" => area.Height * 0.75 + area.Top,
|
||||||
_ => area.Height * 0.2 + area.Top, // "center-top" (기본)
|
_ => area.Height * 0.2 + area.Top, // "center-top" (기본)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -214,6 +214,15 @@ public partial class LauncherWindow
|
|||||||
|
|
||||||
private void Window_Deactivated(object sender, EventArgs e)
|
private void Window_Deactivated(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
// 위치 기억 설정이 켜진 경우 현재 위치 저장
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
var s = app?.SettingsService?.Settings;
|
||||||
|
if (s?.Launcher.RememberPosition == true && IsVisible && Left >= 0 && Top >= 0)
|
||||||
|
{
|
||||||
|
s.Launcher.LastLeft = Left;
|
||||||
|
s.Launcher.LastTop = Top;
|
||||||
|
_ = Task.Run(() => { try { app!.SettingsService!.Save(); } catch { } });
|
||||||
|
}
|
||||||
// 설정 › 기능 › "포커스 잃으면 닫기"가 켜진 경우에만 자동 숨김
|
// 설정 › 기능 › "포커스 잃으면 닫기"가 켜진 경우에만 자동 숨김
|
||||||
if (_vm.CloseOnFocusLost) Hide();
|
if (_vm.CloseOnFocusLost) Hide();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,7 @@
|
|||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- ─── 입력 영역 ─── -->
|
<!-- ─── 입력 영역 ─── -->
|
||||||
@@ -780,9 +781,32 @@
|
|||||||
</ListView.ItemTemplate>
|
</ListView.ItemTemplate>
|
||||||
</ListView>
|
</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"
|
<TextBlock x:Name="IndexStatusText"
|
||||||
Grid.Row="5"
|
Grid.Row="6"
|
||||||
Visibility="Collapsed"
|
Visibility="Collapsed"
|
||||||
FontSize="10"
|
FontSize="10"
|
||||||
Foreground="{DynamicResource SecondaryText}"
|
Foreground="{DynamicResource SecondaryText}"
|
||||||
@@ -792,7 +816,7 @@
|
|||||||
|
|
||||||
<!-- ─── 위젯 바 (Row A: 기존 4개 / Row B: 날씨·일정·배터리) ─── -->
|
<!-- ─── 위젯 바 (Row A: 기존 4개 / Row B: 날씨·일정·배터리) ─── -->
|
||||||
<Border x:Name="WidgetBar"
|
<Border x:Name="WidgetBar"
|
||||||
Grid.Row="6"
|
Grid.Row="7"
|
||||||
BorderBrush="{DynamicResource SeparatorColor}"
|
BorderBrush="{DynamicResource SeparatorColor}"
|
||||||
BorderThickness="0,1,0,0"
|
BorderThickness="0,1,0,0"
|
||||||
Padding="10,7,10,9">
|
Padding="10,7,10,9">
|
||||||
@@ -977,7 +1001,7 @@
|
|||||||
|
|
||||||
<!-- ─── 토스트 오버레이 ─── -->
|
<!-- ─── 토스트 오버레이 ─── -->
|
||||||
<Border x:Name="ToastOverlay"
|
<Border x:Name="ToastOverlay"
|
||||||
Grid.Row="4" Grid.RowSpan="3"
|
Grid.Row="4" Grid.RowSpan="4"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Bottom"
|
VerticalAlignment="Bottom"
|
||||||
Margin="0,0,0,12"
|
Margin="0,0,0,12"
|
||||||
|
|||||||
@@ -3165,6 +3165,20 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- 창 위치 기억 -->
|
||||||
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel HorizontalAlignment="Left">
|
||||||
|
<TextBlock Style="{StaticResource RowLabel}" Text="마지막 위치 기억"/>
|
||||||
|
<TextBlock Style="{StaticResource RowHint}"
|
||||||
|
Text="AX Commander를 닫은 위치를 기억하고 다음에 같은 위치에서 엽니다."/>
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Style="{StaticResource ToggleSwitch}"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding RememberPosition, Mode=TwoWay}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- 액션 모드 -->
|
<!-- 액션 모드 -->
|
||||||
<Border Style="{StaticResource SettingsRow}">
|
<Border Style="{StaticResource SettingsRow}">
|
||||||
<Grid>
|
<Grid>
|
||||||
|
|||||||
Reference in New Issue
Block a user