using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Input.Platform; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ClaudeDo.Ui.ViewModels.Modals; /// /// Log Visualizer overlay — shows the worker's last 30 min of log records (all levels), /// fetched once on open via with a manual /// Refresh and a "warnings & errors only" filter. /// public sealed partial class LogVisualizerViewModel : ViewModelBase { private const int CopyLastCount = 40; private readonly IWorkerClient _worker; private readonly Func> _copyToClipboard; private IReadOnlyList _all = Array.Empty(); public ObservableCollection Rows { get; } = new(); [ObservableProperty] private bool _warnErrorOnly; [ObservableProperty] private string _statusText = ""; public Action? CloseAction { get; set; } public LogVisualizerViewModel(IWorkerClient worker) : this(worker, CopyToClipboardAsync) { } internal LogVisualizerViewModel(IWorkerClient worker, Func> copyToClipboard) { _worker = worker; _copyToClipboard = copyToClipboard; Rows.CollectionChanged += (_, _) => CopyLastCommand.NotifyCanExecuteChanged(); // Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reads // through the log), so refresh it on the same cadence as TasksIslandViewModel's tick // instead of leaving it frozen at the moment it was opened. _reconcileTimer.Elapsed += (_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = RefreshAsync()); _reconcileTimer.Start(); } private readonly System.Timers.Timer _reconcileTimer = new(4_000); [RelayCommand] public async Task RefreshAsync() { var latest = await _worker.GetRecentLogsAsync(); Reconcile(latest); } partial void OnWarnErrorOnlyChanged(bool value) => Apply(); private void Apply() { Rows.Clear(); IEnumerable items = WarnErrorOnly ? _all.Where(e => e.Level is WorkerLogLevel.Warn or WorkerLogLevel.Error) : _all; foreach (var e in items.Reverse()) Rows.Add(new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level)); UpdateStatusText(); } // `_all` (oldest-first) mirrors the worker's LogRingBuffer: append-mostly, with the oldest // entries falling off the front once they age past the 30-min window. A full Rows.Clear() // on every 4s tick would reset the user's scroll position/selection while they're mid-read, // so patch just the delta instead: entries still present keep their row, aged-out entries // are trimmed off the (oldest) tail of Rows, new entries are inserted at the (newest) head. private void Reconcile(IReadOnlyList latest) { var previous = _all; _all = latest; if (previous.Count == 0) { Apply(); return; } var overlap = FindOverlap(previous, latest); var evictedCount = previous.Count - overlap; var added = latest.Skip(overlap).ToList(); if (evictedCount == 0 && added.Count == 0) return; // nothing aged out, nothing new — leave Rows (and scroll/selection) untouched if (evictedCount > 0) { var removeCount = WarnErrorOnly ? previous.Take(evictedCount).Count(e => e.Level is WorkerLogLevel.Warn or WorkerLogLevel.Error) : evictedCount; for (var i = 0; i < removeCount; i++) Rows.RemoveAt(Rows.Count - 1); } foreach (var e in added) { if (WarnErrorOnly && e.Level is not (WorkerLogLevel.Warn or WorkerLogLevel.Error)) continue; Rows.Insert(0, new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level)); } UpdateStatusText(); } // The longest run of `previous`'s tail that reappears as `latest`'s head — i.e. how much of // the old snapshot survived unevicted. 0 when nothing overlaps (e.g. right after opening). private static int FindOverlap(IReadOnlyList previous, IReadOnlyList latest) { for (var k = Math.Min(previous.Count, latest.Count); k > 0; k--) { var matches = true; for (var i = 0; i < k; i++) { if (!previous[previous.Count - k + i].Equals(latest[i])) { matches = false; break; } } if (matches) return k; } return 0; } private void UpdateStatusText() => StatusText = Rows.Count == 0 ? Loc.T("modals.logVisualizer.empty") : Loc.T("modals.logVisualizer.count", Rows.Count); [RelayCommand] private void Close() { StopReconcileTick(); CloseAction?.Invoke(); } /// Stops the reconcile tick. Also called from the dialog's native-close fallback /// (WindowDialogService), which bypasses — otherwise an Alt+F4 /// leaves the timer polling the worker on an orphaned VM. Idempotent. internal void StopReconcileTick() { _reconcileTimer.Stop(); _reconcileTimer.Dispose(); } private bool CanCopyLast() => Rows.Count > 0; [RelayCommand(CanExecute = nameof(CanCopyLast))] private async Task CopyLastAsync() { var count = Math.Min(Rows.Count, CopyLastCount); var ok = await _copyToClipboard(BuildCopyText()); StatusText = ok ? Loc.T("modals.logVisualizer.copied", count) : Loc.T("modals.logVisualizer.copyFailed"); } internal string BuildCopyText() => string.Join('\n', Rows.Take(CopyLastCount).Reverse() .Select(r => $"{r.Time} {r.Level.ToString().ToUpperInvariant()} {r.Message}")); private static async Task CopyToClipboardAsync(string text) { if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow?.Clipboard is { } clipboard) { try { await clipboard.SetTextAsync(text); return true; } catch { return false; } } return false; } } public sealed record LogVisualizerRow(string Time, string Message, WorkerLogLevel Level);