From e33f8ac6204998748d494c7bda47d6193b4b087a Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 17:38:12 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20UX]=20=EC=B0=BD=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=20=EA=B8=B0=EC=96=B5=C2=B7=ED=8C=8C=EC=9D=BC=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=C2=B7=ED=8D=BC=EC=A7=80=20=EB=9E=AD=ED=82=B9?= =?UTF-8?q?=C2=B7=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=204=EC=A2=85=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 창 위치 기억 (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 --- src/AxCopilot/App.xaml.cs | 5 + src/AxCopilot/Core/CommandResolver.cs | 2 +- src/AxCopilot/Handlers/FileBrowserHandler.cs | 7 +- src/AxCopilot/Models/AppSettings.cs | 12 ++ src/AxCopilot/Services/IconCacheService.cs | 192 ++++++++++++++++++ src/AxCopilot/Services/UsageRankingService.cs | 100 +++++---- src/AxCopilot/ViewModels/LauncherViewModel.cs | 87 +++++++- .../ViewModels/SettingsViewModel.Methods.cs | 1 + .../SettingsViewModel.Properties.cs | 6 + src/AxCopilot/ViewModels/SettingsViewModel.cs | 2 + .../Views/LauncherWindow.Animations.cs | 23 ++- src/AxCopilot/Views/LauncherWindow.Shell.cs | 9 + src/AxCopilot/Views/LauncherWindow.xaml | 30 ++- src/AxCopilot/Views/SettingsWindow.xaml | 14 ++ 14 files changed, 442 insertions(+), 48 deletions(-) create mode 100644 src/AxCopilot/Services/IconCacheService.cs diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index d0c8e59..879c4ca 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -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( diff --git a/src/AxCopilot/Core/CommandResolver.cs b/src/AxCopilot/Core/CommandResolver.cs index e118ad0..34bd789 100644 --- a/src/AxCopilot/Core/CommandResolver.cs +++ b/src/AxCopilot/Core/CommandResolver.cs @@ -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 { diff --git a/src/AxCopilot/Handlers/FileBrowserHandler.cs b/src/AxCopilot/Handlers/FileBrowserHandler.cs index 4679c38..d3485d0 100644 --- a/src/AxCopilot/Handlers/FileBrowserHandler.cs +++ b/src/AxCopilot/Handlers/FileBrowserHandler.cs @@ -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))); } diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 250e4c8..0c1d38f 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -283,6 +283,18 @@ public class LauncherSettings /// [JsonPropertyName("monitorDockPositions")] public Dictionary MonitorDockPositions { get; set; } = new(); + + /// AX Commander 마지막 위치 기억 여부. true이면 닫은 위치에서 다시 열립니다. 기본 false. + [JsonPropertyName("rememberPosition")] + public bool RememberPosition { get; set; } = false; + + /// AX Commander 마지막 Left 좌표 (-1이면 기본 위치). + [JsonPropertyName("lastLeft")] + public double LastLeft { get; set; } = -1; + + /// AX Commander 마지막 Top 좌표 (-1이면 기본 위치). + [JsonPropertyName("lastTop")] + public double LastTop { get; set; } = -1; } /// diff --git a/src/AxCopilot/Services/IconCacheService.cs b/src/AxCopilot/Services/IconCacheService.cs new file mode 100644 index 0000000..e87ce8e --- /dev/null +++ b/src/AxCopilot/Services/IconCacheService.cs @@ -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; + +/// +/// Shell32 SHGetFileInfo를 사용하여 파일/폴더의 Windows 아이콘을 추출·캐시합니다. +/// 캐시 위치: %LOCALAPPDATA%\AxCopilot\IconCache\{확장자}.png +/// GetIconPath()는 캐시 미스 시 null을 반환하고 백그라운드에서 추출을 시작합니다. +/// WarmUp()을 앱 시작 시 호출하면 자주 쓰는 확장자를 미리 준비합니다. +/// +internal static class IconCacheService +{ + private static readonly string _cacheDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "AxCopilot", "IconCache"); + + /// key(확장자 또는 "folder") → PNG 캐시 파일 경로 (null = 추출 실패) + private static readonly ConcurrentDictionary _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 ─────────────────────────────────────────────────────────── + + /// + /// 파일 확장자 기반 아이콘 PNG 경로를 반환합니다. + /// 캐시가 없으면 백그라운드에서 추출을 시작하고 null을 반환합니다. + /// + 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; + } + + /// + /// 앱 시작 시 자주 쓰는 파일 형식의 아이콘을 미리 추출합니다. + /// 중복 실행은 자동으로 건너뜁니다. + /// + 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); + } + } + + /// Dispatcher(STA) 스레드에서 HICON을 PNG로 변환하여 저장합니다. + 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); + } + } +} diff --git a/src/AxCopilot/Services/UsageRankingService.cs b/src/AxCopilot/Services/UsageRankingService.cs index 3965d3f..617b994 100644 --- a/src/AxCopilot/Services/UsageRankingService.cs +++ b/src/AxCopilot/Services/UsageRankingService.cs @@ -1,12 +1,13 @@ using System.IO; using System.Text.Json; +using System.Text.Json.Serialization; namespace AxCopilot.Services; /// -/// 런처 항목 실행 횟수를 추적하여 퍼지 검색 결과 정렬에 활용합니다. +/// 런처 항목 실행 횟수와 최신성을 추적하여 퍼지 검색 결과 정렬에 활용합니다. /// 저장 위치: %APPDATA%\AxCopilot\usage.json -/// 형식: { "키": 횟수 } — 키는 IndexEntry.Path (파일/폴더/앱 경로) +/// 점수 = 실행횟수 × 최신성감쇠 (30일 반감기: 30일 전 사용 ≈ 현재의 37%) /// internal static class UsageRankingService { @@ -14,57 +15,61 @@ internal static class UsageRankingService Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "usage.json"); - private static Dictionary _counts = new(StringComparer.OrdinalIgnoreCase); + private static Dictionary _data = new(StringComparer.OrdinalIgnoreCase); private static bool _loaded = false; private static readonly object _lock = new(); - /// - /// 항목 실행 시 호출하여 카운트를 증가시킵니다. - /// + private sealed class UsageRecord + { + [JsonPropertyName("c")] public int Count { get; set; } + [JsonPropertyName("t")] public long LastUsedMs { get; set; } + } + + // ─── 공개 API ────────────────────────────────────────────────────────────── + + /// 항목 실행 시 호출하여 카운트 및 최근 실행 시각을 갱신합니다. 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(); } - /// - /// 주어진 키의 실행 횟수를 반환합니다. 없으면 0. - /// - public static int GetScore(string key) + /// 실행 횟수 × 최신성 감쇠 합산 점수를 반환합니다. 없으면 0. + 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; } - /// - /// 실행 횟수 기준 상위 N개의 (경로, 횟수) 목록을 반환합니다. - /// 퀵 액션 바 표시에 활용됩니다. - /// + /// 실행 횟수 기준 상위 N개의 (경로, 횟수) 목록을 반환합니다. 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(); } - /// - /// 실행 횟수 기준으로 내림차순 정렬하는 컴파러를 반환합니다. - /// 동점이면 원래 순서 유지 (stable sort). - /// + /// 실행 점수(횟수×최신성) 기준으로 내림차순 정렬합니다. 동점이면 원래 순서 유지. public static IEnumerable SortByUsage(IEnumerable items, Func 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>(json); - if (data != null) - _counts = new Dictionary(data, StringComparer.OrdinalIgnoreCase); + // 새 형식(UsageRecord) 먼저 시도 + var newData = JsonSerializer.Deserialize>(json); + if (newData != null) + { + _data = new Dictionary(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>(json); + if (oldData != null) + { + var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + _data = new Dictionary(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 snapshot; - lock (_lock) { snapshot = new Dictionary(_counts); } + Dictionary snapshot; + lock (_lock) { snapshot = new Dictionary(_data); } Directory.CreateDirectory(Path.GetDirectoryName(_dataFile)!); var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = false }); diff --git a/src/AxCopilot/ViewModels/LauncherViewModel.cs b/src/AxCopilot/ViewModels/LauncherViewModel.cs index 11ee9db..3d5c2e3 100644 --- a/src/AxCopilot/ViewModels/LauncherViewModel.cs +++ b/src/AxCopilot/ViewModels/LauncherViewModel.cs @@ -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> ReadFirstLinesAsync( + string path, int maxLines, CancellationToken ct) + { + var lines = new List(); + 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; + } } diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.Methods.cs b/src/AxCopilot/ViewModels/SettingsViewModel.Methods.cs index a72d151..ff399fd 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.Methods.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.Methods.cs @@ -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; diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs b/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs index e22835d..15bbe69 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.Properties.cs @@ -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; diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index 7ff6eb3..7a6ff27 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -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; diff --git a/src/AxCopilot/Views/LauncherWindow.Animations.cs b/src/AxCopilot/Views/LauncherWindow.Animations.cs index e24dccd..987abe5 100644 --- a/src/AxCopilot/Views/LauncherWindow.Animations.cs +++ b/src/AxCopilot/Views/LauncherWindow.Animations.cs @@ -112,18 +112,31 @@ public partial class LauncherWindow /// /// 마우스 커서가 위치한 모니터 중앙에 런처를 배치합니다. - /// 듀얼 모니터 환경에서도 핫키를 누른 순간 마우스가 있는 화면에 나타납니다. + /// RememberPosition 설정이 켜진 경우 마지막 위치를 복원합니다. /// 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" (기본) }; diff --git a/src/AxCopilot/Views/LauncherWindow.Shell.cs b/src/AxCopilot/Views/LauncherWindow.Shell.cs index 358f63d..9e61317 100644 --- a/src/AxCopilot/Views/LauncherWindow.Shell.cs +++ b/src/AxCopilot/Views/LauncherWindow.Shell.cs @@ -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(); } diff --git a/src/AxCopilot/Views/LauncherWindow.xaml b/src/AxCopilot/Views/LauncherWindow.xaml index 69a5953..426a560 100644 --- a/src/AxCopilot/Views/LauncherWindow.xaml +++ b/src/AxCopilot/Views/LauncherWindow.xaml @@ -203,6 +203,7 @@ + @@ -780,9 +781,32 @@ + + + + +