From 8ee64592c7ba0d5ac264bdb67c6f10d51421d796 Mon Sep 17 00:00:00 2001 From: mubeda <1380447+mubeda@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:59:49 -0300 Subject: [PATCH] chore: upgrade Avalonia 12 dependencies for 2.1.2 - bump Logbert version to 2.1.2 - upgrade Avalonia/Dock/MCP/System package references - migrate tests to xUnit v3 - update Avalonia 12 clipboard and placeholder API usage --- docs/CHANGELOG.md | 292 +- src/Logbert/Logbert.csproj | 31 +- .../ViewModels/Controls/LogViewerViewModel.cs | 2387 +++++++++-------- .../Dialogs/AboutDialogViewModel.cs | 325 +-- .../Views/Dialogs/ColumnizerTestDialog.axaml | 534 ++-- src/Logbert/Views/Dialogs/ExportDialog.axaml | 258 +- .../Views/Dialogs/FilterEditorDialog.axaml | 366 +-- .../Dialogs/KeyboardShortcutsDialog.axaml | 228 +- .../Views/Dialogs/LogLevelMapDialog.axaml | 372 +-- src/Logbert/Views/Dialogs/OptionsDialog.axaml | 798 +++--- .../CustomHttpReceiverSettingsView.axaml | 252 +- src/Logbert/Views/Dialogs/SearchDialog.axaml | 118 +- .../Views/Dialogs/TimestampFormatDialog.axaml | 478 ++-- .../Views/Docking/FilterPanelView.axaml | 384 +-- .../Views/Docking/SearchPanelView.axaml | 176 +- tests/Logbert.Tests/Logbert.Tests.csproj | 63 +- .../Services/ExportServiceTests.cs | 826 +++--- 17 files changed, 3948 insertions(+), 3940 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d322427..811a61b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,150 +1,156 @@ -# Changelog - -All notable changes to Logbert will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +# Changelog + +All notable changes to Logbert will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.1.1] - 2026-01-24 - -### Fixed -- **Documentation**: Fixed GitHub repository link in About dialog and README -- **Documentation**: Updated copyright year to 2026 across all documentation files -- **Documentation**: Removed outdated references to winforms branch in README - -## [2.1.0] - 2026-01-23 - -### Added -- **MCP Server Integration**: Built-in Model Context Protocol server enables AI assistants (Claude Code, etc.) to query log data - - HTTP transport on `http://localhost:5112/mcp` for GUI mode - - Stdio transport for headless/CLI mode - - 5 MCP tools: QueryLogs, SearchLogs, GetLogDetails, GetStatistics, ListSources - - Supports regex search, log level filtering, time range queries, and pagination - - See [MCP_SERVER.md](MCP_SERVER.md) for setup instructions - -## [2.0.6] - 2026-01-22 - -### Fixed -- **Listen Interface IP Default**: Changed default listen IP from `0.0.0.0` to `127.0.0.1` for all network receivers (NLog UDP/TCP, Log4Net UDP, Syslog UDP, Custom UDP/TCP). This fixes connectivity issues when testing locally, as `0.0.0.0` can fail due to IPv4/IPv6 mismatches or firewall restrictions. -- **TCP Receivers Ignoring Listen Interface**: Fixed bug in NLog TCP and Custom TCP receivers where the configured listen interface IP was ignored and `IPAddress.Any` was hardcoded. TCP receivers now correctly use the configured IP address. - -## [2.0.5] - 2026-01-22 - -### Added -- **Session Restore on Startup**: Automatically reopen all logger tabs that were open when the application was last closed - - Preserves per-tab view state: log level filters, grouping columns, logger tree filter, and font size - - Supports all receiver types: Log4Net File/UDP, NLog File/UDP/TCP, Syslog File/UDP - - Handles partial restore gracefully (e.g., if a file was deleted or port is in use) - - New "Reopen last logger(s) on startup" option in Options > General (enabled by default) -- **Configurable Batch Processing**: New settings in Options > Advanced for batch size (50-1000 messages) and batch interval (20-200ms) to tune performance for different workloads -- **Lazy Loading for Groups**: Groups now load messages on-demand when expanded, with a "Load More" button to fetch additional messages in batches of 100 -- **VirtualizingStackPanel for Groups**: Grouped view now uses UI virtualization for efficient rendering of many groups - -### Fixed -- **Grouped View Performance**: Major performance improvements for high-volume trace logging with grouping enabled: - - Groups now start collapsed by default to avoid unnecessary rendering - - Throttled property change notifications (every 100 messages instead of per-message) - - Bulk collection updates using `BulkObservableCollection.AddRange()` for single UI notification - - Filter changes are now debounced (150ms) to prevent UI jank during rapid filter toggling - -### Changed -- `LogMessageGroup` now uses `BulkObservableCollection` for efficient batch loading -- Group header count now shows total messages, not just loaded messages - -## [2.0.4] - 2025-01-21 - -### Added -- **Toolbar Copy Actions**: Added Select All, Copy Selected, and Copy All buttons to the main toolbar for quick clipboard operations - -### Fixed -- **Toolbar Button State**: Fixed Select All and Copy All toolbar buttons not enabling when messages are present - -### Changed -- Command state notifications now properly trigger for all copy-related commands when messages change - -## [2.0.3] - 2025-01-20 - -### Added -- **Multi-Select Support**: DataGrid now supports extended selection (Ctrl+Click, Shift+Click) for selecting multiple log messages -- **Keyboard Shortcuts**: Added Ctrl+A (Select All) and Ctrl+C (Copy Selected) keyboard shortcuts in the log viewer -- **Context Menu Enhancements**: Added "Copy Selected Messages", "Copy All Messages", and "Select All" options to the log grid context menu -- **Dashboard Direct Links**: Dashboard receiver shortcuts now open settings dialogs directly, bypassing the receiver selection step - -### Fixed -- **Grouped View Performance**: Fixed UI unresponsiveness when receiving high-volume trace logs with column grouping enabled. Groups are now updated incrementally instead of being fully rebuilt on every batch. -- **macOS Application Name**: Fixed macOS menu bar showing "Avalonia Application" instead of "Logbert" +## [2.1.2] - 2026-06-25 ### Changed -- Added O(1) dictionary lookup for group assignment during message processing -- `LogMessageGroup.Count` and `DisplayText` properties now properly notify changes when messages are added - -## [2.0.2] - 2025-01-20 +- **Dependencies**: Updated the desktop stack to Avalonia 12 and refreshed NuGet packages to current stable versions. +- **Tests**: Migrated the test project to xUnit v3. -### Fixed -- **UDP Socket Release**: Fixed issue where closing a UDP logger tab (NLog UDP, Log4Net UDP, Syslog UDP, Custom UDP) did not release the socket, causing "Only one usage of each socket address is normally permitted" error when reopening on the same port - -### Added -- **macOS App Bundle**: Added build scripts to create proper macOS `.app` bundles with icon support -- **High-Resolution App Icon**: Added 1024x1024 PNG icon for macOS app bundles - -### Changed -- macOS builds now produce `.app` bundles instead of loose binaries -- Added `IDisposable` implementation to `LogViewerViewModel` and `LogDocumentViewModel` for proper resource cleanup -- Updated all NuGet packages to latest versions - -## [2.0.1] - 2025-01-18 +## [2.1.1] - 2026-01-24 ### Fixed -- **Message Details Panel**: Fixed issue where the Message Details panel was not updating when selecting rows in a grouped DataGrid view -- **Grid Empty on Startup**: Fixed issue where the log grid appeared empty when loading a file with previously saved grouping settings -- **Collection Modification Error**: Fixed "Collection was modified" exception that occurred when loading grouping settings during file load - -### Changed -- Publish scripts now output versioned ZIP files (e.g., `Logbert-2.0.1-win-x64.zip`) - -### Added -- Test log file (`tests/test.log`) for UI testing with 50 sample log messages -- Screenshots section in README.md with all available screenshots - -## [2.0.0] - 2025-12-01 - -### Added -- **Cross-Platform Support**: Logbert now runs natively on Windows (x64, ARM64), macOS (Intel x64, Apple Silicon ARM64), and Linux (x64, ARM64) -- **Modern UI Framework**: Complete migration from Windows Forms to Avalonia UI 11.3.8 -- **Theme Support**: Fluent Design theme with Light/Dark mode and system theme following -- **Export Functionality**: Export log messages to CSV or Plain Text format with multiple encoding options -- **NotificationService**: Centralized error handling with user-friendly dialogs -- **Settings Persistence**: JSON-based cross-platform settings storage -- **Recent Files Menu**: Quick access to last 9 opened log files - -### Changed -- Target framework upgraded to .NET 10.0 -- Settings now stored in JSON format at: - - Windows: `%AppData%\Logbert\settings.json` - - macOS/Linux: `~/.config/Logbert/settings.json` -- Improved high-DPI support and font rendering - -### Removed -- Windows Forms dependency -- GDI+ graphics dependency -- System.Drawing (Windows-specific) dependency - -### Breaking Changes -- Minimum .NET version is now .NET 10.0 (was .NET Framework 4.8) -- Windows Event Log receiver: Windows only -- Windows Debug Output receiver: Windows only -- Previous v1.x settings are not automatically migrated - -## [1.x] - Legacy - -### Features (carried forward to 2.0) -- 16 receiver types (File-based, Network, System, Custom) -- DataGrid with virtualization for large files -- Color-coded log levels -- Full-text search with regex support -- Log level toggle filtering -- Logger tree with hierarchical filtering -- Lua scripting for advanced filtering -- Bookmarks system -- Statistics panel -- Color map visualization +- **Documentation**: Fixed GitHub repository link in About dialog and README +- **Documentation**: Updated copyright year to 2026 across all documentation files +- **Documentation**: Removed outdated references to winforms branch in README + +## [2.1.0] - 2026-01-23 + +### Added +- **MCP Server Integration**: Built-in Model Context Protocol server enables AI assistants (Claude Code, etc.) to query log data + - HTTP transport on `http://localhost:5112/mcp` for GUI mode + - Stdio transport for headless/CLI mode + - 5 MCP tools: QueryLogs, SearchLogs, GetLogDetails, GetStatistics, ListSources + - Supports regex search, log level filtering, time range queries, and pagination + - See [MCP_SERVER.md](MCP_SERVER.md) for setup instructions + +## [2.0.6] - 2026-01-22 + +### Fixed +- **Listen Interface IP Default**: Changed default listen IP from `0.0.0.0` to `127.0.0.1` for all network receivers (NLog UDP/TCP, Log4Net UDP, Syslog UDP, Custom UDP/TCP). This fixes connectivity issues when testing locally, as `0.0.0.0` can fail due to IPv4/IPv6 mismatches or firewall restrictions. +- **TCP Receivers Ignoring Listen Interface**: Fixed bug in NLog TCP and Custom TCP receivers where the configured listen interface IP was ignored and `IPAddress.Any` was hardcoded. TCP receivers now correctly use the configured IP address. + +## [2.0.5] - 2026-01-22 + +### Added +- **Session Restore on Startup**: Automatically reopen all logger tabs that were open when the application was last closed + - Preserves per-tab view state: log level filters, grouping columns, logger tree filter, and font size + - Supports all receiver types: Log4Net File/UDP, NLog File/UDP/TCP, Syslog File/UDP + - Handles partial restore gracefully (e.g., if a file was deleted or port is in use) + - New "Reopen last logger(s) on startup" option in Options > General (enabled by default) +- **Configurable Batch Processing**: New settings in Options > Advanced for batch size (50-1000 messages) and batch interval (20-200ms) to tune performance for different workloads +- **Lazy Loading for Groups**: Groups now load messages on-demand when expanded, with a "Load More" button to fetch additional messages in batches of 100 +- **VirtualizingStackPanel for Groups**: Grouped view now uses UI virtualization for efficient rendering of many groups + +### Fixed +- **Grouped View Performance**: Major performance improvements for high-volume trace logging with grouping enabled: + - Groups now start collapsed by default to avoid unnecessary rendering + - Throttled property change notifications (every 100 messages instead of per-message) + - Bulk collection updates using `BulkObservableCollection.AddRange()` for single UI notification + - Filter changes are now debounced (150ms) to prevent UI jank during rapid filter toggling + +### Changed +- `LogMessageGroup` now uses `BulkObservableCollection` for efficient batch loading +- Group header count now shows total messages, not just loaded messages + +## [2.0.4] - 2025-01-21 + +### Added +- **Toolbar Copy Actions**: Added Select All, Copy Selected, and Copy All buttons to the main toolbar for quick clipboard operations + +### Fixed +- **Toolbar Button State**: Fixed Select All and Copy All toolbar buttons not enabling when messages are present + +### Changed +- Command state notifications now properly trigger for all copy-related commands when messages change + +## [2.0.3] - 2025-01-20 + +### Added +- **Multi-Select Support**: DataGrid now supports extended selection (Ctrl+Click, Shift+Click) for selecting multiple log messages +- **Keyboard Shortcuts**: Added Ctrl+A (Select All) and Ctrl+C (Copy Selected) keyboard shortcuts in the log viewer +- **Context Menu Enhancements**: Added "Copy Selected Messages", "Copy All Messages", and "Select All" options to the log grid context menu +- **Dashboard Direct Links**: Dashboard receiver shortcuts now open settings dialogs directly, bypassing the receiver selection step + +### Fixed +- **Grouped View Performance**: Fixed UI unresponsiveness when receiving high-volume trace logs with column grouping enabled. Groups are now updated incrementally instead of being fully rebuilt on every batch. +- **macOS Application Name**: Fixed macOS menu bar showing "Avalonia Application" instead of "Logbert" + +### Changed +- Added O(1) dictionary lookup for group assignment during message processing +- `LogMessageGroup.Count` and `DisplayText` properties now properly notify changes when messages are added + +## [2.0.2] - 2025-01-20 + +### Fixed +- **UDP Socket Release**: Fixed issue where closing a UDP logger tab (NLog UDP, Log4Net UDP, Syslog UDP, Custom UDP) did not release the socket, causing "Only one usage of each socket address is normally permitted" error when reopening on the same port + +### Added +- **macOS App Bundle**: Added build scripts to create proper macOS `.app` bundles with icon support +- **High-Resolution App Icon**: Added 1024x1024 PNG icon for macOS app bundles + +### Changed +- macOS builds now produce `.app` bundles instead of loose binaries +- Added `IDisposable` implementation to `LogViewerViewModel` and `LogDocumentViewModel` for proper resource cleanup +- Updated all NuGet packages to latest versions + +## [2.0.1] - 2025-01-18 + +### Fixed +- **Message Details Panel**: Fixed issue where the Message Details panel was not updating when selecting rows in a grouped DataGrid view +- **Grid Empty on Startup**: Fixed issue where the log grid appeared empty when loading a file with previously saved grouping settings +- **Collection Modification Error**: Fixed "Collection was modified" exception that occurred when loading grouping settings during file load + +### Changed +- Publish scripts now output versioned ZIP files (e.g., `Logbert-2.0.1-win-x64.zip`) + +### Added +- Test log file (`tests/test.log`) for UI testing with 50 sample log messages +- Screenshots section in README.md with all available screenshots + +## [2.0.0] - 2025-12-01 + +### Added +- **Cross-Platform Support**: Logbert now runs natively on Windows (x64, ARM64), macOS (Intel x64, Apple Silicon ARM64), and Linux (x64, ARM64) +- **Modern UI Framework**: Complete migration from Windows Forms to Avalonia UI 11.3.8 +- **Theme Support**: Fluent Design theme with Light/Dark mode and system theme following +- **Export Functionality**: Export log messages to CSV or Plain Text format with multiple encoding options +- **NotificationService**: Centralized error handling with user-friendly dialogs +- **Settings Persistence**: JSON-based cross-platform settings storage +- **Recent Files Menu**: Quick access to last 9 opened log files + +### Changed +- Target framework upgraded to .NET 10.0 +- Settings now stored in JSON format at: + - Windows: `%AppData%\Logbert\settings.json` + - macOS/Linux: `~/.config/Logbert/settings.json` +- Improved high-DPI support and font rendering + +### Removed +- Windows Forms dependency +- GDI+ graphics dependency +- System.Drawing (Windows-specific) dependency + +### Breaking Changes +- Minimum .NET version is now .NET 10.0 (was .NET Framework 4.8) +- Windows Event Log receiver: Windows only +- Windows Debug Output receiver: Windows only +- Previous v1.x settings are not automatically migrated + +## [1.x] - Legacy + +### Features (carried forward to 2.0) +- 16 receiver types (File-based, Network, System, Custom) +- DataGrid with virtualization for large files +- Color-coded log levels +- Full-text search with regex support +- Log level toggle filtering +- Logger tree with hierarchical filtering +- Lua scripting for advanced filtering +- Bookmarks system +- Statistics panel +- Color map visualization diff --git a/src/Logbert/Logbert.csproj b/src/Logbert/Logbert.csproj index 5fa96ad..77cd714 100644 --- a/src/Logbert/Logbert.csproj +++ b/src/Logbert/Logbert.csproj @@ -3,7 +3,7 @@ net10.0 WinExe - 2.1.1 + 2.1.2 enable true app.manifest @@ -40,22 +40,21 @@ - - - - - - - - - - - - - + + + + + + + + + + + + - - + + diff --git a/src/Logbert/ViewModels/Controls/LogViewerViewModel.cs b/src/Logbert/ViewModels/Controls/LogViewerViewModel.cs index 5e6e2c0..874e077 100644 --- a/src/Logbert/ViewModels/Controls/LogViewerViewModel.cs +++ b/src/Logbert/ViewModels/Controls/LogViewerViewModel.cs @@ -1,1193 +1,1194 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Logbert.Logging; -using Logbert.Interfaces; -using Logbert.Models; -using Logbert.Services; -using Avalonia.Threading; -using Avalonia.Media; - -namespace Logbert.ViewModels.Controls; - -/// -/// ViewModel for the log viewer control. -/// -public partial class LogViewerViewModel : ViewModelBase, ILogHandler, IDisposable -{ - /// - /// Event raised when messages are added to or removed from the viewer. - /// - public event EventHandler? MessagesUpdated; - - [ObservableProperty] - private ObservableCollection _messages = new(); - - [ObservableProperty] - private LogMessage? _selectedMessage; - - [ObservableProperty] - private double _fontSize = 11; - - [ObservableProperty] - private bool _showTrace = true; - - [ObservableProperty] - private bool _showDebug = true; - - [ObservableProperty] - private bool _showInfo = true; - - [ObservableProperty] - private bool _showWarning = true; - - [ObservableProperty] - private bool _showError = true; - - [ObservableProperty] - private bool _showFatal = true; - - /// - /// Gets or sets the logger filter path. Null or empty means no filter. - /// - [ObservableProperty] - private string? _loggerFilterPath; - - /// - /// Gets or sets whether the logger filter is recursive (includes child loggers). - /// - [ObservableProperty] - private bool _loggerFilterRecursive = true; - - /// - /// Gets or sets whether the error panel is visible. - /// - [ObservableProperty] - private bool _errorPanelVisible; - - /// - /// Gets or sets the error panel title. - /// - [ObservableProperty] - private string _errorPanelTitle = string.Empty; - - /// - /// Gets or sets the error panel message. - /// - [ObservableProperty] - private string _errorPanelMessage = string.Empty; - - /// - /// Gets or sets the error panel background brush. - /// - [ObservableProperty] - private IBrush _errorPanelBackgroundBrush = new SolidColorBrush(Color.FromRgb(255, 165, 0)); - - /// - /// Gets or sets the error panel foreground brush. - /// - [ObservableProperty] - private IBrush _errorPanelForegroundBrush = new SolidColorBrush(Colors.Black); - - /// - /// Tracks the count of suppressed errors for the same message. - /// - private int _errorCount; - - /// - /// Tracks the last error message for deduplication. - /// - private string _lastErrorMessage = string.Empty; - - /// - /// Reference to the log provider/receiver for pause control. - /// - private ILogProvider? _logProvider; - - /// - /// Queue for batching incoming log messages. - /// - private readonly ConcurrentQueue _messageQueue = new(); - - /// - /// Timer for processing batched messages. - /// - private DispatcherTimer? _batchTimer; - - /// - /// Flag to track if a batch process is pending. - /// - private bool _batchProcessPending; - - /// - /// Maximum number of messages to process in one batch (configurable via settings). - /// - private int MaxBatchSize => SettingsService.Instance.Settings.BatchSize; - - /// - /// Interval for batch processing in milliseconds (configurable via settings). - /// - private int BatchIntervalMs => SettingsService.Instance.Settings.BatchIntervalMs; - - /// - /// Cancellation token for filter debouncing. - /// - private CancellationTokenSource? _filterDebounceToken; - - /// - /// Debounce delay for filter changes in milliseconds. - /// - private const int FilterDebounceMs = 150; - - /// - /// Gets or sets whether log receiving is paused. - /// - [ObservableProperty] - private bool _isPaused; - - /// - /// Gets the filtered messages based on log level visibility. - /// - public ObservableCollection FilteredMessages { get; } = new(); - - #region Column Grouping - - /// - /// Gets or sets whether grouping is enabled. - /// - [ObservableProperty] - private bool _isGroupingEnabled; - - /// - /// Gets or sets the currently selected grouping column. - /// - [ObservableProperty] - private GroupableColumn? _selectedGroupingColumn; - - /// - /// Gets the list of columns that can be grouped. - /// - public ObservableCollection GroupableColumns { get; } = new() - { - new GroupableColumn { Name = "Level", PropertyName = "Level" }, - new GroupableColumn { Name = "Logger", PropertyName = "Logger" }, - new GroupableColumn { Name = "Thread", PropertyName = "ThreadName" }, - new GroupableColumn { Name = "Machine", PropertyName = "MachineName" }, - new GroupableColumn { Name = "Process", PropertyName = "ProcessName" } - }; - - /// - /// Gets the columns currently being used for grouping. - /// - public ObservableCollection ActiveGroupingColumns { get; } = new(); - - /// - /// Gets the grouped messages when grouping is enabled. - /// - public ObservableCollection GroupedMessages { get; } = new(); - - /// - /// Dictionary for O(1) group lookup by group key. - /// - private readonly Dictionary _groupLookup = new(); - - /// - /// Gets the command to add a grouping column. - /// - public IRelayCommand AddGroupingColumnCommand { get; } = null!; - - /// - /// Gets the command to remove a grouping column. - /// - public IRelayCommand RemoveGroupingColumnCommand { get; } = null!; - - /// - /// Gets the command to clear all grouping. - /// - public IRelayCommand ClearGroupingCommand { get; } = null!; - - /// - /// Gets the command to toggle a group's expanded state. - /// - public IRelayCommand ToggleGroupExpandedCommand { get; } = null!; - - #endregion - - #region Column Visibility - - /// - /// Gets the column configurations for visibility control. - /// - public ObservableCollection ColumnConfigs { get; } = new(); - - /// - /// Gets the command to toggle a column's visibility. - /// - public IRelayCommand ToggleColumnVisibilityCommand { get; } = null!; - - #endregion - - /// - /// Gets the command to zoom in. - /// - public IRelayCommand ZoomInCommand { get; } = null!; - - /// - /// Gets the command to zoom out. - /// - public IRelayCommand ZoomOutCommand { get; } = null!; - - /// - /// Gets the command to copy selected message. - /// - public IRelayCommand CopyMessageCommand { get; } = null!; - - /// - /// Gets the command to dismiss the error panel. - /// - public IRelayCommand DismissErrorPanelCommand { get; } = null!; - - /// - /// Gets the command to clear all messages. - /// - public IRelayCommand ClearMessagesCommand { get; } = null!; - - /// - /// Gets the command to toggle pause/resume log receiving. - /// - public IRelayCommand TogglePauseCommand { get; } = null!; - - /// - /// Gets the command to select all messages. - /// - public IRelayCommand SelectAllCommand { get; } = null!; - - /// - /// Gets the command to copy all messages (or all selected/filtered messages). - /// - public IRelayCommand CopyAllMessagesCommand { get; } = null!; - - /// - /// Initializes a new instance of the class. - /// - public LogViewerViewModel() - { - ZoomInCommand = new RelayCommand(OnZoomIn, CanZoomIn); - ZoomOutCommand = new RelayCommand(OnZoomOut, CanZoomOut); - CopyMessageCommand = new RelayCommand(OnCopyMessage, CanCopyMessage); - DismissErrorPanelCommand = new RelayCommand(OnDismissErrorPanel); - ClearMessagesCommand = new RelayCommand(OnClearMessages, CanClearMessages); - TogglePauseCommand = new RelayCommand(OnTogglePause, CanTogglePause); - SelectAllCommand = new RelayCommand(OnSelectAll, CanSelectAll); - CopyAllMessagesCommand = new RelayCommand(OnCopyAllMessages, CanCopyAllMessages); - - // Column grouping commands - AddGroupingColumnCommand = new RelayCommand(OnAddGroupingColumn); - RemoveGroupingColumnCommand = new RelayCommand(OnRemoveGroupingColumn); - ClearGroupingCommand = new RelayCommand(OnClearGrouping); - ToggleGroupExpandedCommand = new RelayCommand(OnToggleGroupExpanded); - - // Column visibility command - ToggleColumnVisibilityCommand = new RelayCommand(OnToggleColumnVisibility); - - // Initialize column configurations - InitializeColumnConfigs(); - - // Load saved grouping settings - LoadGroupingSettings(); - - // Listen for changes to log level filters and logger filters (debounced for performance) - PropertyChanged += (s, e) => - { - if (e.PropertyName?.StartsWith("Show") == true || - e.PropertyName == nameof(LoggerFilterPath) || - e.PropertyName == nameof(LoggerFilterRecursive)) - { - OnFilterChangedDebounced(); - } - }; - - // Initialize batch processing timer - _batchTimer = new DispatcherTimer - { - Interval = TimeSpan.FromMilliseconds(BatchIntervalMs) - }; - _batchTimer.Tick += (s, e) => ProcessMessageBatch(); - } - - /// - /// Dismisses the error panel. - /// - private void OnDismissErrorPanel() - { - ErrorPanelVisible = false; - _errorCount = 0; - _lastErrorMessage = string.Empty; - } - - /// - /// Returns whether the clear messages command can execute. - /// - private bool CanClearMessages() => Messages.Count > 0; - - /// - /// Clears all messages from the log viewer. - /// - private void OnClearMessages() - { - // Clear the message queue first - while (_messageQueue.TryDequeue(out _)) { } - - // Stop batch processing - _batchTimer?.Stop(); - _batchProcessPending = false; - - Messages.Clear(); - FilteredMessages.Clear(); - GroupedMessages.Clear(); - _groupLookup.Clear(); // Clear the group lookup dictionary - SelectedMessage = null; - ErrorPanelVisible = false; - _errorCount = 0; - _lastErrorMessage = string.Empty; - - // Update command state - ((RelayCommand)ClearMessagesCommand).NotifyCanExecuteChanged(); - ((RelayCommand)SelectAllCommand).NotifyCanExecuteChanged(); - ((RelayCommand)CopyAllMessagesCommand).NotifyCanExecuteChanged(); - } - - /// - /// Returns whether the toggle pause command can execute. - /// - private bool CanTogglePause() => _logProvider != null; - - /// - /// Toggles pause/resume for log receiving. - /// - private void OnTogglePause() - { - if (_logProvider != null) - { - IsPaused = !IsPaused; - _logProvider.IsActive = !IsPaused; - } - } - - /// - /// Sets the log provider for pause control. - /// - public void SetLogProvider(ILogProvider provider) - { - _logProvider = provider; - IsPaused = !provider.IsActive; - ((RelayCommand)TogglePauseCommand).NotifyCanExecuteChanged(); - } - - /// - /// Updates the messages collection. - /// - public void UpdateMessages(ObservableCollection messages) - { - Messages = messages; - UpdateFilteredMessages(); - } - - /// - /// Updates the filtered messages based on current filter settings. - /// - private void UpdateFilteredMessages() - { - FilteredMessages.Clear(); - - foreach (LogMessage message in Messages) - { - if (ShouldShowMessage(message)) - { - FilteredMessages.Add(message); - } - } - - // Update grouped messages if grouping is enabled - // This is a full rebuild since the filtered set changed entirely - if (IsGroupingEnabled) - { - UpdateGroupedMessages(); - } - - // Update command state after filtering - ((RelayCommand)ClearMessagesCommand).NotifyCanExecuteChanged(); - ((RelayCommand)SelectAllCommand).NotifyCanExecuteChanged(); - ((RelayCommand)CopyAllMessagesCommand).NotifyCanExecuteChanged(); - } - - /// - /// Debounced filter change handler to prevent UI jank when rapidly toggling filters. - /// - private async void OnFilterChangedDebounced() - { - // Cancel previous pending update - _filterDebounceToken?.Cancel(); - _filterDebounceToken = new CancellationTokenSource(); - - try - { - await Task.Delay(FilterDebounceMs, _filterDebounceToken.Token); - - // Execute on UI thread - await Dispatcher.UIThread.InvokeAsync(UpdateFilteredMessages); - } - catch (TaskCanceledException) - { - // Debounced - newer update pending, this is expected - } - } - - /// - /// Determines if a message should be shown based on filter settings. - /// - private bool ShouldShowMessage(LogMessage message) - { - // Check log level filter first - bool levelMatch = message.Level switch - { - LogLevel.Trace => ShowTrace, - LogLevel.Debug => ShowDebug, - LogLevel.Info => ShowInfo, - LogLevel.Warning => ShowWarning, - LogLevel.Error => ShowError, - LogLevel.Fatal => ShowFatal, - _ => true - }; - - if (!levelMatch) - { - return false; - } - - // Check logger filter - if (!string.IsNullOrEmpty(LoggerFilterPath)) - { - var messageLogger = message.Logger ?? string.Empty; - if (LoggerFilterRecursive) - { - // Recursive: logger must start with the filter path - if (!messageLogger.StartsWith(LoggerFilterPath, StringComparison.Ordinal)) - { - return false; - } - } - else - { - // Exact match: logger must equal the filter path - if (!messageLogger.Equals(LoggerFilterPath, StringComparison.Ordinal)) - { - return false; - } - } - } - - return true; - } - - private bool CanZoomIn() => FontSize < 60; - - private void OnZoomIn() - { - if (FontSize < 60) - { - FontSize += 2; - } - } - - private bool CanZoomOut() => FontSize > 6; - - private void OnZoomOut() - { - if (FontSize > 6) - { - FontSize -= 2; - } - } - - private bool CanCopyMessage() => SelectedMessage != null; - - private async void OnCopyMessage() - { - if (SelectedMessage == null) return; - - try - { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - var mainWindow = desktop.MainWindow; - if (mainWindow?.Clipboard != null) - { - await mainWindow.Clipboard.SetTextAsync(SelectedMessage.Message); - } - } - } - catch - { - // Ignore clipboard errors - } - } - - /// - /// Returns true if select all can be executed. - /// - private bool CanSelectAll() => FilteredMessages.Count > 0; - - /// - /// Selects all messages - raises an event that the view can handle. - /// - private void OnSelectAll() - { - // Raise event for the view to handle DataGrid selection - SelectAllRequested?.Invoke(this, EventArgs.Empty); - } - - /// - /// Event raised when select all is requested. - /// - public event EventHandler? SelectAllRequested; - - /// - /// Returns true if copy all messages can be executed. - /// - private bool CanCopyAllMessages() => FilteredMessages.Count > 0; - - /// - /// Copies all filtered messages to the clipboard. - /// - private async void OnCopyAllMessages() - { - if (FilteredMessages.Count == 0) return; - - try - { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - var mainWindow = desktop.MainWindow; - if (mainWindow?.Clipboard != null) - { - var sb = new System.Text.StringBuilder(); - - // Add header - sb.AppendLine("#\tTimestamp\tLevel\tLogger\tThread\tProcess\tMessage"); - - // Add all filtered messages - foreach (var msg in FilteredMessages) - { - sb.AppendLine($"{msg.Index}\t{msg.Timestamp:yyyy-MM-dd HH:mm:ss.fff}\t{msg.Level}\t{msg.Logger}\t{msg.ThreadName}\t{msg.ProcessName}\t{msg.Message}"); - } - - await mainWindow.Clipboard.SetTextAsync(sb.ToString()); - } - } - } - catch - { - // Ignore clipboard errors - } - } - - /// - /// Copies selected messages to the clipboard. Called from the view with selected items. - /// - public async void CopySelectedMessages(IList selectedMessages) - { - if (selectedMessages == null || selectedMessages.Count == 0) return; - - try - { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - var mainWindow = desktop.MainWindow; - if (mainWindow?.Clipboard != null) - { - var sb = new System.Text.StringBuilder(); - - // Add header - sb.AppendLine("#\tTimestamp\tLevel\tLogger\tThread\tProcess\tMessage"); - - // Add selected messages - foreach (var msg in selectedMessages.OrderBy(m => m.Index)) - { - sb.AppendLine($"{msg.Index}\t{msg.Timestamp:yyyy-MM-dd HH:mm:ss.fff}\t{msg.Level}\t{msg.Logger}\t{msg.ThreadName}\t{msg.ProcessName}\t{msg.Message}"); - } - - await mainWindow.Clipboard.SetTextAsync(sb.ToString()); - } - } - } - catch - { - // Ignore clipboard errors - } - } - - /// - /// Handles a single log message from the receiver. - /// Uses batching to prevent UI thread flooding. - /// - public void HandleMessage(LogMessage logMsg) - { - // Queue the message for batch processing - _messageQueue.Enqueue(logMsg); - - // Start the batch timer if not already running - if (!_batchProcessPending) - { - _batchProcessPending = true; - Dispatcher.UIThread.Post(StartBatchTimer, DispatcherPriority.Background); - } - } - - /// - /// Handles multiple log messages from the receiver. - /// Uses batching to prevent UI thread flooding. - /// - public void HandleMessage(LogMessage[] logMsgs) - { - // Queue all messages for batch processing - foreach (var msg in logMsgs) - { - _messageQueue.Enqueue(msg); - } - - // Start the batch timer if not already running - if (!_batchProcessPending) - { - _batchProcessPending = true; - Dispatcher.UIThread.Post(StartBatchTimer, DispatcherPriority.Background); - } - } - - /// - /// Starts the batch processing timer on the UI thread. - /// - private void StartBatchTimer() - { - if (_batchTimer != null && !_batchTimer.IsEnabled) - { - _batchTimer.Start(); - } - } - - /// - /// Processes a batch of queued messages. - /// - private void ProcessMessageBatch() - { - var processedMessages = new List(); - var filteredBatchMessages = new List(); - int count = 0; - - // Dequeue up to MaxBatchSize messages - while (count < MaxBatchSize && _messageQueue.TryDequeue(out var msg)) - { - Messages.Add(msg); - - if (ShouldShowMessage(msg)) - { - FilteredMessages.Add(msg); - filteredBatchMessages.Add(msg); - } - - processedMessages.Add(msg); - count++; - } - - // If we processed any messages, notify once - if (processedMessages.Count > 0) - { - // INCREMENTAL update: Add only new filtered messages to groups - if (IsGroupingEnabled && filteredBatchMessages.Count > 0) - { - AddMessagesToGroups(filteredBatchMessages); - } - - // Notify subscribers that messages were updated (single notification for batch) - MessagesUpdated?.Invoke(this, new NotifyCollectionChangedEventArgs( - NotifyCollectionChangedAction.Add, - processedMessages)); - - // Update command state once per batch - ((RelayCommand)ClearMessagesCommand).NotifyCanExecuteChanged(); - ((RelayCommand)SelectAllCommand).NotifyCanExecuteChanged(); - ((RelayCommand)CopyAllMessagesCommand).NotifyCanExecuteChanged(); - } - - // Stop timer if queue is empty - if (_messageQueue.IsEmpty) - { - _batchTimer?.Stop(); - _batchProcessPending = false; - } - } - - /// - /// Incrementally adds messages to their appropriate groups. - /// Uses O(1) dictionary lookup instead of rebuilding all groups. - /// - private void AddMessagesToGroups(IEnumerable messages) - { - if (ActiveGroupingColumns.Count == 0) - { - return; - } - - var groupingColumn = ActiveGroupingColumns[0]; - var modifiedGroups = new HashSet(); - - foreach (var message in messages) - { - var groupKey = GetPropertyValue(message, groupingColumn.PropertyName) ?? "Unknown"; - - if (!_groupLookup.TryGetValue(groupKey, out var group)) - { - // Create new group - group = new LogMessageGroup(groupingColumn.Name, groupKey, Array.Empty()); - _groupLookup[groupKey] = group; - - // Insert in sorted position to maintain alphabetical order - InsertGroupSorted(group); - } - - // Add message to existing group (lazy loaded - only visible if within initial load count) - group.AddMessage(message); - modifiedGroups.Add(group); - } - - // Notify all modified groups that the batch is complete (single UI update per group) - foreach (var group in modifiedGroups) - { - group.NotifyBatchComplete(); - } - } - - /// - /// Inserts a group into GroupedMessages in sorted order by GroupKey. - /// - private void InsertGroupSorted(LogMessageGroup newGroup) - { - int insertIndex = 0; - for (int i = 0; i < GroupedMessages.Count; i++) - { - if (string.Compare(GroupedMessages[i].GroupKey, newGroup.GroupKey, StringComparison.Ordinal) > 0) - { - break; - } - insertIndex = i + 1; - } - GroupedMessages.Insert(insertIndex, newGroup); - } - - /// - /// Handles an error from the receiver by showing a non-blocking error panel. - /// - public void HandleError(LogError error) - { - Dispatcher.UIThread.Post(() => - { - - // Check if this is the same error message (for deduplication) - if (error.Message == _lastErrorMessage) - { - _errorCount++; - // Update message with count if there are repeated errors - ErrorPanelMessage = _errorCount > 0 - ? $"{error.Message}{(error.Message.EndsWith(".") ? string.Empty : ".")} (+{_errorCount} similar)" - : $"{error.Message}{(error.Message.EndsWith(".") ? string.Empty : ".")}"; - } - else - { - // New error message - _lastErrorMessage = error.Message; - _errorCount = 0; - ErrorPanelMessage = $"{error.Message}{(error.Message.EndsWith(".") ? string.Empty : ".")}"; - } - - // Set panel properties based on error type - ErrorPanelTitle = error.Title; - - // Convert System.Drawing.Color to Avalonia brushes - ErrorPanelBackgroundBrush = new SolidColorBrush(Color.FromRgb(error.BackColor.R, error.BackColor.G, error.BackColor.B)); - ErrorPanelForegroundBrush = new SolidColorBrush(Color.FromRgb(error.ForeColor.R, error.ForeColor.G, error.ForeColor.B)); - - // Show the panel (non-blocking) - ErrorPanelVisible = true; - }); - } - - /// - /// Event raised when column configuration changes. - /// - public event System.EventHandler? ColumnConfigurationChanged; - - /// - /// Refreshes the column configuration from settings. - /// - public void RefreshColumnConfiguration() - { - ColumnConfigurationChanged?.Invoke(this, System.EventArgs.Empty); - } - - #region Column Grouping Methods - - /// - /// Adds a column to the grouping. - /// - private void OnAddGroupingColumn(GroupableColumn? column) - { - if (column == null || ActiveGroupingColumns.Contains(column)) - { - return; - } - - column.IsGrouped = true; - ActiveGroupingColumns.Add(column); - IsGroupingEnabled = ActiveGroupingColumns.Count > 0; - UpdateGroupedMessages(); - SaveGroupingSettings(); - } - - /// - /// Removes a column from the grouping. - /// - private void OnRemoveGroupingColumn(GroupableColumn? column) - { - if (column == null || !ActiveGroupingColumns.Contains(column)) - { - return; - } - - column.IsGrouped = false; - ActiveGroupingColumns.Remove(column); - IsGroupingEnabled = ActiveGroupingColumns.Count > 0; - UpdateGroupedMessages(); - SaveGroupingSettings(); - } - - /// - /// Clears all grouping. - /// - private void OnClearGrouping() - { - foreach (var column in ActiveGroupingColumns) - { - column.IsGrouped = false; - } - - ActiveGroupingColumns.Clear(); - GroupedMessages.Clear(); - _groupLookup.Clear(); // Clear the group lookup dictionary - IsGroupingEnabled = false; - SaveGroupingSettings(); - } - - /// - /// Toggles a group's expanded state. - /// - private void OnToggleGroupExpanded(LogMessageGroup? group) - { - if (group != null) - { - group.IsExpanded = !group.IsExpanded; - } - } - - /// - /// Updates the grouped messages based on active grouping columns. - /// - private void UpdateGroupedMessages() - { - GroupedMessages.Clear(); - _groupLookup.Clear(); // Clear the lookup dictionary when doing full rebuild - - if (!IsGroupingEnabled || ActiveGroupingColumns.Count == 0) - { - return; - } - - // Get the first grouping column - var groupingColumn = ActiveGroupingColumns[0]; - - // Group the filtered messages by the selected column - // IMPORTANT: ToList() materializes the query to avoid "Collection was modified" - // exceptions that can occur when the deferred LINQ query is enumerated while - // FilteredMessages is being modified by another operation (e.g., during data loading) - var groups = FilteredMessages - .GroupBy(msg => GetPropertyValue(msg, groupingColumn.PropertyName)) - .OrderBy(g => g.Key) - .Select(g => new { Key = g.Key, Messages = g.ToList() }) - .ToList(); - - foreach (var group in groups) - { - var groupKey = group.Key?.ToString() ?? "Unknown"; - var messageGroup = new LogMessageGroup( - groupingColumn.Name, - groupKey, - group.Messages); - GroupedMessages.Add(messageGroup); - _groupLookup[groupKey] = messageGroup; // Populate the lookup dictionary - } - } - - /// - /// Gets the value of a property from a log message. - /// - private static string? GetPropertyValue(LogMessage message, string propertyName) - { - return propertyName switch - { - "Level" => message.Level.ToString(), - "Logger" => message.Logger, - "Message" => message.Message, - "ThreadName" => message.ThreadName ?? "(none)", - "MachineName" => message.MachineName ?? "(none)", - "ProcessName" => message.ProcessName ?? "(none)", - _ => null - }; - } - - /// - /// Called when IsGroupingEnabled changes. - /// - partial void OnIsGroupingEnabledChanged(bool value) - { - if (!value) - { - GroupedMessages.Clear(); - _groupLookup.Clear(); // Clear the group lookup dictionary - } - else - { - UpdateGroupedMessages(); - } - } - - #endregion - - #region Column Visibility Methods - - /// - /// Initializes column configurations from settings. - /// - private void InitializeColumnConfigs() - { - var settings = SettingsService.Instance.Settings; - var columns = new[] - { - new { Name = "Number", Header = "#", Binding = "Index", DefaultWidth = 60.0 }, - new { Name = "Timestamp", Header = "Timestamp", Binding = "Timestamp", DefaultWidth = 180.0 }, - new { Name = "Level", Header = "Level", Binding = "Level", DefaultWidth = 80.0 }, - new { Name = "Logger", Header = "Logger", Binding = "Logger", DefaultWidth = 200.0 }, - new { Name = "Thread", Header = "Thread", Binding = "ThreadName", DefaultWidth = 80.0 }, - new { Name = "Machine", Header = "Machine", Binding = "MachineName", DefaultWidth = 100.0 }, - new { Name = "Process", Header = "Process", Binding = "ProcessName", DefaultWidth = 100.0 }, - new { Name = "Message", Header = "Message", Binding = "Message", DefaultWidth = 400.0 } - }; - - for (int i = 0; i < columns.Length; i++) - { - var col = columns[i]; - var isVisible = settings.ColumnVisibility.TryGetValue(col.Name, out var visible) ? visible : true; - var width = settings.ColumnWidths.TryGetValue(col.Name, out var w) ? w : col.DefaultWidth; - var order = settings.ColumnOrder.TryGetValue(col.Name, out var o) ? o : i; - - var config = new ColumnConfig - { - Name = col.Name, - Header = col.Header, - BindingProperty = col.Binding, - IsVisible = isVisible, - Width = width, - DisplayOrder = order - }; - - // Subscribe to visibility changes - config.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(ColumnConfig.IsVisible)) - { - SaveColumnVisibilitySettings(); - } - }; - - ColumnConfigs.Add(config); - } - } - - /// - /// Toggles a column's visibility. - /// - private void OnToggleColumnVisibility(ColumnConfig? column) - { - if (column != null) - { - column.IsVisible = !column.IsVisible; - } - } - - /// - /// Saves column visibility settings. - /// - private void SaveColumnVisibilitySettings() - { - SettingsService.Instance.UpdateSettings(settings => - { - foreach (var col in ColumnConfigs) - { - settings.ColumnVisibility[col.Name] = col.IsVisible; - } - }); - SettingsService.Instance.SaveIfDirty(); - } - - /// - /// Loads grouping settings from storage. - /// - private void LoadGroupingSettings() - { - var settings = SettingsService.Instance.Settings; - if (settings.ActiveGroupingColumns.Count > 0) - { - // ToList() creates a copy to avoid "Collection was modified" exception - // since OnAddGroupingColumn calls SaveGroupingSettings which modifies the original collection - foreach (var columnName in settings.ActiveGroupingColumns.ToList()) - { - var column = GroupableColumns.FirstOrDefault(c => c.Name == columnName); - if (column != null) - { - OnAddGroupingColumn(column); - } - } - } - } - - /// - /// Saves grouping settings to storage. - /// - private void SaveGroupingSettings() - { - SettingsService.Instance.UpdateSettings(settings => - { - settings.ActiveGroupingColumns.Clear(); - foreach (var col in ActiveGroupingColumns) - { - settings.ActiveGroupingColumns.Add(col.Name); - } - }); - SettingsService.Instance.SaveIfDirty(); - } - - #endregion - - #region Session State - - /// - /// Captures the current view state for session persistence. - /// - /// A GridViewState containing the current filter and grouping settings. - public GridViewState CaptureViewState() - { - return new GridViewState - { - ShowTrace = ShowTrace, - ShowDebug = ShowDebug, - ShowInfo = ShowInfo, - ShowWarning = ShowWarning, - ShowError = ShowError, - ShowFatal = ShowFatal, - ActiveGroupingColumns = ActiveGroupingColumns.Select(c => c.Name).ToList(), - IsGroupingEnabled = IsGroupingEnabled, - LoggerFilterPath = LoggerFilterPath, - LoggerFilterRecursive = LoggerFilterRecursive, - FontSize = FontSize - }; - } - - /// - /// Restores view state from a saved configuration. - /// - /// The saved view state to restore. - public void RestoreViewState(GridViewState state) - { - if (state == null) return; - - // Restore log level filters - ShowTrace = state.ShowTrace; - ShowDebug = state.ShowDebug; - ShowInfo = state.ShowInfo; - ShowWarning = state.ShowWarning; - ShowError = state.ShowError; - ShowFatal = state.ShowFatal; - - // Restore grouping - OnClearGrouping(); - if (state.IsGroupingEnabled && state.ActiveGroupingColumns.Count > 0) - { - foreach (var columnName in state.ActiveGroupingColumns) - { - var column = GroupableColumns.FirstOrDefault(c => c.Name == columnName); - if (column != null) - { - OnAddGroupingColumn(column); - } - } - } - - // Restore logger filter - LoggerFilterPath = state.LoggerFilterPath; - LoggerFilterRecursive = state.LoggerFilterRecursive; - - // Restore font size if specified - if (state.FontSize.HasValue) - { - FontSize = state.FontSize.Value; - } - } - - #endregion - - #region IDisposable - - /// - /// Disposes resources used by this view model, including shutting down any active log provider. - /// - public void Dispose() - { - // Cancel any pending filter debounce - _filterDebounceToken?.Cancel(); - _filterDebounceToken?.Dispose(); - _filterDebounceToken = null; - - // Stop the batch timer - if (_batchTimer != null) - { - _batchTimer.Stop(); - _batchTimer = null; - } - - // Shutdown the log provider to release resources (e.g., sockets) - if (_logProvider != null) - { - _logProvider.Shutdown(); - _logProvider = null; - } - } - - #endregion -} +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Logbert.Logging; +using Logbert.Interfaces; +using Logbert.Models; +using Logbert.Services; +using Avalonia.Threading; +using Avalonia.Media; + +namespace Logbert.ViewModels.Controls; + +/// +/// ViewModel for the log viewer control. +/// +public partial class LogViewerViewModel : ViewModelBase, ILogHandler, IDisposable +{ + /// + /// Event raised when messages are added to or removed from the viewer. + /// + public event EventHandler? MessagesUpdated; + + [ObservableProperty] + private ObservableCollection _messages = new(); + + [ObservableProperty] + private LogMessage? _selectedMessage; + + [ObservableProperty] + private double _fontSize = 11; + + [ObservableProperty] + private bool _showTrace = true; + + [ObservableProperty] + private bool _showDebug = true; + + [ObservableProperty] + private bool _showInfo = true; + + [ObservableProperty] + private bool _showWarning = true; + + [ObservableProperty] + private bool _showError = true; + + [ObservableProperty] + private bool _showFatal = true; + + /// + /// Gets or sets the logger filter path. Null or empty means no filter. + /// + [ObservableProperty] + private string? _loggerFilterPath; + + /// + /// Gets or sets whether the logger filter is recursive (includes child loggers). + /// + [ObservableProperty] + private bool _loggerFilterRecursive = true; + + /// + /// Gets or sets whether the error panel is visible. + /// + [ObservableProperty] + private bool _errorPanelVisible; + + /// + /// Gets or sets the error panel title. + /// + [ObservableProperty] + private string _errorPanelTitle = string.Empty; + + /// + /// Gets or sets the error panel message. + /// + [ObservableProperty] + private string _errorPanelMessage = string.Empty; + + /// + /// Gets or sets the error panel background brush. + /// + [ObservableProperty] + private IBrush _errorPanelBackgroundBrush = new SolidColorBrush(Color.FromRgb(255, 165, 0)); + + /// + /// Gets or sets the error panel foreground brush. + /// + [ObservableProperty] + private IBrush _errorPanelForegroundBrush = new SolidColorBrush(Colors.Black); + + /// + /// Tracks the count of suppressed errors for the same message. + /// + private int _errorCount; + + /// + /// Tracks the last error message for deduplication. + /// + private string _lastErrorMessage = string.Empty; + + /// + /// Reference to the log provider/receiver for pause control. + /// + private ILogProvider? _logProvider; + + /// + /// Queue for batching incoming log messages. + /// + private readonly ConcurrentQueue _messageQueue = new(); + + /// + /// Timer for processing batched messages. + /// + private DispatcherTimer? _batchTimer; + + /// + /// Flag to track if a batch process is pending. + /// + private bool _batchProcessPending; + + /// + /// Maximum number of messages to process in one batch (configurable via settings). + /// + private int MaxBatchSize => SettingsService.Instance.Settings.BatchSize; + + /// + /// Interval for batch processing in milliseconds (configurable via settings). + /// + private int BatchIntervalMs => SettingsService.Instance.Settings.BatchIntervalMs; + + /// + /// Cancellation token for filter debouncing. + /// + private CancellationTokenSource? _filterDebounceToken; + + /// + /// Debounce delay for filter changes in milliseconds. + /// + private const int FilterDebounceMs = 150; + + /// + /// Gets or sets whether log receiving is paused. + /// + [ObservableProperty] + private bool _isPaused; + + /// + /// Gets the filtered messages based on log level visibility. + /// + public ObservableCollection FilteredMessages { get; } = new(); + + #region Column Grouping + + /// + /// Gets or sets whether grouping is enabled. + /// + [ObservableProperty] + private bool _isGroupingEnabled; + + /// + /// Gets or sets the currently selected grouping column. + /// + [ObservableProperty] + private GroupableColumn? _selectedGroupingColumn; + + /// + /// Gets the list of columns that can be grouped. + /// + public ObservableCollection GroupableColumns { get; } = new() + { + new GroupableColumn { Name = "Level", PropertyName = "Level" }, + new GroupableColumn { Name = "Logger", PropertyName = "Logger" }, + new GroupableColumn { Name = "Thread", PropertyName = "ThreadName" }, + new GroupableColumn { Name = "Machine", PropertyName = "MachineName" }, + new GroupableColumn { Name = "Process", PropertyName = "ProcessName" } + }; + + /// + /// Gets the columns currently being used for grouping. + /// + public ObservableCollection ActiveGroupingColumns { get; } = new(); + + /// + /// Gets the grouped messages when grouping is enabled. + /// + public ObservableCollection GroupedMessages { get; } = new(); + + /// + /// Dictionary for O(1) group lookup by group key. + /// + private readonly Dictionary _groupLookup = new(); + + /// + /// Gets the command to add a grouping column. + /// + public IRelayCommand AddGroupingColumnCommand { get; } = null!; + + /// + /// Gets the command to remove a grouping column. + /// + public IRelayCommand RemoveGroupingColumnCommand { get; } = null!; + + /// + /// Gets the command to clear all grouping. + /// + public IRelayCommand ClearGroupingCommand { get; } = null!; + + /// + /// Gets the command to toggle a group's expanded state. + /// + public IRelayCommand ToggleGroupExpandedCommand { get; } = null!; + + #endregion + + #region Column Visibility + + /// + /// Gets the column configurations for visibility control. + /// + public ObservableCollection ColumnConfigs { get; } = new(); + + /// + /// Gets the command to toggle a column's visibility. + /// + public IRelayCommand ToggleColumnVisibilityCommand { get; } = null!; + + #endregion + + /// + /// Gets the command to zoom in. + /// + public IRelayCommand ZoomInCommand { get; } = null!; + + /// + /// Gets the command to zoom out. + /// + public IRelayCommand ZoomOutCommand { get; } = null!; + + /// + /// Gets the command to copy selected message. + /// + public IRelayCommand CopyMessageCommand { get; } = null!; + + /// + /// Gets the command to dismiss the error panel. + /// + public IRelayCommand DismissErrorPanelCommand { get; } = null!; + + /// + /// Gets the command to clear all messages. + /// + public IRelayCommand ClearMessagesCommand { get; } = null!; + + /// + /// Gets the command to toggle pause/resume log receiving. + /// + public IRelayCommand TogglePauseCommand { get; } = null!; + + /// + /// Gets the command to select all messages. + /// + public IRelayCommand SelectAllCommand { get; } = null!; + + /// + /// Gets the command to copy all messages (or all selected/filtered messages). + /// + public IRelayCommand CopyAllMessagesCommand { get; } = null!; + + /// + /// Initializes a new instance of the class. + /// + public LogViewerViewModel() + { + ZoomInCommand = new RelayCommand(OnZoomIn, CanZoomIn); + ZoomOutCommand = new RelayCommand(OnZoomOut, CanZoomOut); + CopyMessageCommand = new RelayCommand(OnCopyMessage, CanCopyMessage); + DismissErrorPanelCommand = new RelayCommand(OnDismissErrorPanel); + ClearMessagesCommand = new RelayCommand(OnClearMessages, CanClearMessages); + TogglePauseCommand = new RelayCommand(OnTogglePause, CanTogglePause); + SelectAllCommand = new RelayCommand(OnSelectAll, CanSelectAll); + CopyAllMessagesCommand = new RelayCommand(OnCopyAllMessages, CanCopyAllMessages); + + // Column grouping commands + AddGroupingColumnCommand = new RelayCommand(OnAddGroupingColumn); + RemoveGroupingColumnCommand = new RelayCommand(OnRemoveGroupingColumn); + ClearGroupingCommand = new RelayCommand(OnClearGrouping); + ToggleGroupExpandedCommand = new RelayCommand(OnToggleGroupExpanded); + + // Column visibility command + ToggleColumnVisibilityCommand = new RelayCommand(OnToggleColumnVisibility); + + // Initialize column configurations + InitializeColumnConfigs(); + + // Load saved grouping settings + LoadGroupingSettings(); + + // Listen for changes to log level filters and logger filters (debounced for performance) + PropertyChanged += (s, e) => + { + if (e.PropertyName?.StartsWith("Show") == true || + e.PropertyName == nameof(LoggerFilterPath) || + e.PropertyName == nameof(LoggerFilterRecursive)) + { + OnFilterChangedDebounced(); + } + }; + + // Initialize batch processing timer + _batchTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(BatchIntervalMs) + }; + _batchTimer.Tick += (s, e) => ProcessMessageBatch(); + } + + /// + /// Dismisses the error panel. + /// + private void OnDismissErrorPanel() + { + ErrorPanelVisible = false; + _errorCount = 0; + _lastErrorMessage = string.Empty; + } + + /// + /// Returns whether the clear messages command can execute. + /// + private bool CanClearMessages() => Messages.Count > 0; + + /// + /// Clears all messages from the log viewer. + /// + private void OnClearMessages() + { + // Clear the message queue first + while (_messageQueue.TryDequeue(out _)) { } + + // Stop batch processing + _batchTimer?.Stop(); + _batchProcessPending = false; + + Messages.Clear(); + FilteredMessages.Clear(); + GroupedMessages.Clear(); + _groupLookup.Clear(); // Clear the group lookup dictionary + SelectedMessage = null; + ErrorPanelVisible = false; + _errorCount = 0; + _lastErrorMessage = string.Empty; + + // Update command state + ((RelayCommand)ClearMessagesCommand).NotifyCanExecuteChanged(); + ((RelayCommand)SelectAllCommand).NotifyCanExecuteChanged(); + ((RelayCommand)CopyAllMessagesCommand).NotifyCanExecuteChanged(); + } + + /// + /// Returns whether the toggle pause command can execute. + /// + private bool CanTogglePause() => _logProvider != null; + + /// + /// Toggles pause/resume for log receiving. + /// + private void OnTogglePause() + { + if (_logProvider != null) + { + IsPaused = !IsPaused; + _logProvider.IsActive = !IsPaused; + } + } + + /// + /// Sets the log provider for pause control. + /// + public void SetLogProvider(ILogProvider provider) + { + _logProvider = provider; + IsPaused = !provider.IsActive; + ((RelayCommand)TogglePauseCommand).NotifyCanExecuteChanged(); + } + + /// + /// Updates the messages collection. + /// + public void UpdateMessages(ObservableCollection messages) + { + Messages = messages; + UpdateFilteredMessages(); + } + + /// + /// Updates the filtered messages based on current filter settings. + /// + private void UpdateFilteredMessages() + { + FilteredMessages.Clear(); + + foreach (LogMessage message in Messages) + { + if (ShouldShowMessage(message)) + { + FilteredMessages.Add(message); + } + } + + // Update grouped messages if grouping is enabled + // This is a full rebuild since the filtered set changed entirely + if (IsGroupingEnabled) + { + UpdateGroupedMessages(); + } + + // Update command state after filtering + ((RelayCommand)ClearMessagesCommand).NotifyCanExecuteChanged(); + ((RelayCommand)SelectAllCommand).NotifyCanExecuteChanged(); + ((RelayCommand)CopyAllMessagesCommand).NotifyCanExecuteChanged(); + } + + /// + /// Debounced filter change handler to prevent UI jank when rapidly toggling filters. + /// + private async void OnFilterChangedDebounced() + { + // Cancel previous pending update + _filterDebounceToken?.Cancel(); + _filterDebounceToken = new CancellationTokenSource(); + + try + { + await Task.Delay(FilterDebounceMs, _filterDebounceToken.Token); + + // Execute on UI thread + await Dispatcher.UIThread.InvokeAsync(UpdateFilteredMessages); + } + catch (TaskCanceledException) + { + // Debounced - newer update pending, this is expected + } + } + + /// + /// Determines if a message should be shown based on filter settings. + /// + private bool ShouldShowMessage(LogMessage message) + { + // Check log level filter first + bool levelMatch = message.Level switch + { + LogLevel.Trace => ShowTrace, + LogLevel.Debug => ShowDebug, + LogLevel.Info => ShowInfo, + LogLevel.Warning => ShowWarning, + LogLevel.Error => ShowError, + LogLevel.Fatal => ShowFatal, + _ => true + }; + + if (!levelMatch) + { + return false; + } + + // Check logger filter + if (!string.IsNullOrEmpty(LoggerFilterPath)) + { + var messageLogger = message.Logger ?? string.Empty; + if (LoggerFilterRecursive) + { + // Recursive: logger must start with the filter path + if (!messageLogger.StartsWith(LoggerFilterPath, StringComparison.Ordinal)) + { + return false; + } + } + else + { + // Exact match: logger must equal the filter path + if (!messageLogger.Equals(LoggerFilterPath, StringComparison.Ordinal)) + { + return false; + } + } + } + + return true; + } + + private bool CanZoomIn() => FontSize < 60; + + private void OnZoomIn() + { + if (FontSize < 60) + { + FontSize += 2; + } + } + + private bool CanZoomOut() => FontSize > 6; + + private void OnZoomOut() + { + if (FontSize > 6) + { + FontSize -= 2; + } + } + + private bool CanCopyMessage() => SelectedMessage != null; + + private async void OnCopyMessage() + { + if (SelectedMessage == null) return; + + try + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainWindow = desktop.MainWindow; + if (mainWindow?.Clipboard != null) + { + await mainWindow.Clipboard.SetTextAsync(SelectedMessage.Message); + } + } + } + catch + { + // Ignore clipboard errors + } + } + + /// + /// Returns true if select all can be executed. + /// + private bool CanSelectAll() => FilteredMessages.Count > 0; + + /// + /// Selects all messages - raises an event that the view can handle. + /// + private void OnSelectAll() + { + // Raise event for the view to handle DataGrid selection + SelectAllRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Event raised when select all is requested. + /// + public event EventHandler? SelectAllRequested; + + /// + /// Returns true if copy all messages can be executed. + /// + private bool CanCopyAllMessages() => FilteredMessages.Count > 0; + + /// + /// Copies all filtered messages to the clipboard. + /// + private async void OnCopyAllMessages() + { + if (FilteredMessages.Count == 0) return; + + try + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainWindow = desktop.MainWindow; + if (mainWindow?.Clipboard != null) + { + var sb = new System.Text.StringBuilder(); + + // Add header + sb.AppendLine("#\tTimestamp\tLevel\tLogger\tThread\tProcess\tMessage"); + + // Add all filtered messages + foreach (var msg in FilteredMessages) + { + sb.AppendLine($"{msg.Index}\t{msg.Timestamp:yyyy-MM-dd HH:mm:ss.fff}\t{msg.Level}\t{msg.Logger}\t{msg.ThreadName}\t{msg.ProcessName}\t{msg.Message}"); + } + + await mainWindow.Clipboard.SetTextAsync(sb.ToString()); + } + } + } + catch + { + // Ignore clipboard errors + } + } + + /// + /// Copies selected messages to the clipboard. Called from the view with selected items. + /// + public async void CopySelectedMessages(IList selectedMessages) + { + if (selectedMessages == null || selectedMessages.Count == 0) return; + + try + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainWindow = desktop.MainWindow; + if (mainWindow?.Clipboard != null) + { + var sb = new System.Text.StringBuilder(); + + // Add header + sb.AppendLine("#\tTimestamp\tLevel\tLogger\tThread\tProcess\tMessage"); + + // Add selected messages + foreach (var msg in selectedMessages.OrderBy(m => m.Index)) + { + sb.AppendLine($"{msg.Index}\t{msg.Timestamp:yyyy-MM-dd HH:mm:ss.fff}\t{msg.Level}\t{msg.Logger}\t{msg.ThreadName}\t{msg.ProcessName}\t{msg.Message}"); + } + + await mainWindow.Clipboard.SetTextAsync(sb.ToString()); + } + } + } + catch + { + // Ignore clipboard errors + } + } + + /// + /// Handles a single log message from the receiver. + /// Uses batching to prevent UI thread flooding. + /// + public void HandleMessage(LogMessage logMsg) + { + // Queue the message for batch processing + _messageQueue.Enqueue(logMsg); + + // Start the batch timer if not already running + if (!_batchProcessPending) + { + _batchProcessPending = true; + Dispatcher.UIThread.Post(StartBatchTimer, DispatcherPriority.Background); + } + } + + /// + /// Handles multiple log messages from the receiver. + /// Uses batching to prevent UI thread flooding. + /// + public void HandleMessage(LogMessage[] logMsgs) + { + // Queue all messages for batch processing + foreach (var msg in logMsgs) + { + _messageQueue.Enqueue(msg); + } + + // Start the batch timer if not already running + if (!_batchProcessPending) + { + _batchProcessPending = true; + Dispatcher.UIThread.Post(StartBatchTimer, DispatcherPriority.Background); + } + } + + /// + /// Starts the batch processing timer on the UI thread. + /// + private void StartBatchTimer() + { + if (_batchTimer != null && !_batchTimer.IsEnabled) + { + _batchTimer.Start(); + } + } + + /// + /// Processes a batch of queued messages. + /// + private void ProcessMessageBatch() + { + var processedMessages = new List(); + var filteredBatchMessages = new List(); + int count = 0; + + // Dequeue up to MaxBatchSize messages + while (count < MaxBatchSize && _messageQueue.TryDequeue(out var msg)) + { + Messages.Add(msg); + + if (ShouldShowMessage(msg)) + { + FilteredMessages.Add(msg); + filteredBatchMessages.Add(msg); + } + + processedMessages.Add(msg); + count++; + } + + // If we processed any messages, notify once + if (processedMessages.Count > 0) + { + // INCREMENTAL update: Add only new filtered messages to groups + if (IsGroupingEnabled && filteredBatchMessages.Count > 0) + { + AddMessagesToGroups(filteredBatchMessages); + } + + // Notify subscribers that messages were updated (single notification for batch) + MessagesUpdated?.Invoke(this, new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Add, + processedMessages)); + + // Update command state once per batch + ((RelayCommand)ClearMessagesCommand).NotifyCanExecuteChanged(); + ((RelayCommand)SelectAllCommand).NotifyCanExecuteChanged(); + ((RelayCommand)CopyAllMessagesCommand).NotifyCanExecuteChanged(); + } + + // Stop timer if queue is empty + if (_messageQueue.IsEmpty) + { + _batchTimer?.Stop(); + _batchProcessPending = false; + } + } + + /// + /// Incrementally adds messages to their appropriate groups. + /// Uses O(1) dictionary lookup instead of rebuilding all groups. + /// + private void AddMessagesToGroups(IEnumerable messages) + { + if (ActiveGroupingColumns.Count == 0) + { + return; + } + + var groupingColumn = ActiveGroupingColumns[0]; + var modifiedGroups = new HashSet(); + + foreach (var message in messages) + { + var groupKey = GetPropertyValue(message, groupingColumn.PropertyName) ?? "Unknown"; + + if (!_groupLookup.TryGetValue(groupKey, out var group)) + { + // Create new group + group = new LogMessageGroup(groupingColumn.Name, groupKey, Array.Empty()); + _groupLookup[groupKey] = group; + + // Insert in sorted position to maintain alphabetical order + InsertGroupSorted(group); + } + + // Add message to existing group (lazy loaded - only visible if within initial load count) + group.AddMessage(message); + modifiedGroups.Add(group); + } + + // Notify all modified groups that the batch is complete (single UI update per group) + foreach (var group in modifiedGroups) + { + group.NotifyBatchComplete(); + } + } + + /// + /// Inserts a group into GroupedMessages in sorted order by GroupKey. + /// + private void InsertGroupSorted(LogMessageGroup newGroup) + { + int insertIndex = 0; + for (int i = 0; i < GroupedMessages.Count; i++) + { + if (string.Compare(GroupedMessages[i].GroupKey, newGroup.GroupKey, StringComparison.Ordinal) > 0) + { + break; + } + insertIndex = i + 1; + } + GroupedMessages.Insert(insertIndex, newGroup); + } + + /// + /// Handles an error from the receiver by showing a non-blocking error panel. + /// + public void HandleError(LogError error) + { + Dispatcher.UIThread.Post(() => + { + + // Check if this is the same error message (for deduplication) + if (error.Message == _lastErrorMessage) + { + _errorCount++; + // Update message with count if there are repeated errors + ErrorPanelMessage = _errorCount > 0 + ? $"{error.Message}{(error.Message.EndsWith(".") ? string.Empty : ".")} (+{_errorCount} similar)" + : $"{error.Message}{(error.Message.EndsWith(".") ? string.Empty : ".")}"; + } + else + { + // New error message + _lastErrorMessage = error.Message; + _errorCount = 0; + ErrorPanelMessage = $"{error.Message}{(error.Message.EndsWith(".") ? string.Empty : ".")}"; + } + + // Set panel properties based on error type + ErrorPanelTitle = error.Title; + + // Convert System.Drawing.Color to Avalonia brushes + ErrorPanelBackgroundBrush = new SolidColorBrush(Color.FromRgb(error.BackColor.R, error.BackColor.G, error.BackColor.B)); + ErrorPanelForegroundBrush = new SolidColorBrush(Color.FromRgb(error.ForeColor.R, error.ForeColor.G, error.ForeColor.B)); + + // Show the panel (non-blocking) + ErrorPanelVisible = true; + }); + } + + /// + /// Event raised when column configuration changes. + /// + public event System.EventHandler? ColumnConfigurationChanged; + + /// + /// Refreshes the column configuration from settings. + /// + public void RefreshColumnConfiguration() + { + ColumnConfigurationChanged?.Invoke(this, System.EventArgs.Empty); + } + + #region Column Grouping Methods + + /// + /// Adds a column to the grouping. + /// + private void OnAddGroupingColumn(GroupableColumn? column) + { + if (column == null || ActiveGroupingColumns.Contains(column)) + { + return; + } + + column.IsGrouped = true; + ActiveGroupingColumns.Add(column); + IsGroupingEnabled = ActiveGroupingColumns.Count > 0; + UpdateGroupedMessages(); + SaveGroupingSettings(); + } + + /// + /// Removes a column from the grouping. + /// + private void OnRemoveGroupingColumn(GroupableColumn? column) + { + if (column == null || !ActiveGroupingColumns.Contains(column)) + { + return; + } + + column.IsGrouped = false; + ActiveGroupingColumns.Remove(column); + IsGroupingEnabled = ActiveGroupingColumns.Count > 0; + UpdateGroupedMessages(); + SaveGroupingSettings(); + } + + /// + /// Clears all grouping. + /// + private void OnClearGrouping() + { + foreach (var column in ActiveGroupingColumns) + { + column.IsGrouped = false; + } + + ActiveGroupingColumns.Clear(); + GroupedMessages.Clear(); + _groupLookup.Clear(); // Clear the group lookup dictionary + IsGroupingEnabled = false; + SaveGroupingSettings(); + } + + /// + /// Toggles a group's expanded state. + /// + private void OnToggleGroupExpanded(LogMessageGroup? group) + { + if (group != null) + { + group.IsExpanded = !group.IsExpanded; + } + } + + /// + /// Updates the grouped messages based on active grouping columns. + /// + private void UpdateGroupedMessages() + { + GroupedMessages.Clear(); + _groupLookup.Clear(); // Clear the lookup dictionary when doing full rebuild + + if (!IsGroupingEnabled || ActiveGroupingColumns.Count == 0) + { + return; + } + + // Get the first grouping column + var groupingColumn = ActiveGroupingColumns[0]; + + // Group the filtered messages by the selected column + // IMPORTANT: ToList() materializes the query to avoid "Collection was modified" + // exceptions that can occur when the deferred LINQ query is enumerated while + // FilteredMessages is being modified by another operation (e.g., during data loading) + var groups = FilteredMessages + .GroupBy(msg => GetPropertyValue(msg, groupingColumn.PropertyName)) + .OrderBy(g => g.Key) + .Select(g => new { Key = g.Key, Messages = g.ToList() }) + .ToList(); + + foreach (var group in groups) + { + var groupKey = group.Key?.ToString() ?? "Unknown"; + var messageGroup = new LogMessageGroup( + groupingColumn.Name, + groupKey, + group.Messages); + GroupedMessages.Add(messageGroup); + _groupLookup[groupKey] = messageGroup; // Populate the lookup dictionary + } + } + + /// + /// Gets the value of a property from a log message. + /// + private static string? GetPropertyValue(LogMessage message, string propertyName) + { + return propertyName switch + { + "Level" => message.Level.ToString(), + "Logger" => message.Logger, + "Message" => message.Message, + "ThreadName" => message.ThreadName ?? "(none)", + "MachineName" => message.MachineName ?? "(none)", + "ProcessName" => message.ProcessName ?? "(none)", + _ => null + }; + } + + /// + /// Called when IsGroupingEnabled changes. + /// + partial void OnIsGroupingEnabledChanged(bool value) + { + if (!value) + { + GroupedMessages.Clear(); + _groupLookup.Clear(); // Clear the group lookup dictionary + } + else + { + UpdateGroupedMessages(); + } + } + + #endregion + + #region Column Visibility Methods + + /// + /// Initializes column configurations from settings. + /// + private void InitializeColumnConfigs() + { + var settings = SettingsService.Instance.Settings; + var columns = new[] + { + new { Name = "Number", Header = "#", Binding = "Index", DefaultWidth = 60.0 }, + new { Name = "Timestamp", Header = "Timestamp", Binding = "Timestamp", DefaultWidth = 180.0 }, + new { Name = "Level", Header = "Level", Binding = "Level", DefaultWidth = 80.0 }, + new { Name = "Logger", Header = "Logger", Binding = "Logger", DefaultWidth = 200.0 }, + new { Name = "Thread", Header = "Thread", Binding = "ThreadName", DefaultWidth = 80.0 }, + new { Name = "Machine", Header = "Machine", Binding = "MachineName", DefaultWidth = 100.0 }, + new { Name = "Process", Header = "Process", Binding = "ProcessName", DefaultWidth = 100.0 }, + new { Name = "Message", Header = "Message", Binding = "Message", DefaultWidth = 400.0 } + }; + + for (int i = 0; i < columns.Length; i++) + { + var col = columns[i]; + var isVisible = settings.ColumnVisibility.TryGetValue(col.Name, out var visible) ? visible : true; + var width = settings.ColumnWidths.TryGetValue(col.Name, out var w) ? w : col.DefaultWidth; + var order = settings.ColumnOrder.TryGetValue(col.Name, out var o) ? o : i; + + var config = new ColumnConfig + { + Name = col.Name, + Header = col.Header, + BindingProperty = col.Binding, + IsVisible = isVisible, + Width = width, + DisplayOrder = order + }; + + // Subscribe to visibility changes + config.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(ColumnConfig.IsVisible)) + { + SaveColumnVisibilitySettings(); + } + }; + + ColumnConfigs.Add(config); + } + } + + /// + /// Toggles a column's visibility. + /// + private void OnToggleColumnVisibility(ColumnConfig? column) + { + if (column != null) + { + column.IsVisible = !column.IsVisible; + } + } + + /// + /// Saves column visibility settings. + /// + private void SaveColumnVisibilitySettings() + { + SettingsService.Instance.UpdateSettings(settings => + { + foreach (var col in ColumnConfigs) + { + settings.ColumnVisibility[col.Name] = col.IsVisible; + } + }); + SettingsService.Instance.SaveIfDirty(); + } + + /// + /// Loads grouping settings from storage. + /// + private void LoadGroupingSettings() + { + var settings = SettingsService.Instance.Settings; + if (settings.ActiveGroupingColumns.Count > 0) + { + // ToList() creates a copy to avoid "Collection was modified" exception + // since OnAddGroupingColumn calls SaveGroupingSettings which modifies the original collection + foreach (var columnName in settings.ActiveGroupingColumns.ToList()) + { + var column = GroupableColumns.FirstOrDefault(c => c.Name == columnName); + if (column != null) + { + OnAddGroupingColumn(column); + } + } + } + } + + /// + /// Saves grouping settings to storage. + /// + private void SaveGroupingSettings() + { + SettingsService.Instance.UpdateSettings(settings => + { + settings.ActiveGroupingColumns.Clear(); + foreach (var col in ActiveGroupingColumns) + { + settings.ActiveGroupingColumns.Add(col.Name); + } + }); + SettingsService.Instance.SaveIfDirty(); + } + + #endregion + + #region Session State + + /// + /// Captures the current view state for session persistence. + /// + /// A GridViewState containing the current filter and grouping settings. + public GridViewState CaptureViewState() + { + return new GridViewState + { + ShowTrace = ShowTrace, + ShowDebug = ShowDebug, + ShowInfo = ShowInfo, + ShowWarning = ShowWarning, + ShowError = ShowError, + ShowFatal = ShowFatal, + ActiveGroupingColumns = ActiveGroupingColumns.Select(c => c.Name).ToList(), + IsGroupingEnabled = IsGroupingEnabled, + LoggerFilterPath = LoggerFilterPath, + LoggerFilterRecursive = LoggerFilterRecursive, + FontSize = FontSize + }; + } + + /// + /// Restores view state from a saved configuration. + /// + /// The saved view state to restore. + public void RestoreViewState(GridViewState state) + { + if (state == null) return; + + // Restore log level filters + ShowTrace = state.ShowTrace; + ShowDebug = state.ShowDebug; + ShowInfo = state.ShowInfo; + ShowWarning = state.ShowWarning; + ShowError = state.ShowError; + ShowFatal = state.ShowFatal; + + // Restore grouping + OnClearGrouping(); + if (state.IsGroupingEnabled && state.ActiveGroupingColumns.Count > 0) + { + foreach (var columnName in state.ActiveGroupingColumns) + { + var column = GroupableColumns.FirstOrDefault(c => c.Name == columnName); + if (column != null) + { + OnAddGroupingColumn(column); + } + } + } + + // Restore logger filter + LoggerFilterPath = state.LoggerFilterPath; + LoggerFilterRecursive = state.LoggerFilterRecursive; + + // Restore font size if specified + if (state.FontSize.HasValue) + { + FontSize = state.FontSize.Value; + } + } + + #endregion + + #region IDisposable + + /// + /// Disposes resources used by this view model, including shutting down any active log provider. + /// + public void Dispose() + { + // Cancel any pending filter debounce + _filterDebounceToken?.Cancel(); + _filterDebounceToken?.Dispose(); + _filterDebounceToken = null; + + // Stop the batch timer + if (_batchTimer != null) + { + _batchTimer.Stop(); + _batchTimer = null; + } + + // Shutdown the log provider to release resources (e.g., sockets) + if (_logProvider != null) + { + _logProvider.Shutdown(); + _logProvider = null; + } + } + + #endregion +} diff --git a/src/Logbert/ViewModels/Dialogs/AboutDialogViewModel.cs b/src/Logbert/ViewModels/Dialogs/AboutDialogViewModel.cs index 9838f16..736a061 100644 --- a/src/Logbert/ViewModels/Dialogs/AboutDialogViewModel.cs +++ b/src/Logbert/ViewModels/Dialogs/AboutDialogViewModel.cs @@ -1,162 +1,163 @@ -using System; -using System.Diagnostics; -using System.Reflection; -using System.Runtime.InteropServices; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; - -namespace Logbert.ViewModels.Dialogs; - -/// -/// ViewModel for the About dialog. -/// -public partial class AboutDialogViewModel : ViewModelBase -{ - [ObservableProperty] - private string _applicationName = "Logbert"; - - [ObservableProperty] - private string _version = string.Empty; - - [ObservableProperty] - private string _copyright = "Copyright © 2026 Logbert Contributors"; - - [ObservableProperty] - private string _description = "Cross-platform log file viewer for various log formats"; - - [ObservableProperty] - private string _licenseText = string.Empty; - - #region System Information - - [ObservableProperty] - private string _operatingSystem = string.Empty; - - [ObservableProperty] - private string _dotNetVersion = string.Empty; - - [ObservableProperty] - private string _architecture = string.Empty; - - [ObservableProperty] - private string _avaloniaVersion = string.Empty; - - #endregion - - #region Commands - - public IRelayCommand OpenGitHubCommand { get; } - public IRelayCommand OpenIssuesCommand { get; } - public IRelayCommand OpenDocumentationCommand { get; } - public IRelayCommand CopySystemInfoCommand { get; } - - #endregion - - public AboutDialogViewModel() - { - // Get version from assembly - var assembly = Assembly.GetExecutingAssembly(); - var versionInfo = assembly.GetName().Version; - Version = versionInfo != null - ? $"Version {versionInfo.Major}.{versionInfo.Minor}.{versionInfo.Build}" - : "Version 2.0.0"; - - // Get system information - OperatingSystem = $"{RuntimeInformation.OSDescription}"; - DotNetVersion = $".NET {Environment.Version}"; - Architecture = RuntimeInformation.ProcessArchitecture.ToString(); - - // Get Avalonia version - var avaloniaAssembly = typeof(Avalonia.Application).Assembly; - var avaloniaVersionInfo = avaloniaAssembly.GetName().Version; - AvaloniaVersion = avaloniaVersionInfo != null - ? $"Avalonia {avaloniaVersionInfo.Major}.{avaloniaVersionInfo.Minor}.{avaloniaVersionInfo.Build}" - : "Avalonia 11.x"; - - // Initialize commands - OpenGitHubCommand = new RelayCommand(() => OpenUrl("https://github.com/mubeda/Logbert")); - OpenIssuesCommand = new RelayCommand(() => OpenUrl("https://github.com/mubeda/Logbert/issues")); - OpenDocumentationCommand = new RelayCommand(() => OpenUrl("https://github.com/mubeda/Logbert/wiki")); - CopySystemInfoCommand = new RelayCommand(OnCopySystemInfo); - - // Load MIT license text - LicenseText = @"The MIT License (MIT) - -Copyright (c) 2026 Logbert Contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the ""Software""), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE."; - } - - private void OpenUrl(string url) - { - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Process.Start("xdg-open", url); - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - Process.Start("open", url); - } - } - catch - { - // Ignore errors opening browser - } - } - - private async void OnCopySystemInfo() - { - var info = $""" - Logbert {Version} - OS: {OperatingSystem} - Runtime: {DotNetVersion} - Architecture: {Architecture} - UI Framework: {AvaloniaVersion} - """; - - try - { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - var mainWindow = desktop.MainWindow; - if (mainWindow != null) - { - var clipboard = mainWindow.Clipboard; - if (clipboard != null) - { - await clipboard.SetTextAsync(info); - } - } - } - } - catch - { - // Ignore clipboard errors - } - } -} +using System; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Input.Platform; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace Logbert.ViewModels.Dialogs; + +/// +/// ViewModel for the About dialog. +/// +public partial class AboutDialogViewModel : ViewModelBase +{ + [ObservableProperty] + private string _applicationName = "Logbert"; + + [ObservableProperty] + private string _version = string.Empty; + + [ObservableProperty] + private string _copyright = "Copyright © 2026 Logbert Contributors"; + + [ObservableProperty] + private string _description = "Cross-platform log file viewer for various log formats"; + + [ObservableProperty] + private string _licenseText = string.Empty; + + #region System Information + + [ObservableProperty] + private string _operatingSystem = string.Empty; + + [ObservableProperty] + private string _dotNetVersion = string.Empty; + + [ObservableProperty] + private string _architecture = string.Empty; + + [ObservableProperty] + private string _avaloniaVersion = string.Empty; + + #endregion + + #region Commands + + public IRelayCommand OpenGitHubCommand { get; } + public IRelayCommand OpenIssuesCommand { get; } + public IRelayCommand OpenDocumentationCommand { get; } + public IRelayCommand CopySystemInfoCommand { get; } + + #endregion + + public AboutDialogViewModel() + { + // Get version from assembly + var assembly = Assembly.GetExecutingAssembly(); + var versionInfo = assembly.GetName().Version; + Version = versionInfo != null + ? $"Version {versionInfo.Major}.{versionInfo.Minor}.{versionInfo.Build}" + : "Version 2.0.0"; + + // Get system information + OperatingSystem = $"{RuntimeInformation.OSDescription}"; + DotNetVersion = $".NET {Environment.Version}"; + Architecture = RuntimeInformation.ProcessArchitecture.ToString(); + + // Get Avalonia version + var avaloniaAssembly = typeof(Avalonia.Application).Assembly; + var avaloniaVersionInfo = avaloniaAssembly.GetName().Version; + AvaloniaVersion = avaloniaVersionInfo != null + ? $"Avalonia {avaloniaVersionInfo.Major}.{avaloniaVersionInfo.Minor}.{avaloniaVersionInfo.Build}" + : "Avalonia 11.x"; + + // Initialize commands + OpenGitHubCommand = new RelayCommand(() => OpenUrl("https://github.com/mubeda/Logbert")); + OpenIssuesCommand = new RelayCommand(() => OpenUrl("https://github.com/mubeda/Logbert/issues")); + OpenDocumentationCommand = new RelayCommand(() => OpenUrl("https://github.com/mubeda/Logbert/wiki")); + CopySystemInfoCommand = new RelayCommand(OnCopySystemInfo); + + // Load MIT license text + LicenseText = @"The MIT License (MIT) + +Copyright (c) 2026 Logbert Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the ""Software""), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE."; + } + + private void OpenUrl(string url) + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start("xdg-open", url); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", url); + } + } + catch + { + // Ignore errors opening browser + } + } + + private async void OnCopySystemInfo() + { + var info = $""" + Logbert {Version} + OS: {OperatingSystem} + Runtime: {DotNetVersion} + Architecture: {Architecture} + UI Framework: {AvaloniaVersion} + """; + + try + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainWindow = desktop.MainWindow; + if (mainWindow != null) + { + var clipboard = mainWindow.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(info); + } + } + } + } + catch + { + // Ignore clipboard errors + } + } +} diff --git a/src/Logbert/Views/Dialogs/ColumnizerTestDialog.axaml b/src/Logbert/Views/Dialogs/ColumnizerTestDialog.axaml index f1fcf13..269cdbc 100644 --- a/src/Logbert/Views/Dialogs/ColumnizerTestDialog.axaml +++ b/src/Logbert/Views/Dialogs/ColumnizerTestDialog.axaml @@ -1,267 +1,267 @@ - - - - - - - - - - - - - - - -