Initial commit to new repository

This commit is contained in:
2026-04-03 18:23:52 +09:00
commit deffb33cf9
5248 changed files with 267762 additions and 0 deletions

View File

@@ -0,0 +1,805 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Media;
using AxCopilot.Core;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.ViewModels;
public class LauncherViewModel : INotifyPropertyChanged
{
private static App? CurrentApp => System.Windows.Application.Current as App;
private readonly CommandResolver _resolver;
private readonly SettingsService _settings;
private string _inputText = "";
private LauncherItem? _selectedItem;
private bool _isLoading;
private CancellationTokenSource? _searchCts;
private System.Threading.Timer? _debounceTimer;
private const int DebounceMs = 30; // 30ms 디바운스 — 연속 입력 시 중간 검색 스킵
private string _lastSearchQuery = ""; // IME 조합 완성 후 동일 쿼리 재검색 방지용
// ─── 파일 액션 모드 ───────────────────────────────────────────────────────
private bool _isActionMode;
private LauncherItem? _actionSourceItem;
private string _savedQuery = "";
// ─── 클립보드 병합 ────────────────────────────────────────────────────────
private readonly HashSet<ClipboardEntry> _mergeQueue = new();
/// <summary>
/// 검색 결과 컬렉션. ReplaceAll() 호출 시 단일 Reset 이벤트로 WPF 레이아웃 1회만 갱신.
/// </summary>
public BulkObservableCollection<LauncherItem> Results { get; } = new();
// ─── 기본 프로퍼티 ────────────────────────────────────────────────────────
public string InputText
{
get => _inputText;
set
{
if (_inputText == value) return;
_inputText = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasActivePrefix));
OnPropertyChanged(nameof(ActivePrefixLabel));
OnPropertyChanged(nameof(ActivePrefixSymbol));
OnPropertyChanged(nameof(ActivePrefixBrush));
OnPropertyChanged(nameof(IsClipboardMode));
OnPropertyChanged(nameof(ShowMergeHint));
OnPropertyChanged(nameof(MergeHintText));
// 연속 입력 시 이전 검색 즉시 취소 + 50ms 디바운스 후 실제 검색 시작
_searchCts?.Cancel();
_debounceTimer?.Dispose();
if (string.IsNullOrWhiteSpace(value))
{
Results.Clear();
}
else
{
var captured = value;
_debounceTimer = new System.Threading.Timer(
_ => Application.Current?.Dispatcher.InvokeAsync(() => _ = SearchAsync(captured)),
null, DebounceMs, System.Threading.Timeout.Infinite);
}
}
}
public LauncherItem? SelectedItem
{
get => _selectedItem;
set { _selectedItem = value; OnPropertyChanged(); }
}
public bool IsLoading
{
get => _isLoading;
set { _isLoading = value; OnPropertyChanged(); }
}
private string _placeholderText = AxCopilot.Services.L10n.Get("placeholder");
public string PlaceholderText
{
get => _placeholderText;
private set { _placeholderText = value; OnPropertyChanged(); }
}
/// <summary>런처가 열릴 때마다 호출 — 설정에 따라 랜덤 또는 고정 문구로 교체합니다.</summary>
public void RefreshPlaceholder() =>
PlaceholderText = _settings.Settings.Launcher.EnableRandomPlaceholder
? AxCopilot.Services.L10n.GetRandomPlaceholder()
: AxCopilot.Services.L10n.Get("placeholder");
/// <summary>아이콘 애니메이션 설정값</summary>
public bool EnableIconAnimation => _settings.Settings.Launcher.EnableIconAnimation;
/// <summary>런처 무지개 글로우 설정값</summary>
public bool EnableRainbowGlow => _settings.Settings.Launcher.EnableRainbowGlow;
/// <summary>선택 아이템 글로우 설정값</summary>
public bool EnableSelectionGlow => _settings.Settings.Launcher.EnableSelectionGlow;
/// <summary>런처 창 테두리 표시 설정값</summary>
public bool ShowLauncherBorder => _settings.Settings.Launcher.ShowLauncherBorder;
public string ThemeSetting => _settings.Settings.Launcher.Theme;
public CustomThemeColors? CustomThemeColors => _settings.Settings.Launcher.CustomTheme;
public string WindowPosition => _settings.Settings.Launcher.Position;
// ─── Prefix 배지 ─────────────────────────────────────────────────────────
private static readonly Dictionary<string, (string Label, string Symbol, string ColorHex)> PrefixMap = new()
{
// ─── 기본 프리픽스 ──────────────────────────────────────────────────────
{ "@", ("URL", Symbols.Globe, "#0078D4") },
{ "~", ("워크", Symbols.Workspace, "#C50F1F") },
{ ">", ("명령", Symbols.Terminal, "#323130") },
{ "$", ("클립", Symbols.Clipboard, "#8764B8") },
{ "cd", ("폴더", Symbols.Folder, "#107C10") },
{ "#", ("히스", Symbols.History, "#B7791F") },
{ ";", ("스닛", Symbols.Snippet, "#0F6CBD") },
{ "=", ("계산", Symbols.Calculator, "#4B5EFC") },
{ "!", ("AI", "\uE8BD", "#8B2FC9") },
{ "?", ("검색", Symbols.Globe, "#006EAF") },
{ "/", ("시스템", Symbols.Power, "#4A4A4A") },
// ─── 시스템 도구 ─────────────────────────────────────────────────────────
{ "kill ", ("킬", Symbols.Error, "#CC2222") },
{ "media ", ("미디어", Symbols.MediaPlay, "#1A6B3C") },
{ "info ", ("시스템", Symbols.Computer, "#5B4E7E") },
{ "port", ("포트", Symbols.Network, "#006699") },
// ─── v1.5 핸들러 ─────────────────────────────────────────────────────────
{ "emoji", ("이모지", Symbols.Emoji, "#F59E0B") },
{ "color", ("색상", Symbols.ColorPicker, "#EC4899") },
{ "recent", ("최근", Symbols.RecentFiles, "#059669") },
{ "note", ("메모", Symbols.Note, "#7C3AED") },
{ "uninstall", ("제거", Symbols.Uninstall, "#DC2626") },
// ─── v1.6 핸들러 ─────────────────────────────────────────────────────────
{ "env", ("환경변수", Symbols.EnvVar, "#0D9488") },
{ "json", ("JSON", Symbols.JsonValid, "#D97706") },
{ "encode ", ("인코딩", Symbols.EncodeIcon, "#6366F1") },
{ "snap", ("스냅", Symbols.SnapLayout, "#B45309") },
{ "cap", ("캡처", Symbols.CaptureIcon, "#BE185D") },
{ "help", ("도움말", Symbols.Info, "#6B7280") },
};
// ─── 설정 기능 토글 (런처 실동작 연결) ──────────────────────────────────
/// <summary>번호 배지(1~9) 표시 여부 — LauncherWindow.xaml 번호 뱃지 Visibility 바인딩</summary>
public bool ShowNumberBadges => _settings.Settings.Launcher.ShowNumberBadges;
/// <summary>포커스 잃으면 런처 닫기 — Window_Deactivated에서 읽음</summary>
public bool CloseOnFocusLost => _settings.Settings.Launcher.CloseOnFocusLost;
/// <summary>액션 모드(→) 허용 여부</summary>
public bool EnableActionMode => _settings.Settings.Launcher.EnableActionMode;
/// <summary>AI 기능 활성화 여부 (AiEnabled 설정). false이면 ! prefix 숨김.</summary>
private static bool IsAiEnabled() =>
CurrentApp?.SettingsService?.Settings.AiEnabled ?? true;
public bool HasActivePrefix =>
_settings.Settings.Launcher.ShowPrefixBadge &&
_inputText.Length > 0 && PrefixMap.Keys.Any(k =>
(k != "!" || IsAiEnabled()) &&
_inputText.StartsWith(k, StringComparison.OrdinalIgnoreCase));
public string? ActivePrefix =>
PrefixMap.Keys.FirstOrDefault(k =>
(k != "!" || IsAiEnabled()) &&
_inputText.StartsWith(k, StringComparison.OrdinalIgnoreCase));
public string ActivePrefixLabel =>
ActivePrefix != null && PrefixMap.TryGetValue(ActivePrefix, out var info) ? info.Label : "";
public string ActivePrefixSymbol =>
ActivePrefix != null && PrefixMap.TryGetValue(ActivePrefix, out var info) ? info.Symbol : Symbols.Search;
public SolidColorBrush ActivePrefixBrush
{
get
{
if (ActivePrefix != null && PrefixMap.TryGetValue(ActivePrefix, out var info))
{
var color = (Color)ColorConverter.ConvertFromString(info.ColorHex);
return new SolidColorBrush(color);
}
return new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
}
}
// ─── 파일 액션 모드 프로퍼티 ─────────────────────────────────────────────
public bool IsActionMode
{
get => _isActionMode;
private set
{
_isActionMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ShowActionModeBar));
OnPropertyChanged(nameof(ActionModeBreadcrumb));
}
}
public bool ShowActionModeBar => IsActionMode;
public string ActionModeBreadcrumb => _actionSourceItem?.Title ?? "";
/// <summary>현재 선택 항목이 파일 액션 가능 여부</summary>
public bool CanEnterActionMode() =>
!IsActionMode && SelectedItem?.Data is IndexEntry;
// ─── 클립보드 병합 모드 프로퍼티 ─────────────────────────────────────────
public bool IsClipboardMode => _inputText.StartsWith("#", StringComparison.Ordinal);
public int MergeCount => _mergeQueue.Count;
public bool ShowMergeHint => _mergeQueue.Count > 0 && IsClipboardMode;
public string MergeHintText =>
_mergeQueue.Count > 0
? $"{_mergeQueue.Count}개 선택됨 · Shift+Enter로 합치기 · Esc로 취소"
: "";
public bool IsItemMarkedForMerge(LauncherItem item) =>
item.Data is ClipboardEntry e && _mergeQueue.Contains(e);
// ─── 이벤트 ───────────────────────────────────────────────────────────────
public event EventHandler? CloseRequested;
public event EventHandler<string>? NotificationRequested;
public LauncherViewModel(CommandResolver resolver, SettingsService settings)
{
_resolver = resolver;
_settings = settings;
}
// ─── 런처 표시 시 초기화 ──────────────────────────────────────────────────
/// <summary>런처가 표시될 때 호출 — 이전 상태 초기화</summary>
public void OnShown()
{
if (IsActionMode) { IsActionMode = false; _actionSourceItem = null; }
Results.Clear();
_lastSearchQuery = "";
ClearMerge();
}
// ─── 검색 ────────────────────────────────────────────────────────────────
/// <summary>
/// IME 조합 중 코드비하인드에서 직접 호출하는 검색 트리거.
/// InputText 바인딩을 건드리지 않으므로 한글 조합 상태가 유지됩니다.
/// 동일 쿼리로 재호출 시 (IME 조합 완성 이벤트 중복) 검색을 건너뜁니다.
/// </summary>
internal Task TriggerImeSearchAsync(string text)
{
var key = text.Trim().ToLowerInvariant();
// IME 조합 완성으로 인한 동일 쿼리 재검색이면 선택 위치 유지 (건너뜀)
if (key == _lastSearchQuery && Results.Count > 0) return Task.CompletedTask;
return SearchAsync(text);
}
private async Task SearchAsync(string query)
{
// CTS 취소는 setter에서 이미 처리됨. 새 토큰만 발급.
_searchCts = new CancellationTokenSource();
var ct = _searchCts.Token;
var queryKey = query.Trim().ToLowerInvariant();
// 동일 쿼리 재검색(IME 커밋 후 WPF 바인딩 갱신 등)이면 결과를 일단 유지
var isSameQuery = queryKey == _lastSearchQuery && Results.Count > 0;
if (!isSameQuery) Results.Clear();
if (string.IsNullOrWhiteSpace(query)) { _lastSearchQuery = ""; return; }
// 기능 비활성화 시 해당 프리픽스 쿼리는 빈 결과 반환
if (!_settings.Settings.Launcher.EnableFavorites &&
query.StartsWith("fav", StringComparison.OrdinalIgnoreCase)) return;
if (!_settings.Settings.Launcher.EnableRecent &&
query.StartsWith("recent", StringComparison.OrdinalIgnoreCase)) return;
// 동일 쿼리면 현재 선택 항목을 기억해 복원 시도
var prevSelected = isSameQuery ? SelectedItem : null;
IsLoading = true;
try
{
var items = await _resolver.ResolveAsync(query, ct);
if (ct.IsCancellationRequested) return;
// ReplaceAll: Clear+AddRange를 단일 Reset 이벤트로 → WPF 레이아웃 1회만 갱신
Results.ReplaceAll(items);
_lastSearchQuery = queryKey;
if (isSameQuery && prevSelected != null)
{
// 결과 목록이 같으면 이전 선택 복원 (ReferenceEquals: 동일 데이터 객체 비교)
var restored = Results.FirstOrDefault(r =>
ReferenceEquals(r.Data, prevSelected.Data) || r.Title == prevSelected.Title);
SelectedItem = restored ?? Results.FirstOrDefault();
}
else
{
SelectedItem = Results.FirstOrDefault();
}
}
catch (OperationCanceledException) { /* 정상 취소 */ }
catch (Exception ex)
{
LogService.Error($"검색 오류: {ex.Message}");
}
finally
{
if (!ct.IsCancellationRequested) IsLoading = false;
}
}
// ─── 실행 ────────────────────────────────────────────────────────────────
public async Task ExecuteSelectedAsync()
{
// 파일 액션 모드: 선택한 액션 실행
if (IsActionMode && SelectedItem?.Data is FileActionData fileAction)
{
ExecuteFileAction(fileAction);
ExitActionMode();
CloseRequested?.Invoke(this, EventArgs.Empty);
return;
}
if (SelectedItem == null) return;
// 창을 먼저 닫아 체감 속도 확보 → 실행은 백그라운드
CloseRequested?.Invoke(this, EventArgs.Empty);
try
{
await _resolver.ExecuteAsync(SelectedItem, InputText, CancellationToken.None);
}
catch (Exception ex)
{
NotificationRequested?.Invoke(this, $"실행 실패: {ex.Message}");
LogService.Error($"Execute 오류: {ex.Message}");
}
}
/// <summary>
/// 캡처 모드에서 Shift+Enter 시 지연 캡처 타이머 선택 목록을 표시합니다.
/// </summary>
public bool ShowDelayTimerItems()
{
if (SelectedItem?.Data is not string mode) return false;
// 이미 delay 아이템이면 실행으로 넘기기
if (mode.StartsWith("delay:")) return false;
// ScreenCaptureHandler 찾기
if (!_resolver.RegisteredHandlers.TryGetValue(
ActivePrefix ?? "", out var handler) ||
handler is not AxCopilot.Handlers.ScreenCaptureHandler capHandler)
return false;
var items = capHandler.GetDelayItems(mode).ToList();
Results.Clear();
foreach (var item in items) Results.Add(item);
SelectedItem = Results.FirstOrDefault();
return true;
}
public void SelectNext()
{
if (Results.Count == 0) return;
var idx = SelectedItem != null ? Results.IndexOf(SelectedItem) : -1;
SelectedItem = Results[(idx + 1) % Results.Count];
}
public void SelectPrev()
{
if (Results.Count == 0) return;
var idx = SelectedItem != null ? Results.IndexOf(SelectedItem) : 0;
SelectedItem = Results[(idx - 1 + Results.Count) % Results.Count];
}
// ─── Large Type ───────────────────────────────────────────────────────────
/// <summary>Shift+Enter 시 Large Type으로 표시할 텍스트 반환</summary>
public string GetLargeTypeText()
{
if (SelectedItem == null) return "";
// 계산기: 결과값 문자열
if (SelectedItem.Data is string s && !string.IsNullOrWhiteSpace(s)) return s;
// 클립보드 히스토리: 전체 텍스트
if (SelectedItem.Data is ClipboardEntry entry && entry.IsText) return entry.Text;
// 기본: 제목
return SelectedItem.Title;
}
// ─── 파일 액션 서브메뉴 ───────────────────────────────────────────────────
public void EnterActionMode(LauncherItem item)
{
if (!_settings.Settings.Launcher.EnableActionMode) return;
if (item.Data is not IndexEntry entry) return;
_actionSourceItem = item;
_savedQuery = _inputText;
IsActionMode = true;
var path = Environment.ExpandEnvironmentVariables(entry.Path);
var isDir = Directory.Exists(path);
var name = Path.GetFileName(path);
Results.Clear();
Results.Add(MakeAction("경로 복사",
path, FileAction.CopyPath, Symbols.Clipboard, "#8764B8"));
Results.Add(MakeAction("전체 경로 복사",
path, FileAction.CopyFullPath, Symbols.Clipboard, "#C55A11"));
Results.Add(MakeAction("파일 탐색기에서 열기",
"Explorer에서 위치 선택됨으로 표시", FileAction.OpenExplorer, Symbols.Folder, "#107C10"));
if (!isDir)
Results.Add(MakeAction("관리자 권한으로 실행",
"UAC 권한 상승 후 실행", FileAction.RunAsAdmin, Symbols.Lock, "#C50F1F"));
Results.Add(MakeAction("터미널에서 열기",
isDir ? path : Path.GetDirectoryName(path) ?? path,
FileAction.OpenTerminal, Symbols.Terminal, "#323130"));
if (!isDir)
Results.Add(MakeAction("파일 속성 보기",
"Windows 속성 대화 상자 열기", FileAction.ShowProperties, Symbols.Info, "#6B2C91"));
Results.Add(MakeAction("이름 바꾸기",
name, FileAction.Rename, Symbols.Rename, "#D97706"));
Results.Add(MakeAction("휴지통으로 삭제",
"복구 가능한 삭제 · 확인 후 실행", FileAction.DeleteToRecycleBin, Symbols.Delete, "#C50F1F"));
SelectedItem = Results.FirstOrDefault();
static LauncherItem MakeAction(string title, string subtitle,
FileAction action, string symbol, string colorHex)
{
var data = new FileActionData(subtitle, action);
return new LauncherItem(title, subtitle, null, data, Symbol: symbol);
}
}
public void ExitActionMode()
{
IsActionMode = false;
_actionSourceItem = null;
// 이전 검색 쿼리 복원
var q = _savedQuery;
_savedQuery = "";
_ = SearchAsync(q);
}
private static void ExecuteFileAction(FileActionData data)
{
var path = data.Path;
switch (data.Action)
{
case FileAction.CopyPath:
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
break;
case FileAction.OpenExplorer:
if (File.Exists(path))
Process.Start("explorer.exe", $"/select,\"{path}\"");
else
Process.Start("explorer.exe", $"\"{path}\"");
break;
case FileAction.RunAsAdmin:
try
{
Process.Start(new ProcessStartInfo(path)
{ UseShellExecute = true, Verb = "runas" });
}
catch (Exception ex)
{
LogService.Warn($"관리자 실행 취소: {ex.Message}");
}
break;
case FileAction.CopyFullPath:
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
break;
case FileAction.ShowProperties:
try
{
var psi = new ProcessStartInfo("explorer.exe")
{
Arguments = $"/select,\"{path}\"",
UseShellExecute = true
};
Process.Start(psi);
// Shell property dialog
var propInfo = new ProcessStartInfo
{
FileName = "rundll32.exe",
Arguments = $"shell32.dll,ShellExec_RunDLL \"properties\" \"{path}\"",
UseShellExecute = false
};
// 대안: Shell verb "properties"
try
{
Process.Start(new ProcessStartInfo(path)
{ UseShellExecute = true, Verb = "properties" });
}
catch (Exception) { /* 일부 파일 형식에서 지원 안됨 */ }
}
catch (Exception ex) { LogService.Warn($"속성 열기 실패: {ex.Message}"); }
break;
case FileAction.Rename:
// 런처에서 rename 핸들러로 전달
// ExitActionMode 후 InputText가 rename 프리픽스로 설정됨
break;
case FileAction.DeleteToRecycleBin:
// LauncherWindow.xaml.cs의 ExecuteSelected에서 확인 다이얼로그 처리
break;
case FileAction.OpenTerminal:
var dir = File.Exists(path) ? Path.GetDirectoryName(path) ?? path : path;
try { Process.Start("wt.exe", $"-d \"{dir}\""); }
catch (Exception)
{
try { Process.Start("cmd.exe", $"/k cd /d \"{dir}\""); }
catch (Exception ex) { LogService.Warn($"터미널 열기 실패: {ex.Message}"); }
}
break;
}
}
// ─── 단축키 지원 메서드 ──────────────────────────────────────────────────
/// <summary>선택된 항목의 경로를 클립보드에 복사</summary>
public bool CopySelectedPath()
{
if (SelectedItem?.Data is IndexEntry entry)
{
var path = Path.GetFileName(Environment.ExpandEnvironmentVariables(entry.Path));
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
return true;
}
return false;
}
/// <summary>선택된 항목의 전체 경로를 클립보드에 복사</summary>
public bool CopySelectedFullPath()
{
if (SelectedItem?.Data is IndexEntry entry)
{
var path = Environment.ExpandEnvironmentVariables(entry.Path);
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
return true;
}
return false;
}
/// <summary>선택된 항목을 탐색기에서 열기</summary>
public bool OpenSelectedInExplorer()
{
if (SelectedItem?.Data is IndexEntry entry)
{
var path = Environment.ExpandEnvironmentVariables(entry.Path);
if (File.Exists(path))
Process.Start("explorer.exe", $"/select,\"{path}\"");
else if (Directory.Exists(path))
Process.Start("explorer.exe", $"\"{path}\"");
return true;
}
return false;
}
/// <summary>선택된 항목을 관리자 권한으로 실행</summary>
public bool RunSelectedAsAdmin()
{
if (SelectedItem?.Data is IndexEntry entry)
{
var path = Environment.ExpandEnvironmentVariables(entry.Path);
try
{
Process.Start(new ProcessStartInfo(path)
{ UseShellExecute = true, Verb = "runas" });
return true;
}
catch (Exception ex) { LogService.Warn($"관리자 실행 취소: {ex.Message}"); }
}
return false;
}
/// <summary>선택된 항목의 속성 창 열기</summary>
public bool ShowSelectedProperties()
{
if (SelectedItem?.Data is IndexEntry entry)
{
var path = Environment.ExpandEnvironmentVariables(entry.Path);
try
{
Process.Start(new ProcessStartInfo(path)
{ UseShellExecute = true, Verb = "properties" });
return true;
}
catch (Exception)
{
// properties verb 미지원 시 탐색기에서 선택
Process.Start("explorer.exe", $"/select,\"{path}\"");
return true;
}
}
return false;
}
/// <summary>최근 기록에서 항목 삭제 (Delete 키용)</summary>
public bool RemoveSelectedFromRecent()
{
if (SelectedItem == null || Results.Count == 0) return false;
var idx = Results.IndexOf(SelectedItem);
Results.Remove(SelectedItem);
if (Results.Count > 0)
SelectedItem = Results[Math.Min(idx, Results.Count - 1)];
else
SelectedItem = null;
return true;
}
/// <summary>입력창 초기화</summary>
public void ClearInput()
{
InputText = "";
}
/// <summary>첫 번째 결과 항목 선택</summary>
public void SelectFirst()
{
if (Results.Count > 0) SelectedItem = Results[0];
}
/// <summary>마지막 결과 항목 선택</summary>
public void SelectLast()
{
if (Results.Count > 0) SelectedItem = Results[^1];
}
/// <summary>현재 선택된 파일/폴더 항목을 즐겨찾기에 추가하거나 제거합니다.</summary>
/// <returns>(추가됐으면 true, 제거됐으면 false, 대상 없으면 null)</returns>
public bool? ToggleFavorite()
{
if (SelectedItem?.Data is not IndexEntry entry) return null;
var path = Environment.ExpandEnvironmentVariables(entry.Path);
var name = Path.GetFileNameWithoutExtension(path);
if (string.IsNullOrWhiteSpace(name)) name = Path.GetFileName(path);
var favFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "favorites.json");
try
{
var opts = new System.Text.Json.JsonSerializerOptions
{ WriteIndented = true, PropertyNameCaseInsensitive = true };
List<FavJson> list = new();
if (File.Exists(favFile))
list = System.Text.Json.JsonSerializer.Deserialize<List<FavJson>>(
File.ReadAllText(favFile), opts) ?? new();
var existing = list.FirstOrDefault(f =>
f.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
list.Remove(existing);
Directory.CreateDirectory(Path.GetDirectoryName(favFile)!);
File.WriteAllText(favFile, System.Text.Json.JsonSerializer.Serialize(list, opts));
return false; // 제거됨
}
else
{
list.Insert(0, new FavJson { Name = name, Path = path });
Directory.CreateDirectory(Path.GetDirectoryName(favFile)!);
File.WriteAllText(favFile, System.Text.Json.JsonSerializer.Serialize(list, opts));
return true; // 추가됨
}
}
catch (Exception ex)
{
LogService.Warn($"즐겨찾기 토글 실패: {ex.Message}");
return null;
}
}
/// <summary>선택 항목의 디렉터리에서 터미널을 열기.</summary>
/// <returns>성공 여부</returns>
public bool OpenSelectedInTerminal()
{
string dir;
if (SelectedItem?.Data is IndexEntry entry)
{
var path = Environment.ExpandEnvironmentVariables(entry.Path);
dir = Directory.Exists(path) ? path : Path.GetDirectoryName(path) ?? path;
}
else
{
dir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
}
try { Process.Start("wt.exe", $"-d \"{dir}\""); return true; }
catch (Exception)
{
try { Process.Start("cmd.exe", $"/k cd /d \"{dir}\""); return true; }
catch (Exception ex) { LogService.Warn($"터미널 열기 실패: {ex.Message}"); return false; }
}
}
/// <summary>다운로드 폴더를 cd 프리픽스로 탐색합니다.</summary>
public void NavigateToDownloads()
{
var downloads = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
InputText = $"cd {downloads}";
}
// 즐겨찾기 직렬화용 내부 레코드
private sealed class FavJson
{
[System.Text.Json.Serialization.JsonPropertyName("name")]
public string Name { get; set; } = "";
[System.Text.Json.Serialization.JsonPropertyName("path")]
public string Path { get; set; } = "";
}
// ─── 클립보드 병합 ────────────────────────────────────────────────────────
public void ToggleMergeItem(LauncherItem? item)
{
if (item?.Data is not ClipboardEntry entry || !entry.IsText) return;
if (!_mergeQueue.Remove(entry))
_mergeQueue.Add(entry);
OnPropertyChanged(nameof(MergeCount));
OnPropertyChanged(nameof(ShowMergeHint));
OnPropertyChanged(nameof(MergeHintText));
}
/// <summary>선택된 항목들을 줄바꿈으로 합쳐 클립보드에 복사</summary>
public void ExecuteMerge()
{
if (_mergeQueue.Count == 0) return;
// 선택 순서 보존: Results에서 보이는 순서 기준
var ordered = Results
.Where(r => r.Data is ClipboardEntry e && _mergeQueue.Contains(e))
.Select(r => ((ClipboardEntry)r.Data!).Text)
.ToList();
if (ordered.Count == 0)
ordered = _mergeQueue.Select(e => e.Text).ToList();
var merged = string.Join("\n", ordered);
try { Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(merged)); }
catch (Exception ex) { LogService.Warn($"병합 클립보드 실패: {ex.Message}"); }
ClearMerge();
CloseRequested?.Invoke(this, EventArgs.Empty);
LogService.Info($"클립보드 병합: {ordered.Count}개 항목");
}
public void ClearMerge()
{
_mergeQueue.Clear();
OnPropertyChanged(nameof(MergeCount));
OnPropertyChanged(nameof(ShowMergeHint));
OnPropertyChanged(nameof(MergeHintText));
}
// ─── INotifyPropertyChanged ───────────────────────────────────────────────
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
// ─── 파일 액션 데이터 타입 ────────────────────────────────────────────────────
public enum FileAction { CopyPath, CopyFullPath, OpenExplorer, RunAsAdmin, OpenTerminal, ShowProperties, Rename, DeleteToRecycleBin }
public record FileActionData(string Path, FileAction Action);

