Initial commit to new repository
This commit is contained in:
803
src/AxCopilot/ViewModels/LauncherViewModel.cs
Normal file
803
src/AxCopilot/ViewModels/LauncherViewModel.cs
Normal file
@@ -0,0 +1,803 @@
|
||||
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 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() =>
|
||||
(System.Windows.Application.Current as App)?.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 { /* 일부 파일 형식에서 지원 안됨 */ }
|
||||
}
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
// 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
|
||||
{
|
||||
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);
|
||||
1929
src/AxCopilot/ViewModels/SettingsViewModel.cs
Normal file
1929
src/AxCopilot/ViewModels/SettingsViewModel.cs
Normal file
File diff suppressed because it is too large
Load Diff
491
src/AxCopilot/ViewModels/StatisticsViewModel.cs
Normal file
491
src/AxCopilot/ViewModels/StatisticsViewModel.cs
Normal file
@@ -0,0 +1,491 @@
|
||||
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.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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#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 { 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));
|
||||
}
|
||||
Reference in New Issue
Block a user