[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(),
|
||||
System.Windows.Threading.DispatcherPriority.ApplicationIdle);
|
||||
|
||||
// ─── 파일 아이콘 캐시 워밍업 (앱 유휴 시점에 자주 쓰는 확장자 미리 추출) ──
|
||||
Dispatcher.BeginInvoke(
|
||||
() => AxCopilot.Services.IconCacheService.WarmUp(),
|
||||
System.Windows.Threading.DispatcherPriority.SystemIdle);
|
||||
|
||||
// ─── ChatWindow 미리 생성 (앱 유휴 시점에 숨겨진 채로 초기화) ──────────
|
||||
// 이후 OpenAiChat() 시 창 생성 비용 없이 즉시 열림
|
||||
Dispatcher.BeginInvoke(
|
||||
|
||||
@@ -107,7 +107,7 @@ public class CommandResolver
|
||||
"batch" => "명령 단축키",
|
||||
_ => r.Entry.Path
|
||||
} : r.Entry.Path + " ⇧ Shift+Enter: 폴더 열기",
|
||||
null,
|
||||
IconCacheService.GetIconPath(r.Entry.Path, r.Entry.Type == IndexEntryType.Folder),
|
||||
r.Entry,
|
||||
Symbol: r.Entry.Type switch
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
@@ -87,7 +88,7 @@ public class FileBrowserHandler : IActionHandler
|
||||
items.Add(new LauncherItem(
|
||||
".. (상위 폴더)",
|
||||
parent,
|
||||
null,
|
||||
IconCacheService.GetIconPath(parent, true),
|
||||
new FileBrowserEntry(parent, true),
|
||||
Symbol: "\uE74A")); // Back 아이콘
|
||||
}
|
||||
@@ -107,7 +108,7 @@ public class FileBrowserHandler : IActionHandler
|
||||
items.Add(new LauncherItem(
|
||||
name,
|
||||
d,
|
||||
null,
|
||||
IconCacheService.GetIconPath(d, true),
|
||||
new FileBrowserEntry(d, true),
|
||||
Symbol: Symbols.Folder));
|
||||
}
|
||||
@@ -127,7 +128,7 @@ public class FileBrowserHandler : IActionHandler
|
||||
items.Add(new LauncherItem(
|
||||
name,
|
||||
$"{size} · {ext.TrimStart('.')} 파일",
|
||||
null,
|
||||
IconCacheService.GetIconPath(f),
|
||||
new FileBrowserEntry(f, false),
|
||||
Symbol: ExtToSymbol(ext)));
|
||||
}
|
||||
|
||||
@@ -283,6 +283,18 @@ public class LauncherSettings
|
||||
/// </summary>
|
||||
[JsonPropertyName("monitorDockPositions")]
|
||||
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>
|
||||
|
||||
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.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 런처 항목 실행 횟수를 추적하여 퍼지 검색 결과 정렬에 활용합니다.
|
||||
/// 런처 항목 실행 횟수와 최신성을 추적하여 퍼지 검색 결과 정렬에 활용합니다.
|
||||
/// 저장 위치: %APPDATA%\AxCopilot\usage.json
|
||||
/// 형식: { "키": 횟수 } — 키는 IndexEntry.Path (파일/폴더/앱 경로)
|
||||
/// 점수 = 실행횟수 × 최신성감쇠 (30일 반감기: 30일 전 사용 ≈ 현재의 37%)
|
||||
/// </summary>
|
||||
internal static class UsageRankingService
|
||||
{
|
||||
@@ -14,57 +15,61 @@ internal static class UsageRankingService
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"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 readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// 항목 실행 시 호출하여 카운트를 증가시킵니다.
|
||||
/// </summary>
|
||||
private sealed class UsageRecord
|
||||
{
|
||||
[JsonPropertyName("c")] public int Count { get; set; }
|
||||
[JsonPropertyName("t")] public long LastUsedMs { get; set; }
|
||||
}
|
||||
|
||||
// ─── 공개 API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>항목 실행 시 호출하여 카운트 및 최근 실행 시각을 갱신합니다.</summary>
|
||||
public static void RecordExecution(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
EnsureLoaded();
|
||||
lock (_lock)
|
||||
{
|
||||
_counts.TryGetValue(key, out var current);
|
||||
_counts[key] = current + 1;
|
||||
if (!_data.TryGetValue(key, out var rec))
|
||||
rec = _data[key] = new UsageRecord();
|
||||
rec.Count++;
|
||||
rec.LastUsedMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
}
|
||||
_ = SaveAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 주어진 키의 실행 횟수를 반환합니다. 없으면 0.
|
||||
/// </summary>
|
||||
public static int GetScore(string key)
|
||||
/// <summary>실행 횟수 × 최신성 감쇠 합산 점수를 반환합니다. 없으면 0.</summary>
|
||||
public static double GetScore(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return 0;
|
||||
EnsureLoaded();
|
||||
lock (_lock)
|
||||
{
|
||||
return _counts.TryGetValue(key, out var count) ? count : 0;
|
||||
}
|
||||
UsageRecord? rec;
|
||||
lock (_lock) { _data.TryGetValue(key, out rec); }
|
||||
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>
|
||||
/// 실행 횟수 기준 상위 N개의 (경로, 횟수) 목록을 반환합니다.
|
||||
/// 퀵 액션 바 표시에 활용됩니다.
|
||||
/// </summary>
|
||||
/// <summary>실행 횟수 기준 상위 N개의 (경로, 횟수) 목록을 반환합니다.</summary>
|
||||
public static IReadOnlyList<(string Path, int Count)> GetTopItems(int n)
|
||||
{
|
||||
EnsureLoaded();
|
||||
lock (_lock)
|
||||
return _counts
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
return _data
|
||||
.OrderByDescending(kv => GetScore(kv.Key))
|
||||
.Take(n)
|
||||
.Select(kv => (kv.Key, kv.Value))
|
||||
.Select(kv => (kv.Key, kv.Value.Count))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실행 횟수 기준으로 내림차순 정렬하는 컴파러를 반환합니다.
|
||||
/// 동점이면 원래 순서 유지 (stable sort).
|
||||
/// </summary>
|
||||
/// <summary>실행 점수(횟수×최신성) 기준으로 내림차순 정렬합니다. 동점이면 원래 순서 유지.</summary>
|
||||
public static IEnumerable<T> SortByUsage<T>(IEnumerable<T> items, Func<T, string?> keySelector)
|
||||
{
|
||||
EnsureLoaded();
|
||||
@@ -75,7 +80,7 @@ internal static class UsageRankingService
|
||||
.Select(x => x.item);
|
||||
}
|
||||
|
||||
// ─── 내부 ──────────────────────────────────────────────────────────────────
|
||||
// ─── 내부 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void EnsureLoaded()
|
||||
{
|
||||
@@ -88,14 +93,39 @@ internal static class UsageRankingService
|
||||
if (File.Exists(_dataFile))
|
||||
{
|
||||
var json = File.ReadAllText(_dataFile);
|
||||
var data = JsonSerializer.Deserialize<Dictionary<string, int>>(json);
|
||||
if (data != null)
|
||||
_counts = new Dictionary<string, int>(data, StringComparer.OrdinalIgnoreCase);
|
||||
// 새 형식(UsageRecord) 먼저 시도
|
||||
var newData = JsonSerializer.Deserialize<Dictionary<string, UsageRecord>>(json);
|
||||
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;
|
||||
}
|
||||
@@ -105,8 +135,8 @@ internal static class UsageRankingService
|
||||
{
|
||||
try
|
||||
{
|
||||
Dictionary<string, int> snapshot;
|
||||
lock (_lock) { snapshot = new Dictionary<string, int>(_counts); }
|
||||
Dictionary<string, UsageRecord> snapshot;
|
||||
lock (_lock) { snapshot = new Dictionary<string, UsageRecord>(_data); }
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_dataFile)!);
|
||||
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
|
||||
{
|
||||
get => _selectedItem;
|
||||
set { _selectedItem = value; OnPropertyChanged(); }
|
||||
set
|
||||
{
|
||||
_selectedItem = value;
|
||||
OnPropertyChanged();
|
||||
_ = UpdatePreviewAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoading
|
||||
@@ -518,4 +540,67 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
||||
// 기본: 제목
|
||||
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.EnableActionMode = _enableActionMode;
|
||||
s.Launcher.CloseOnFocusLost = _closeOnFocusLost;
|
||||
s.Launcher.RememberPosition = _rememberPosition;
|
||||
s.Launcher.ShowPrefixBadge = _showPrefixBadge;
|
||||
s.Launcher.EnableIconAnimation = _enableIconAnimation;
|
||||
s.Launcher.EnableRainbowGlow = _enableRainbowGlow;
|
||||
|
||||
@@ -37,6 +37,12 @@ public partial class SettingsViewModel
|
||||
set { _closeOnFocusLost = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
public bool RememberPosition
|
||||
{
|
||||
get => _rememberPosition;
|
||||
set { _rememberPosition = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
public bool ShowPrefixBadge
|
||||
{
|
||||
get => _showPrefixBadge;
|
||||
|
||||
@@ -34,6 +34,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
private bool _enableRecent;
|
||||
private bool _enableActionMode;
|
||||
private bool _closeOnFocusLost;
|
||||
private bool _rememberPosition;
|
||||
private bool _showPrefixBadge;
|
||||
private bool _enableIconAnimation;
|
||||
private bool _enableRainbowGlow;
|
||||
@@ -86,6 +87,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
_enableRecent = s.Launcher.EnableRecent;
|
||||
_enableActionMode = s.Launcher.EnableActionMode;
|
||||
_closeOnFocusLost = s.Launcher.CloseOnFocusLost;
|
||||
_rememberPosition = s.Launcher.RememberPosition;
|
||||
_showPrefixBadge = s.Launcher.ShowPrefixBadge;
|
||||
_enableIconAnimation = s.Launcher.EnableIconAnimation;
|
||||
_enableRainbowGlow = s.Launcher.EnableRainbowGlow;
|
||||
|
||||
@@ -112,18 +112,31 @@ public partial class LauncherWindow
|
||||
|
||||
/// <summary>
|
||||
/// 마우스 커서가 위치한 모니터 중앙에 런처를 배치합니다.
|
||||
/// 듀얼 모니터 환경에서도 핫키를 누른 순간 마우스가 있는 화면에 나타납니다.
|
||||
/// RememberPosition 설정이 켜진 경우 마지막 위치를 복원합니다.
|
||||
/// </summary>
|
||||
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();
|
||||
// ActualHeight/ActualWidth는 첫 Show() 전 레이아웃 패스 이전에 0일 수 있음 → 기본값으로 보호
|
||||
var w = ActualWidth > 0 ? ActualWidth : 640;
|
||||
var h = ActualHeight > 0 ? ActualHeight : 80;
|
||||
Left = (area.Width - w) / 2 + area.Left;
|
||||
var w2 = ActualWidth > 0 ? ActualWidth : 640;
|
||||
var h2 = ActualHeight > 0 ? ActualHeight : 80;
|
||||
Left = (area.Width - w2) / 2 + area.Left;
|
||||
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,
|
||||
_ => area.Height * 0.2 + area.Top, // "center-top" (기본)
|
||||
};
|
||||
|
||||
@@ -214,6 +214,15 @@ public partial class LauncherWindow
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- ─── 입력 영역 ─── -->
|
||||
@@ -780,9 +781,32 @@
|
||||
</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}"
|
||||
@@ -792,7 +816,7 @@
|
||||
|
||||
<!-- ─── 위젯 바 (Row A: 기존 4개 / Row B: 날씨·일정·배터리) ─── -->
|
||||
<Border x:Name="WidgetBar"
|
||||
Grid.Row="6"
|
||||
Grid.Row="7"
|
||||
BorderBrush="{DynamicResource SeparatorColor}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="10,7,10,9">
|
||||
@@ -977,7 +1001,7 @@
|
||||
|
||||
<!-- ─── 토스트 오버레이 ─── -->
|
||||
<Border x:Name="ToastOverlay"
|
||||
Grid.Row="4" Grid.RowSpan="3"
|
||||
Grid.Row="4" Grid.RowSpan="4"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,12"
|
||||
|
||||
@@ -3165,6 +3165,20 @@
|
||||
</Grid>
|
||||
</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}">
|
||||
<Grid>
|
||||
|
||||
Reference in New Issue
Block a user