View File

@@ -0,0 +1,134 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
namespace AxCopilot.ViewModels;
/// <summary>Phase 17-G: 단일 파일의 diff 정보를 담는 ViewModel.</summary>
public class FileDiffViewModel : INotifyPropertyChanged
{
private string _filePath = "";
private string _originalContent = "";
private string _newContent = "";
private bool _isSelected;
private AxCopilot.Services.Agent.ToolRiskLevel _riskLevel;
public string FilePath
{
get => _filePath;
set { _filePath = value; OnPropertyChanged(); OnPropertyChanged(nameof(FileName)); }
}
public string FileName => System.IO.Path.GetFileName(FilePath);
public string OriginalContent
{
get => _originalContent;
set { _originalContent = value; OnPropertyChanged(); OnPropertyChanged(nameof(DiffSummary)); }
}
public string NewContent
{
get => _newContent;
set { _newContent = value; OnPropertyChanged(); OnPropertyChanged(nameof(DiffSummary)); }
}
public bool IsSelected
{
get => _isSelected;
set { _isSelected = value; OnPropertyChanged(); }
}
public AxCopilot.Services.Agent.ToolRiskLevel RiskLevel
{
get => _riskLevel;
set { _riskLevel = value; OnPropertyChanged(); OnPropertyChanged(nameof(RiskLabel)); }
}
public string RiskLabel => AxCopilot.Services.Agent.ToolRiskMapper.GetRiskLabel(_riskLevel);
/// <summary>변경 줄 수 요약.</summary>
public string DiffSummary
{
get
{
var origLines = OriginalContent.Split('\n');
var newLines = NewContent.Split('\n');
var added = newLines.Length - origLines.Length;
return added >= 0 ? $"+{added}줄" : $"{added}줄";
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
/// <summary>Phase 17-G: 여러 파일의 diff를 관리하는 ViewModel (AgentDiffPanel용).</summary>
public class MultiFileDiffViewModel : INotifyPropertyChanged
{
public ObservableCollection<FileDiffViewModel> Files { get; } = new();
private FileDiffViewModel? _selectedFile;
public FileDiffViewModel? SelectedFile
{
get => _selectedFile;
set { _selectedFile = value; OnPropertyChanged(); }
}
private bool _isVisible;
public bool IsVisible
{
get => _isVisible;
set { _isVisible = value; OnPropertyChanged(); }
}
/// <summary>에이전트 세션 중 파일 변경을 추적하여 추가합니다.</summary>
public void TrackFileChange(string filePath, string originalContent, string newContent, string toolName)
{
var existing = Files.FirstOrDefault(f =>
string.Equals(f.FilePath, filePath, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
existing.NewContent = newContent;
}
else
{
var vm = new FileDiffViewModel
{
FilePath = filePath,
OriginalContent = originalContent,
NewContent = newContent,
RiskLevel = AxCopilot.Services.Agent.ToolRiskMapper.GetRisk(toolName),
IsSelected = Files.Count == 0 // auto-select first
};
Files.Add(vm);
if (Files.Count == 1) SelectedFile = vm;
}
IsVisible = Files.Count > 0;
}
/// <summary>세션 종료 시 diff 목록을 초기화합니다.</summary>
public void Clear()
{
Files.Clear();
SelectedFile = null;
IsVisible = false;
}
/// <summary>선택된 파일 변경을 승인합니다 (파일을 실제로 수정하지는 않음 — 이미 적용됨).</summary>
public void AcceptSelected()
{
if (SelectedFile != null)
Files.Remove(SelectedFile);
SelectedFile = Files.FirstOrDefault();
}
/// <summary>모든 변경 승인.</summary>
public void AcceptAll() => Clear();
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

View File

@@ -0,0 +1,155 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using AxCopilot.Services.Agent;
namespace AxCopilot.ViewModels;
/// <summary>설치된 플러그인 한 항목의 ViewModel.</summary>
public class PluginItemViewModel : INotifyPropertyChanged
{
private bool _isEnabled;
public InstalledPlugin Plugin { get; }
public string Id => Plugin.Manifest.Id;
public string Name => Plugin.Manifest.Name;
public string Version => Plugin.Manifest.Version;
public string Description => Plugin.Manifest.Description;
public string Author => Plugin.Manifest.Author;
public string Type => Plugin.Manifest.Type;
public string InstalledAt => Plugin.InstalledAt.ToLocalTime().ToString("yyyy-MM-dd");
public bool IsEnabled
{
get => _isEnabled;
set { _isEnabled = value; OnPropertyChanged(); }
}
public PluginItemViewModel(InstalledPlugin plugin)
{
Plugin = plugin;
_isEnabled = plugin.IsEnabled;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
/// <summary>
/// Phase L3-1: 플러그인 갤러리 ViewModel.
/// SettingsWindow 또는 별도 플러그인 관리 화면에서 바인딩합니다.
/// </summary>
public class PluginGalleryViewModel : INotifyPropertyChanged
{
private readonly PluginInstallService _service;
private PluginItemViewModel? _selectedPlugin;
private string _statusMessage = "";
private bool _isBusy;
public ObservableCollection<PluginItemViewModel> Plugins { get; } = new();
public PluginItemViewModel? SelectedPlugin
{
get => _selectedPlugin;
set { _selectedPlugin = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasSelection)); }
}
public bool HasSelection => _selectedPlugin != null;
public string StatusMessage
{
get => _statusMessage;
private set { _statusMessage = value; OnPropertyChanged(); }
}
public bool IsBusy
{
get => _isBusy;
private set { _isBusy = value; OnPropertyChanged(); }
}
// ─── Commands ───────────────────────────────────────────────────────────
public ICommand InstallFromZipCommand { get; }
public ICommand UninstallCommand { get; }
public ICommand ToggleEnabledCommand { get; }
public ICommand RefreshCommand { get; }
public PluginGalleryViewModel(PluginInstallService service)
{
_service = service;
InstallFromZipCommand = new RelayCommand<string>(async zipPath =>
{
if (string.IsNullOrWhiteSpace(zipPath)) return;
IsBusy = true;
StatusMessage = "설치 중…";
var result = await _service.InstallFromZipAsync(zipPath);
StatusMessage = result.Message;
if (result.Success) Refresh();
IsBusy = false;
});
UninstallCommand = new RelayCommand<string>(async pluginId =>
{
if (string.IsNullOrWhiteSpace(pluginId)) return;
IsBusy = true;
StatusMessage = "제거 중…";
var ok = await _service.UninstallAsync(pluginId);
StatusMessage = ok ? "제거 완료." : "제거 실패.";
if (ok) Refresh();
IsBusy = false;
});
ToggleEnabledCommand = new RelayCommand<PluginItemViewModel>(async item =>
{
if (item == null) return;
await _service.SetEnabledAsync(item.Id, item.IsEnabled);
StatusMessage = $"'{item.Name}' {(item.IsEnabled ? "" : "")} 완료.";
});
RefreshCommand = new RelayCommand<object>(_ => { Refresh(); return Task.CompletedTask; })!;
Refresh();
}
/// <summary>서비스에서 설치 목록을 다시 로드합니다.</summary>
public void Refresh()
{
Plugins.Clear();
foreach (var p in _service.Installed)
Plugins.Add(new PluginItemViewModel(p));
StatusMessage = Plugins.Count == 0 ? "설치된 플러그인이 없습니다." : $"{Plugins.Count}개 설치됨";
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
// ─── 간단한 RelayCommand 구현 (WPF MVVM) ────────────────────────────────────
file sealed class RelayCommand<T> : ICommand
{
private readonly Func<T?, Task> _execute;
private bool _running;
public RelayCommand(Func<T?, Task> execute) => _execute = execute;
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? _) => !_running;
public async void Execute(object? parameter)
{
_running = true;
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
try { await _execute(parameter is T t ? t : default); }
finally
{
_running = false;
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
}

View File

@@ -0,0 +1,153 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using AxCopilot.Services.Agent;
namespace AxCopilot.ViewModels;
/// <summary>Phase 18-B: 에이전트 이벤트 타임라인 재생 ViewModel.</summary>
public class ReplayTimelineViewModel : INotifyPropertyChanged
{
private readonly AgentReplayService _replayService;
private CancellationTokenSource? _replayCts;
public ObservableCollection<ReplayEventItem> Events { get; } = new();
public ObservableCollection<ReplaySessionInfo> Sessions { get; } = new();
private ReplaySessionInfo? _selectedSession;
public ReplaySessionInfo? SelectedSession
{
get => _selectedSession;
set { _selectedSession = value; OnPropertyChanged(); OnPropertyChanged(nameof(CanReplay)); }
}
private bool _isReplaying;
public bool IsReplaying
{
get => _isReplaying;
set { _isReplaying = value; OnPropertyChanged(); OnPropertyChanged(nameof(CanReplay)); }
}
public bool CanReplay => SelectedSession != null && !IsReplaying;
private int _replaySpeedMs = 200;
public int ReplaySpeedMs
{
get => _replaySpeedMs;
set { _replaySpeedMs = Math.Max(0, value); OnPropertyChanged(); }
}
private int _progressValue;
public int ProgressValue
{
get => _progressValue;
set { _progressValue = value; OnPropertyChanged(); }
}
private int _progressMax = 100;
public int ProgressMax
{
get => _progressMax;
set { _progressMax = value; OnPropertyChanged(); }
}
public ReplayTimelineViewModel()
{
_replayService = new AgentReplayService();
}
public void LoadSessions()
{
Sessions.Clear();
foreach (var s in _replayService.GetSessions())
Sessions.Add(s);
if (Sessions.Count > 0)
SelectedSession = Sessions[0];
}
public async Task StartReplayAsync()
{
if (SelectedSession == null || IsReplaying) return;
Events.Clear();
IsReplaying = true;
ProgressValue = 0;
_replayCts = new CancellationTokenSource();
try
{
// 전체 이벤트 수 먼저 파악
var allEvents = await _replayService.LoadAllEventsAsync(
SelectedSession.SessionId, _replayCts.Token);
ProgressMax = Math.Max(1, allEvents.Count);
int i = 0;
await _replayService.ReplayAsync(
SelectedSession.SessionId,
async record =>
{
var item = new ReplayEventItem(record);
// Dispatcher needed — raise event for UI thread
ReplayEventReceived?.Invoke(item);
i++;
ProgressValue = i;
await Task.CompletedTask;
},
ReplaySpeedMs,
_replayCts.Token);
}
catch (OperationCanceledException) { }
finally
{
IsReplaying = false;
_replayCts?.Dispose();
_replayCts = null;
}
}
public void StopReplay()
{
_replayCts?.Cancel();
}
/// <summary>UI 스레드에서 이벤트 항목을 추가하기 위한 이벤트.</summary>
public event Action<ReplayEventItem>? ReplayEventReceived;
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
/// <summary>타임라인에 표시할 단일 이벤트 항목.</summary>
public class ReplayEventItem
{
public AgentEventRecord Record { get; }
public string TypeLabel { get; }
public string Icon { get; }
public string TimeLabel { get; }
public ReplayEventItem(AgentEventRecord record)
{
Record = record;
TimeLabel = record.Timestamp.ToString("HH:mm:ss");
(TypeLabel, Icon) = record.Type switch
{
AgentEventLogType.SessionStart => ("세션 시작", "\uE768"),
AgentEventLogType.SessionEnd => ("세션 종료", "\uE711"),
AgentEventLogType.UserMessage => ("사용자 메시지", "\uE8BD"),
AgentEventLogType.AssistantMessage => ("AI 응답", "\uE8D4"),
AgentEventLogType.ToolRequest => ("도구 요청", "\uE756"),
AgentEventLogType.ToolResult => ("도구 결과", "\uE73E"),
AgentEventLogType.HookFired => ("훅 실행", "\uE81C"),
AgentEventLogType.HookResult => ("훅 결과", "\uE73E"),
AgentEventLogType.SkillActivated => ("스킬 활성화", "\uE82D"),
AgentEventLogType.SkillCompleted => ("스킬 완료", "\uE930"),
AgentEventLogType.CompactionTriggered => ("컨텍스트 압축", "\uE8EC"),
AgentEventLogType.CompactionCompleted => ("압축 완료", "\uE930"),
AgentEventLogType.SubagentSpawned => ("서브에이전트 생성", "\uE718"),
AgentEventLogType.SubagentCompleted => ("서브에이전트 완료", "\uE930"),
AgentEventLogType.ReflexionSaved => ("반성 저장", "\uE90F"),
AgentEventLogType.Error => ("오류", "\uE783"),
_ => (record.Type.ToString(), "\uE946")
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,492 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Media;
using AxCopilot.Models;
using AxCopilot.Views;
using AxCopilot.Services;
namespace AxCopilot.ViewModels;
/// <summary>하루 사용 통계를 막대 차트 아이템으로 표현</summary>
public class DayBarItem
{
public string DateLabel { get; init; } = ""; // "3/26" 형태
public string DayLabel { get; init; } = ""; // "월" 형태
public int Value { get; init; }
public double BarHeight { get; init; } // 0~66 (픽셀)
public string ValueLabel { get; init; } = "";
public string ToolTipText { get; init; } = ""; // 마우스 호버 시 표시
public bool IsToday { get; init; }
public bool HasData => Value > 0;
}
/// <summary>인기 명령어 순위 아이템</summary>
public class CommandStatItem
{
public int Rank { get; init; }
public string Command { get; init; } = "";
public int Count { get; init; }
public double BarWidth { get; init; } // 0~200 (픽셀)
}
/// <summary>즐겨찾기 항목 (통계 화면에 표시)</summary>
public class FavoriteStatItem
{
public int Rank { get; init; }
public string Name { get; init; } = "";
public string Path { get; init; } = "";
public string Icon { get; init; } = "\uE8B7"; // 기본: Folder
public bool Exists { get; init; }
}
/// <summary>탭별 대화 비율 아이템</summary>
public class TabRatioItem
{
public string TabName { get; init; } = "";
public int Count { get; init; }
public double Percentage { get; init; } // 0~100
public double BarWidth { get; init; } // 0~300 (픽셀)
public string PercentLabel { get; init; } = "";
public Brush Color { get; init; } = Brushes.MediumSlateBlue;
}
public class StatisticsViewModel : INotifyPropertyChanged
{
private const double MaxBarHeight = 66.0;
private const double MaxBarWidth = 200.0;
// ─── 차트 데이터 ─────────────────────────────────────────────────────────
public ObservableCollection<DayBarItem> LauncherOpenBars { get; } = new();
public ObservableCollection<DayBarItem> ActiveTimeBars { get; } = new();
public ObservableCollection<CommandStatItem> TopCommands { get; } = new();
public ObservableCollection<FavoriteStatItem> TopFavorites { get; } = new();
// AX Agent 통계
public ObservableCollection<DayBarItem> ChatCountBars { get; } = new(); // 대화 빈도
public ObservableCollection<DayBarItem> TokenUsageBars { get; } = new(); // 토큰 사용량
// 추가 차트
public ObservableCollection<DayBarItem> WeekdayAvgBars { get; } = new(); // 요일별 평균 호출
public ObservableCollection<TabRatioItem> TabChatRatios { get; } = new(); // 탭별 대화 비율
private bool _hasFavorites;
public bool HasFavorites { get => _hasFavorites; private set { _hasFavorites = value; OnPropertyChanged(); } }
// ─── 요약 카드 ───────────────────────────────────────────────────────────
private string _todayDate = "";
private string _todaySummary = "";
private string _peakDate = "";
private string _peakDaySummary = "";
private string _weekSummary = "";
private string _totalOpensSummary = "";
public string TodayDate { get => _todayDate; set { _todayDate = value; OnPropertyChanged(); } }
public string TodaySummary { get => _todaySummary; set { _todaySummary = value; OnPropertyChanged(); } }
public string PeakDate { get => _peakDate; set { _peakDate = value; OnPropertyChanged(); } }
public string PeakDaySummary { get => _peakDaySummary; set { _peakDaySummary = value; OnPropertyChanged(); } }
public string WeekSummary { get => _weekSummary; set { _weekSummary = value; OnPropertyChanged(); } }
public string TotalOpensSummary { get => _totalOpensSummary; set { _totalOpensSummary = value; OnPropertyChanged(); } }
private string _agentSummary = "";
public string AgentSummary { get => _agentSummary; set { _agentSummary = value; OnPropertyChanged(); } }
// 토큰 비율
private double _promptBarWidth;
public double PromptBarWidth { get => _promptBarWidth; set { _promptBarWidth = value; OnPropertyChanged(); } }
private double _completionBarWidth;
public double CompletionBarWidth { get => _completionBarWidth; set { _completionBarWidth = value; OnPropertyChanged(); } }
private string _promptTokenLabel = "";
public string PromptTokenLabel { get => _promptTokenLabel; set { _promptTokenLabel = value; OnPropertyChanged(); } }
private string _completionTokenLabel = "";
public string CompletionTokenLabel { get => _completionTokenLabel; set { _completionTokenLabel = value; OnPropertyChanged(); } }
private string _tokenRatioSummary = "";
public string TokenRatioSummary { get => _tokenRatioSummary; set { _tokenRatioSummary = value; OnPropertyChanged(); } }
public StatisticsViewModel()
{
Refresh();
}
public void Refresh()
{
var stats = UsageStatisticsService.GetStats(30);
var today = DateTime.Today.ToString("yyyy-MM-dd");
BuildLauncherOpenChart(stats, today);
BuildActiveTimeChart(stats, today);
BuildChatCountChart(stats, today);
BuildTokenUsageChart(stats, today);
BuildTopCommands(stats);
BuildWeekdayAvgChart(stats);
BuildTabChatRatios(stats);
BuildTokenRatio(stats);
BuildSummaryCards(stats, today);
BuildTopFavorites();
}
// ─── 차트 빌더 ───────────────────────────────────────────────────────────
private void BuildLauncherOpenChart(List<DailyUsageStats> stats, string today)
{
// 최근 14일만 표시
var recent = stats.TakeLast(14).ToList();
int maxVal = recent.Max(s => s.LauncherOpens);
if (maxVal == 0) maxVal = 1;
LauncherOpenBars.Clear();
foreach (var s in recent)
{
var date = DateTime.TryParse(s.Date, out var d) ? d : DateTime.MinValue;
LauncherOpenBars.Add(new DayBarItem
{
DateLabel = date != DateTime.MinValue ? $"{date.Month}/{date.Day}" : "",
DayLabel = date != DateTime.MinValue ? GetKoreanDayOfWeek(date.DayOfWeek) : "",
Value = s.LauncherOpens,
BarHeight = MaxBarHeight * s.LauncherOpens / maxVal,
ValueLabel = s.LauncherOpens > 0 ? s.LauncherOpens.ToString() : "",
ToolTipText = date != DateTime.MinValue
? $"{date:yyyy-MM-dd} ({GetKoreanDayOfWeek(date.DayOfWeek)})\n호출 {s.LauncherOpens}회"
: "",
IsToday = s.Date == today
});
}
}
private void BuildActiveTimeChart(List<DailyUsageStats> stats, string today)
{
var recent = stats.TakeLast(14).ToList();
int maxVal = recent.Max(s => s.ActiveSeconds);
if (maxVal == 0) maxVal = 1;
ActiveTimeBars.Clear();
foreach (var s in recent)
{
var date = DateTime.TryParse(s.Date, out var d) ? d : DateTime.MinValue;
ActiveTimeBars.Add(new DayBarItem
{
DateLabel = date != DateTime.MinValue ? $"{date.Month}/{date.Day}" : "",
DayLabel = date != DateTime.MinValue ? GetKoreanDayOfWeek(date.DayOfWeek) : "",
Value = s.ActiveSeconds,
BarHeight = MaxBarHeight * s.ActiveSeconds / maxVal,
ValueLabel = FormatActiveTime(s.ActiveSeconds),
ToolTipText = date != DateTime.MinValue
? $"{date:yyyy-MM-dd} ({GetKoreanDayOfWeek(date.DayOfWeek)})\n활성 {FormatActiveTime(s.ActiveSeconds)}"
: "",
IsToday = s.Date == today
});
}
}
private void BuildChatCountChart(List<DailyUsageStats> stats, string today)
{
var recent = stats.TakeLast(14).ToList();
// 탭별 합산
int maxVal = recent.Max(s =>
{
int sum = 0;
foreach (var kv in s.ChatCounts) sum += kv.Value;
return sum;
});
if (maxVal == 0) maxVal = 1;
ChatCountBars.Clear();
foreach (var s in recent)
{
var dt = DateTime.TryParse(s.Date, out var d) ? d : DateTime.MinValue;
int chat = s.ChatCounts.GetValueOrDefault("Chat");
int cowork = s.ChatCounts.GetValueOrDefault("Cowork");
int code = s.ChatCounts.GetValueOrDefault("Code");
int total = chat + cowork + code;
var isToday = s.Date == today;
ChatCountBars.Add(new DayBarItem
{
DateLabel = dt.ToString("M/d"),
DayLabel = dt.ToString("ddd")[..1],
Value = total,
BarHeight = total > 0 ? total * MaxBarHeight / maxVal : 2,
ValueLabel = total > 0 ? total.ToString() : "",
ToolTipText = $"{s.Date} ({dt:ddd})\nChat: {chat}회 · Cowork: {cowork}회 · Code: {code}회",
IsToday = isToday,
});
}
}
private void BuildTokenUsageChart(List<DailyUsageStats> stats, string today)
{
var recent = stats.TakeLast(14).ToList();
long maxVal = recent.Max(s => s.TotalTokens);
if (maxVal == 0) maxVal = 1;
TokenUsageBars.Clear();
foreach (var s in recent)
{
var dt = DateTime.TryParse(s.Date, out var d) ? d : DateTime.MinValue;
var isToday = s.Date == today;
var tokens = s.TotalTokens;
TokenUsageBars.Add(new DayBarItem
{
DateLabel = dt.ToString("M/d"),
DayLabel = dt.ToString("ddd")[..1],
Value = (int)Math.Min(tokens, int.MaxValue),
BarHeight = tokens > 0 ? (double)tokens * MaxBarHeight / maxVal : 2,
ValueLabel = tokens > 0 ? FormatTokens(tokens) : "",
ToolTipText = $"{s.Date} ({dt:ddd})\n프롬프트: {s.PromptTokens:N0} · 완료: {s.CompletionTokens:N0} · 합계: {s.TotalTokens:N0}",
IsToday = isToday,
});
}
}
private static string FormatTokens(long tokens) => tokens switch
{
>= 1_000_000 => $"{tokens / 1_000_000.0:F1}M",
>= 1_000 => $"{tokens / 1_000.0:F1}K",
_ => tokens.ToString(),
};
private void BuildWeekdayAvgChart(List<DailyUsageStats> stats)
{
// 요일별 평균 호출 (30일 데이터 → 요일 그룹)
var groups = new Dictionary<DayOfWeek, List<int>>();
foreach (DayOfWeek dow in Enum.GetValues<DayOfWeek>())
groups[dow] = new();
foreach (var s in stats)
{
if (DateTime.TryParse(s.Date, out var d))
groups[d.DayOfWeek].Add(s.LauncherOpens);
}
// 월~일 순서
var ordered = new[] { DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday,
DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday, DayOfWeek.Sunday };
double maxAvg = ordered.Max(dow => groups[dow].Count > 0 ? groups[dow].Average() : 0);
if (maxAvg < 1) maxAvg = 1;
WeekdayAvgBars.Clear();
foreach (var dow in ordered)
{
var list = groups[dow];
var avg = list.Count > 0 ? list.Average() : 0;
WeekdayAvgBars.Add(new DayBarItem
{
DateLabel = "",
DayLabel = GetKoreanDayOfWeek(dow),
Value = (int)Math.Round(avg),
BarHeight = avg > 0 ? avg * MaxBarHeight / maxAvg : 2,
ValueLabel = avg > 0 ? avg.ToString("F1") : "",
ToolTipText = $"{GetKoreanDayOfWeek(dow)}요일 평균 {avg:F1}회 ({list.Count}일 데이터)",
IsToday = DateTime.Today.DayOfWeek == dow,
});
}
}
private void BuildTabChatRatios(List<DailyUsageStats> stats)
{
// 탭별 대화 비율 (30일 합계)
int chat = 0, cowork = 0, code = 0;
foreach (var s in stats)
{
chat += s.ChatCounts.GetValueOrDefault("Chat");
cowork += s.ChatCounts.GetValueOrDefault("Cowork");
code += s.ChatCounts.GetValueOrDefault("Code");
}
int total = chat + cowork + code;
if (total == 0) total = 1;
const double MaxWidth = 300.0;
TabChatRatios.Clear();
TabChatRatios.Add(new TabRatioItem
{
TabName = "Chat", Count = chat,
Percentage = 100.0 * chat / total,
BarWidth = MaxWidth * chat / total,
PercentLabel = $"{100.0 * chat / total:F0}% ({chat}회)",
Color = ThemeResourceHelper.HexBrush("#818CF8")
});
TabChatRatios.Add(new TabRatioItem
{
TabName = "Cowork", Count = cowork,
Percentage = 100.0 * cowork / total,
BarWidth = MaxWidth * cowork / total,
PercentLabel = $"{100.0 * cowork / total:F0}% ({cowork}회)",
Color = ThemeResourceHelper.HexBrush("#10B981")
});
TabChatRatios.Add(new TabRatioItem
{
TabName = "Code", Count = code,
Percentage = 100.0 * code / total,
BarWidth = MaxWidth * code / total,
PercentLabel = $"{100.0 * code / total:F0}% ({code}회)",
Color = ThemeResourceHelper.HexBrush("#F59E0B")
});
}
private void BuildTokenRatio(List<DailyUsageStats> stats)
{
long prompt = stats.Sum(s => s.PromptTokens);
long completion = stats.Sum(s => s.CompletionTokens);
long total = prompt + completion;
if (total == 0) total = 1;
const double TotalWidth = 400.0;
PromptBarWidth = TotalWidth * prompt / total;
CompletionBarWidth = TotalWidth * completion / total;
PromptTokenLabel = $"입력 {FormatTokens(prompt)} ({100.0 * prompt / total:F0}%)";
CompletionTokenLabel = $"출력 {FormatTokens(completion)} ({100.0 * completion / total:F0}%)";
TokenRatioSummary = $"30일 합계 입력 {FormatTokens(prompt)} + 출력 {FormatTokens(completion)} = {FormatTokens(prompt + completion)}";
}
private void BuildTopCommands(List<DailyUsageStats> stats)
{
// 30일 집계
var aggregate = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var s in stats)
foreach (var (key, count) in s.CommandUsage)
{
aggregate.TryGetValue(key, out var cur);
aggregate[key] = cur + count;
}
var top = aggregate
.OrderByDescending(kv => kv.Value)
.Take(10)
.ToList();
int maxCount = top.Count > 0 ? top[0].Value : 1;
TopCommands.Clear();
for (int i = 0; i < top.Count; i++)
{
TopCommands.Add(new CommandStatItem
{
Rank = i + 1,
Command = top[i].Key,
Count = top[i].Value,
BarWidth = MaxBarWidth * top[i].Value / maxCount
});
}
}
private void BuildSummaryCards(List<DailyUsageStats> stats, string today)
{
// 오늘 날짜 표시
TodayDate = DateTime.Today.ToString("yyyy년 M월 d일");
// 오늘 통계
var todayStat = stats.FirstOrDefault(s => s.Date == today);
TodaySummary = todayStat != null
? $"{todayStat.LauncherOpens}회 호출 · {FormatActiveTime(todayStat.ActiveSeconds)}"
: "데이터 없음";
// 역대 최고 호출일
var peakDay = stats.OrderByDescending(s => s.LauncherOpens).FirstOrDefault();
if (peakDay != null && peakDay.LauncherOpens > 0)
{
PeakDate = DateTime.TryParse(peakDay.Date, out var pd)
? pd.ToString("yyyy년 M월 d일")
: peakDay.Date;
PeakDaySummary = $"{peakDay.LauncherOpens}회 호출";
}
else
{
PeakDate = "";
PeakDaySummary = "기록 없음";
}
// 이번 주 합계
var weekStart = DateTime.Today.AddDays(-(int)DateTime.Today.DayOfWeek);
var weekTotal = stats
.Where(s => DateTime.TryParse(s.Date, out var d) && d >= weekStart)
.Sum(s => s.LauncherOpens);
WeekSummary = $"이번 주 {weekTotal}회";
// 30일 총 호출
var totalOpens = stats.Sum(s => s.LauncherOpens);
TotalOpensSummary = $"30일 합계 {totalOpens}회";
// AX Agent 요약
var totalChats = stats.Sum(s => s.ChatCounts.Values.Sum());
var totalTokens = stats.Sum(s => s.TotalTokens);
var todayChats = todayStat?.ChatCounts.Values.Sum() ?? 0;
var todayTokens = todayStat?.TotalTokens ?? 0;
AgentSummary = $"오늘 {todayChats}회 대화 · {FormatTokens(todayTokens)} 토큰 | 30일 합계 {totalChats}회 · {FormatTokens(totalTokens)} 토큰";
}
private void BuildTopFavorites()
{
TopFavorites.Clear();
var favFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "favorites.json");
if (!File.Exists(favFile)) { HasFavorites = false; return; }
try
{
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var list = JsonSerializer.Deserialize<List<FavFileEntry>>(File.ReadAllText(favFile), opts) ?? new();
var top = list.Take(6).ToList();
if (top.Count == 0) { HasFavorites = false; return; }
for (int i = 0; i < top.Count; i++)
{
var entry = top[i];
var expanded = Environment.ExpandEnvironmentVariables(entry.Path);
var isDir = Directory.Exists(expanded);
var exists = isDir || File.Exists(expanded);
TopFavorites.Add(new FavoriteStatItem
{
Rank = i + 1,
Name = entry.Name,
Path = entry.Path,
Icon = isDir ? "\uE8B7" : "\uE8A5", // Folder / Page
Exists = exists
});
}
HasFavorites = TopFavorites.Count > 0;
}
catch (Exception) { HasFavorites = false; }
}
// favorites.json 역직렬화용 내부 레코드
private class FavFileEntry
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("path")] public string Path { get; set; } = "";
}
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
private static string GetKoreanDayOfWeek(DayOfWeek dow) => dow switch
{
DayOfWeek.Monday => "월",
DayOfWeek.Tuesday => "화",
DayOfWeek.Wednesday => "수",
DayOfWeek.Thursday => "목",
DayOfWeek.Friday => "금",
DayOfWeek.Saturday => "토",
_ => "일"
};
private static string FormatActiveTime(int totalSeconds)
{
if (totalSeconds <= 0) return "";
int h = totalSeconds / 3600;
int m = (totalSeconds % 3600) / 60;
if (h > 0 && m > 0) return $"{h}시간 {m}분";
if (h > 0) return $"{h}시간";
if (m > 0) return $"{m}분";
return $"{totalSeconds}초";
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}