From b285276773292e6660897ce95a09f87ace5a1990 Mon Sep 17 00:00:00 2001 From: TautCony Date: Mon, 31 Aug 2026 23:04:17 +0800 Subject: [PATCH 001/147] feat(log-tool): Enhance log viewing experience with list-first design and explicit inspection - Introduced a new log tool design that prioritizes a compact, searchable list view, with an explicit inspector for detailed information. - Added specifications for log export functionality, ensuring secondary actions for JSON and CSV exports while preserving the visible state. --- docs/code-map/avalonia.md | 13 +- docs/code-map/contracts.md | 6 + docs/code-map/infrastructure.md | 9 + docs/code-map/testing.md | 5 +- .../.openspec.yaml | 3 + .../README.md | 8 + .../design.md | 194 +++++ .../proposal.md | 93 +++ .../specs/log-export-archive/spec.md | 60 ++ .../specs/log-tool-viewing/spec.md | 159 ++++ .../2026-08-31-log-tool-enhancements/tasks.md | 75 ++ openspec/specs/log-export-archive/spec.md | 64 ++ openspec/specs/log-tool-viewing/spec.md | 163 ++++ .../Resources/Locales/en-US.axaml | 19 + .../Resources/Locales/ja-JP.axaml | 19 + .../Resources/Locales/zh-CN.axaml | 19 + .../ViewModels/Tools/LogEntryViewModel.cs | 574 ++++++++++++-- .../ViewModels/Tools/LogToolViewModel.cs | 398 +++++++--- .../Views/Tools/LogToolView.axaml | 736 ++++++++++-------- .../Views/Tools/LogToolView.axaml.cs | 192 +++++ .../Composition/AuxiliaryToolsModule.cs | 3 +- .../Composition/LoggingModule.cs | 3 + .../Services/StandardToolCatalogFactory.cs | 7 +- .../PlatformPorts/IApplicationLogService.cs | 24 + .../Platform/ApplicationLogFileExporter.cs | 99 +++ .../Resources/Locales/en-US.json | 19 + .../Resources/Locales/ja-JP.json | 19 + .../Resources/Locales/zh-CN.json | 19 + .../Headless/AuxiliaryToolHeadlessTests.cs | 320 +++++++- .../Headless/MainWindowHeadlessTestHost.cs | 1 + .../ViewModels/LogToolViewModelTests.cs | 397 +++++++++- .../ApplicationLogFileExporterTests.cs | 147 ++++ .../ApplicationLogPanelProviderTests.cs | 17 + 33 files changed, 3375 insertions(+), 509 deletions(-) create mode 100644 openspec/changes/archive/2026-08-31-log-tool-enhancements/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-31-log-tool-enhancements/README.md create mode 100644 openspec/changes/archive/2026-08-31-log-tool-enhancements/design.md create mode 100644 openspec/changes/archive/2026-08-31-log-tool-enhancements/proposal.md create mode 100644 openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-export-archive/spec.md create mode 100644 openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-tool-viewing/spec.md create mode 100644 openspec/changes/archive/2026-08-31-log-tool-enhancements/tasks.md create mode 100644 openspec/specs/log-export-archive/spec.md create mode 100644 openspec/specs/log-tool-viewing/spec.md create mode 100644 src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs create mode 100644 tests/ChapterTool.Infrastructure.Tests/ApplicationLogFileExporterTests.cs diff --git a/docs/code-map/avalonia.md b/docs/code-map/avalonia.md index 2760a20f..086c91ca 100644 --- a/docs/code-map/avalonia.md +++ b/docs/code-map/avalonia.md @@ -112,6 +112,16 @@ This is the first file to inspect when dependency wiring or service registration - `src/ChapterTool.Avalonia.UI/Views/Tools/ForwardShiftToolView.axaml` - `src/ChapterTool.Avalonia.UI/Views/Tools/TextToolView.axaml` +### LogTool + +`LogToolViewModel` owns the bounded log projection, severity and text filters, selection, and inspector commands. Selection stays separate from inspection. The initial state has no selected row and a closed inspector. `LogEntryViewModel` owns compact row identity, localized search runs, and on-demand raw, technical, exception, and structured values. + +`LogToolView.axaml` renders a list-first surface. It shows compact one-line rows by default. It renders the inspector beside the list at wide widths and replaces the list at narrow widths. The inspector opens only after an explicit row action or keyboard command. + +`IApplicationLogService` is the shared log boundary. `ApplicationLogPanelProvider` keeps a bounded in-memory snapshot in append order and raises `EntryAdded` and `Cleared` notifications. It does not provide grouping, timestamp indexes, or history cursors. `IApplicationLogExporter` is an optional manual-export boundary. `ApplicationLogFileExporter` writes the selected entries as UTF-8 JSON or CSV below `settings/logs/`. + +Primary entry points are `src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml`, `src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs`, `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs`, and `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs`. + ### Imported theme resources `src/ChapterTool.Avalonia.UI/Resources/` owns the shared and imported user interface resources. @@ -246,8 +256,9 @@ Start with: - `src/ChapterTool.Avalonia/Services/StandardToolCatalogFactory.cs` - `src/ChapterTool.Contracts/PlatformPorts/IApplicationLogService.cs` - `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` +- `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs` -The ViewModel owns the filtered projection, selection, localized display text, and copy commands. The provider owns bounded history and live entry notifications. The view uses the imported master-detail composition and resources. +The ViewModel owns the filtered list projection, localized row text, selection, and inspector state. `LogEntryViewModel` keeps technical values out of the compact row and exposes them when the inspector is open. The provider owns bounded append-order retention and live entry notifications. The optional exporter receives the visible entries from the ViewModel. The view owns responsive list and inspector composition. ### Clip combine / multi-entry session diff --git a/docs/code-map/contracts.md b/docs/code-map/contracts.md index ae149da8..7e52a73e 100644 --- a/docs/code-map/contracts.md +++ b/docs/code-map/contracts.md @@ -26,4 +26,10 @@ Use this project when a desktop, browser, or command-line host needs the same pe - `src/ChapterTool.Contracts/PlatformPorts/IShellService.cs` - `src/ChapterTool.Contracts/PlatformPorts/ExternalToolLocation.cs` +### Application log boundary + +`IApplicationLogService` exposes the current bounded `Entries` snapshot, the `EntryAdded` notification, and the `Cleared` notification. `ApplicationLogEntry` carries the timestamp, level, message identity, operation, category, event identity, exception text, technical detail, arguments, and structured state. The contract has no grouping, timestamp-query, or history-cursor API. + +`LogExportFormat`, `ApplicationLogExportRequest`, and `ApplicationLogExportResult` define the optional manual-export boundary. `IApplicationLogExporter` receives the entries selected by the caller and returns a path or a recoverable error. Hosts may omit this service. The contract does not require Avalonia or filesystem access. + The project has no Avalonia dependency. Infrastructure implements the shared runtime boundaries. Avalonia UI consumes the contracts through its own adapters. diff --git a/docs/code-map/infrastructure.md b/docs/code-map/infrastructure.md index 1b19a009..51b99ecc 100644 --- a/docs/code-map/infrastructure.md +++ b/docs/code-map/infrastructure.md @@ -95,10 +95,18 @@ All runtime consumers receive the same aggregate store. `SettingsToolViewModel` - `src/ChapterTool.Infrastructure/Platform/NativeDependencyLocation.cs` - app log surface: - `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` +- manual log export: + - `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs` - test/dummy platform services: - `src/ChapterTool.Infrastructure/Platform/MemoryClipboardService.cs` - `src/ChapterTool.Infrastructure/Platform/ScriptedDialogService.cs` +`ApplicationLogPanelProvider` implements `IApplicationLogService` and `ILoggerProvider`. It captures entries at or above its configured minimum level. It keeps a bounded in-memory snapshot in append order. `Entries` returns a copy of the snapshot. `EntryAdded` fires after an entry is retained. `Cleared` fires after the snapshot is empty. The provider has no grouping, timestamp index, or older-page cursor. + +`ApplicationLogFileExporter` implements the optional `IApplicationLogExporter` contract. It creates `settings/logs/` below the host settings directory. It writes the requested entries as UTF-8 JSON or RFC 4180 CSV. It orders exported rows by timestamp and returns filesystem failures as `ApplicationLogExportResult` values. + +The desktop `LoggingModule` registers the provider and exporter as application singletons. It also keeps the Serilog rolling archive separate from the in-memory panel. The archive rolls daily, keeps up to 14 files, and rolls at 10 MB. + ## Feature Lookup ### ffprobe import issues @@ -158,3 +166,4 @@ Start with: - `src/ChapterTool.Infrastructure/Platform/ShellService.cs` - `src/ChapterTool.Infrastructure/Platform/ApplicationLogPanelProvider.cs` +- `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs` diff --git a/docs/code-map/testing.md b/docs/code-map/testing.md index 294e8079..d7c38526 100644 --- a/docs/code-map/testing.md +++ b/docs/code-map/testing.md @@ -116,6 +116,7 @@ BDMV compatibility tests: - platform services: - `tests/ChapterTool.Infrastructure.Tests/PlatformServiceTests.cs` - `tests/ChapterTool.Infrastructure.Tests/ApplicationLogPanelProviderTests.cs` + - `tests/ChapterTool.Infrastructure.Tests/ApplicationLogFileExporterTests.cs` - settings persistence: - `tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs` - `tests/ChapterTool.Infrastructure.Tests/CorruptSettingsFileTests.cs` @@ -141,6 +142,8 @@ High-signal test files: - `tests/ChapterTool.Avalonia.Tests/ViewModels/ToolWindowViewModelTests.cs` - `tests/ChapterTool.Avalonia.Tests/ViewModels/ToolViewModelPortConstructionTests.cs` - `tests/ChapterTool.Avalonia.Tests/ViewModels/LogToolViewModelTests.cs` + +LogTool coverage is split by boundary. `LogToolViewModelTests` covers list-first projection, severity and text filters, compact summaries, explicit inspector selection, search highlights, structured expansion, live updates, eviction handling, localization, and secondary command state. `tests/ChapterTool.Infrastructure.Tests/ApplicationLogPanelProviderTests.cs` covers append-order snapshots, minimum-level filtering, bounded retention, clear notifications, and concurrent access. `tests/ChapterTool.Infrastructure.Tests/ApplicationLogFileExporterTests.cs` covers UTF-8 JSON and CSV output, deterministic ordering, CSV quoting, output paths, and recoverable failures. `tests/ChapterTool.Avalonia.Headless.Tests/Headless/AuxiliaryToolHeadlessTests.cs` and `UiResourceResolutionHeadlessTests.cs` cover rendered list and inspector workflows, keyboard close behavior, responsive layouts, and locale resource resolution. - commands and services - `tests/ChapterTool.Avalonia.Tests/Commands/UiCommandTests.cs` - `tests/ChapterTool.Avalonia.Tests/Services/RuntimeChapterLoadServiceTests.cs` @@ -191,7 +194,7 @@ Editing-preference coverage for delete-rows timing and frame display is in `Chap Imported theme resource coverage is in `AvaloniaThemeApplicationServiceTests`. The tests resolve representative theme brushes and the configured monospace font through the runtime resource tree. They verify every imported `Color.*` token for a dark preset. Headless workflow tests verify visible `Optris.Icons.Avalonia.FontAwesome` icons. -Log projection coverage is in `LogToolViewModelTests` against `LogEntryViewModel`; log orchestration remains in `LogToolViewModel`. Log user-interface behavior is in `AuxiliaryToolHeadlessTests`. These tests verify filtering, selection, copy, clear, live updates, summary, structured data, raw JSON, theme changes, and narrow layout. +Log projection coverage is in `LogToolViewModelTests` against `LogEntryViewModel`; log orchestration remains in `LogToolViewModel`. Log user-interface behavior is in `AuxiliaryToolHeadlessTests`. These tests verify the list-first default, explicit details actions, selection retention, filtering and search, copy and clear actions, live append and bounded eviction, structured and raw disclosures, theme changes, localization, and narrow replacement layout. The settings Headless workflows verify the footer settings-folder action, including its left-side placement, accessible label, and routed shell target. diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/.openspec.yaml b/openspec/changes/archive/2026-08-31-log-tool-enhancements/.openspec.yaml new file mode 100644 index 00000000..6dde4668 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-08-30 +goal: Make log viewing more intuitive and powerful diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/README.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/README.md new file mode 100644 index 00000000..af84405d --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/README.md @@ -0,0 +1,8 @@ +# log-tool-enhancements + +Rebuild the Avalonia log tool around progressive disclosure. + +The tool must show a compact, searchable list first. Users must open a focused +inspector when they need technical data. Copy, clear, and export actions remain +available in a secondary overflow surface. Grouping, time-range filters, and +history pagination are deferred from this change. diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/design.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/design.md new file mode 100644 index 00000000..039cae16 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/design.md @@ -0,0 +1,194 @@ +## Context + +`LogToolViewModel` reads a bounded snapshot from `IApplicationLogService` and +subscribes to `EntryAdded` and `Cleared`. `ApplicationLogPanelProvider` already +owns the bounded in-memory history. `LogEntryViewModel` already formats the +localized summary, technical detail, exception text, raw JSON, and structured +values. The desktop composition already writes rolling Serilog files below +`settings/logs/`. + +The current revision added several independent concerns at once. The rendered +surface still auto-selects the first entry, reserves a large hidden detail +column, exposes empty detail tabs, and does not bind grouping, export-format, +status, or search-highlight state. The new design fixes the information +hierarchy first and keeps the provider contract stable. + +## Goals / Non-Goals + +**Goals:** + +- Make the list the dominant surface when the tool opens. +- Let users inspect one event without losing the list context on wide windows. +- Use an explicit inspector action and predictable selection semantics. +- Show concise rows, useful search, severity filtering, and visible match + feedback. +- Show only populated inspector sections and keep structured expansion state. +- Keep wide and narrow layouts usable, keyboard navigable, localized, and + screen-reader friendly. +- Keep copy, clear, and JSON/CSV export available as secondary actions. +- Preserve bounded memory, live updates, and existing Serilog archive behavior. + +**Non-Goals:** + +- Do not add grouping, time-range filtering, or cursor-based history loading. +- Do not change `IApplicationLogService.Entries`, `EntryAdded`, `Cleared`, or + the provider capacity contract for this visual change. +- Do not create a log database, remote sink, or archive-management workflow. +- Do not change Core APIs, CLI arguments, browser logging behavior, or settings + persistence. + +## Interaction Model + +The inspector has its own state. A selected row and an open inspector are not +the same thing. + +```text +Open tool + | + v +List only <----- close / Escape <----- Inspector + | ^ + | explicit Details action | + +--------------------------------------+ + wide: split view + narrow: inspector replaces list +``` + +- Opening the tool starts in `List only` with no selected entry. +- Clicking a row selects it and keeps the inspector closed. +- A row details action, Enter, or Space selects and opens that entry. +- Closing the inspector keeps the selected row and list scroll position. +- The same row can open the inspector again through the explicit action. +- A filter change clears selection only when the selected entry is no longer + visible. It must not select a replacement entry. +- A live entry must not steal focus or open the inspector. If the selected + entry is evicted, the selection and inspector close together. + +## Decisions + +### 1. Use a list-first responsive shell + +The root layout must give the list all available width when the inspector is +closed. The inspector column and splitter must have zero effective width in +that state. A wide split view may reserve a fixed inspector width between 400 +and 480 pixels while the list uses the remaining star-sized width. The exact +width must come from stable layout resources rather than content measurement. + +When the list and inspector minimum widths cannot coexist, the view must switch +to an inspector-only state with a localized back action. The view must not rely +on horizontal clipping or a `WrapPanel` for primary toolbar layout. + +### 2. Keep rows concise and information-dense + +Each row must have a stable height and one primary summary line. The row must +show a severity marker, localized severity accessible text, local time, and a +truncated summary. Operation, category, and event identity may appear in a +muted secondary line only when they are not already represented by the summary. +The row must expose an explicit details action with a localized automation +name. Empty fields must not reserve layout space. + +The row template must render the highlight runs produced by the projection. A +search match that exists only in hidden technical data must still retain the +row and expose an accessible indication of the match. + +### 3. Make the inspector progressive + +The inspector must use a compact context line for level, time, and identity. It +must not repeat the summary in a large header block. It must show the message, +technical detail, properties, exception, and structured data only when those +values exist. Empty sections and placeholder tabs must not occupy space. + +Structured values must use cached recursive nodes. A node with children must +bind its disclosure control two-way to `IsExpanded`. The projection must enforce +a finite depth and display a localized or stable depth-limit marker. Rebinding +the selected entry must not reset expansion state. + +Raw JSON and copy actions belong to the inspector's secondary action menu. A +user can still select and copy complete technical data without making raw JSON +the default visual surface. + +### 4. Keep filtering discoverable but quiet + +The primary toolbar must contain search, the visible result count, and one +severity filter affordance. The filter surface must label each control. An +active-filter indicator must be visible without opening the menu. Search must +be case-insensitive across the summary, category, operation, event identity, +technical detail, exception text, and structured/raw values. + +This change has no time-range or grouping control. Those controls must not be +reintroduced as disabled or unbound placeholders. + +### 5. Put destructive and infrequent actions in overflow + +Copy summary, copy details, clear, and export must be available through one +labeled overflow menu or an equivalent secondary action surface. The primary +toolbar must not give all four actions equal visual weight with search. JSON and +CSV choices must be inside the export action surface. Export uses the current +visible membership and must preserve selection and inspector state. + +The ViewModel must use `IApplicationLogExporter` when the host provides it. It +must not access the filesystem. Export status and recoverable failures must be +shown in a compact localized status region that is hidden when empty. + +### 6. Preserve boundaries and localization + +The existing bounded provider and Serilog rolling sink remain the source of +log data. This change must not add provider cursors or a persistent history +index. All user-facing labels, automation names, empty states, and status text +must come from the shared locale AXAML resources. Generated Wasm JSON must be +updated only through the repository conversion script. + +### 7. Test visible behavior + +Unit tests must cover projection and state transitions. Headless tests must +drive row selection, explicit inspection, closing, reopening, filtering, +search, keyboard focus, and secondary actions. Screenshot captures must cover +default, wide, narrow, and localized layouts. Tests must assert bounds and +visibility outcomes, not only control existence. + +## Risks / Trade-offs + +- A list-first default changes the previous auto-selection behavior. Tests must + prove that explicit inspection remains fast and that selection is preserved. +- A narrow inspector-only mode adds a visual state. Headless tests must verify + the back action and focus restoration at the tool minimum width. +- Rendering highlight runs and cached structured nodes adds projection state. + The ViewModel must reuse entry projections and update collections once per + filter or live-entry change. +- Moving actions into overflow can make them less obvious. The overflow button + must have a stable accessible name, and each menu item must have a tooltip or + localized label. +- Export can fail for host or file-system reasons. The exporter boundary must + return a failure result and leave the list and inspector unchanged. + +## Migration Plan + +1. Remove the previous draft-only grouping, time-range, and cursor-history + contracts and bindings. Keep the existing entries and live-event contract. +2. Add explicit selection and inspector state. Stop automatic selection and + automatic inspector opening. Add a row details command and focus rules. +3. Rework the row projection and inspector templates. Cache structured nodes, + bind expansion state, and render search matches. +4. Replace the fixed split layout and crowded footer with the responsive shell, + filter affordance, and secondary overflow actions. +5. Keep or finish the existing exporter behind the overflow action. Do not + modify the automatic Serilog archive. +6. Update locale resources and generated Wasm JSON. Update code-map ownership + and test guidance. +7. Run focused Avalonia unit tests, Headless tests in a separate process, + infrastructure export tests, the application build, and the full solution + test sequence. + +Rollback is file-level. Removing the inspector bindings and commands restores +the existing bounded list and logging sink. No provider data migration is +required. + +## Resolved Decisions + +- The list starts without a selected entry. Reuse of an existing tool window + may retain state only after the user has explicitly opened that inspector. +- The inspector is a split surface on wide windows and a replacement surface + on narrow windows. +- The default inspector view is conditional sections, not four permanently + visible tabs. +- Export defaults to the visible filtered membership and remains secondary. diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/proposal.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/proposal.md new file mode 100644 index 00000000..8aca8858 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/proposal.md @@ -0,0 +1,93 @@ +## Why + +The current LogTool opens a technical panel as soon as it creates the first +selection. The panel repeats the message and identity, keeps several empty +tabs visible, and leaves large unused areas for sparse entries. The fixed +master-detail columns also clip content at the tool minimum width. The footer +places diagnostic actions beside the primary search workflow and makes the +common view harder to scan. + +Users need a quick answer to two different questions: + +1. Which recent operation needs attention? +2. What technical evidence belongs to one selected event? + +The first question must be answered by the list. The second question must be +available on demand. The existing bounded log provider, live-entry events, and +Serilog rolling files already supply the required data. A visual redesign must +not expand that provider contract with speculative history or grouping APIs. + +## What Changes + +- Open the tool in a list-first state with no automatic selection or inspector. +- Render each row as a stable one-line summary with a severity marker and local + time. Show operation, category, and event identity only as compact secondary + context when they add information. +- Add an explicit details action for each row. Selecting a row must not open the + inspector by itself. Enter, Space, or the accessible row action can open it. +- Show a compact inspector only after an explicit request. Use a narrow header + for the selected event and show only non-empty technical sections. +- Keep the list available beside the inspector at a wide width. Replace the + list with the inspector and a back action when the two surfaces cannot fit. +- Keep search and severity filtering in the primary toolbar. Put copy, clear, + and JSON/CSV export in a labeled secondary overflow surface. +- Render search matches in the row instead of exposing highlight metadata that + the view does not consume. +- Keep structured values expandable with stable, two-way expansion state and a + finite depth limit. +- Preserve bounded live updates, selection state, keyboard focus, and localized + resource boundaries. +- Keep the existing automatic Serilog archive unchanged. Manual export, when + available on the host, must use the existing exporter boundary. + +## Deferred From This Change + +- Level grouping and grouped virtualized sections. +- Time-range filters. +- Cursor-based history pagination and older-page loading. +- A new persistent log database or a change to the bounded provider capacity. + +These features may be proposed separately after the list and inspector +workflow has evidence from real use. + +## Capabilities + +### New Capabilities + +- `log-tool-viewing`: List-first presentation, concise summaries, explicit + inspection, responsive behavior, search, severity filtering, and accessible + structured details. +- `log-export-archive`: Secondary JSON/CSV export and preservation of the + existing automatic archive behavior. + +### Modified Capabilities + +None. + +## Impact + +- Avalonia UI: `src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml`, + `src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs`, and + `src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs`. +- Host composition: `src/ChapterTool.Avalonia/Services/StandardToolCatalogFactory.cs` + only when the inspector needs a host capability already present in the + catalog. +- Optional export adapter: `src/ChapterTool.Infrastructure/Platform/ApplicationLogFileExporter.cs`. +- Localization: `src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/*.axaml` + and generated Wasm resources through `python3 scripts/axaml-to-json.py`. +- Tests: `tests/ChapterTool.Avalonia.Tests`, + `tests/ChapterTool.Avalonia.Headless.Tests`, and the focused infrastructure + export tests when export remains enabled. +- Documentation: `docs/code-map/avalonia.md` and `docs/code-map/testing.md`. +- Core, CLI, browser log contracts, settings schema, and automatic archive + retention are outside the change. + +## Success Criteria + +- A default screenshot shows the list as the dominant surface and no inspector. +- An explicit details action opens the inspector without losing list context. +- Closing and reopening details does not change the selected row or scroll + position. +- Default, wide, narrow, and all three supported locales render without + clipping or overlapping controls. +- Unit and Headless tests prove the state transitions and visible outcomes. diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-export-archive/spec.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-export-archive/spec.md new file mode 100644 index 00000000..33cc817e --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-export-archive/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Export is a secondary action + +The LogTool MUST expose an explicit export action from a labeled secondary +overflow surface. JSON and CSV choices must be available within that surface. +The primary list and search toolbar must remain usable without opening export. + +#### Scenario: Export choices stay out of the primary toolbar + +- **WHEN** the log tool opens with no menu open +- **THEN** search, count, and the severity filter remain visible while export + format choices are hidden in the secondary action surface + +#### Scenario: Export a selected format + +- **WHEN** the user chooses JSON or CSV and activates export +- **THEN** the host exporter receives that format and a snapshot of the current + visible log membership + +### Requirement: Export preserves the visible state + +Export MUST use the current severity-filtered and search-filtered membership by +default. It must not change selection, inspector visibility, filter values, or +the provider's bounded history. + +#### Scenario: Export after filtering + +- **WHEN** the user applies a severity filter or search query and exports +- **THEN** the output request contains only entries visible under those filters + in deterministic timestamp order + +#### Scenario: Export succeeds or fails + +- **WHEN** the exporter returns success or a recoverable failure +- **THEN** the list, selected row, inspector state, and filter values remain + unchanged and a compact localized status is shown + +### Requirement: Export failures are recoverable + +The export boundary MUST convert expected path, permission, encoding, and file +write failures into a failure result. The UI command must remain usable after a +failure and must not clear retained entries. + +#### Scenario: Export target cannot be written + +- **WHEN** the host cannot create or write the export target +- **THEN** the command completes without an uncaught file-system exception, + shows localized failure feedback, and leaves the log view usable + +### Requirement: Existing automatic archives remain unchanged + +The desktop logging composition MUST keep its existing rolling Serilog files +below `settings/logs/`. This change must not add a second archive workflow or +change retention settings. + +#### Scenario: Desktop logging starts + +- **WHEN** the desktop composition starts with a settings directory +- **THEN** the existing log directory and rolling sink behavior remain intact diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-tool-viewing/spec.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-tool-viewing/spec.md new file mode 100644 index 00000000..b9437ed7 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/specs/log-tool-viewing/spec.md @@ -0,0 +1,159 @@ +## ADDED Requirements + +### Requirement: The log tool opens as a list-first surface + +The LogTool MUST open with no selected entry and no inspector. The retained log +list must occupy the available content width until the user requests details. + +#### Scenario: Open an empty or populated log tool + +- **WHEN** the log tool is created with zero or more retained entries +- **THEN** the inspector is hidden, no entry is selected automatically, and the + list or localized empty state is visible + +#### Scenario: A live entry arrives + +- **WHEN** a new accepted entry arrives while the tool is open +- **THEN** the list updates without selecting the new entry, moving keyboard + focus, or opening the inspector + +### Requirement: Rows provide concise summaries + +Each retained entry MUST render as a stable compact row with a severity marker, +localized severity accessible text, local time, and a truncated summary. Optional +operation, category, or event identity may appear as muted context when it adds +information. Empty values must not reserve space. + +#### Scenario: Routine entry is rendered + +- **WHEN** an entry has a summary and timestamp but no extra identity +- **THEN** the row shows one readable summary line, a severity marker, and the + local time without empty badges or columns + +#### Scenario: Diagnostic identity is useful + +- **WHEN** an entry has category, operation, or event identity that is not in + the summary +- **THEN** the row exposes that identity as compact secondary context and the + inspector remains closed until explicitly requested + +### Requirement: Details require an explicit action + +Each row MUST expose an accessible details action. Selecting a row alone MUST +not open the inspector. Activating the details action, Enter, or Space must +select the entry and open the inspector. + +#### Scenario: Select without inspecting + +- **WHEN** the user clicks a row body +- **THEN** the row becomes selected and the list remains the only visible data + surface + +#### Scenario: Open and close the inspector + +- **WHEN** the user activates the selected row details action +- **THEN** the inspector displays that entry without removing the list on a + wide window +- **WHEN** the user closes the inspector or presses Escape +- **THEN** the list remains at its current filter and scroll state, the selected + row remains selected, and focus returns to the row or its details action + +### Requirement: The inspector uses progressive disclosure + +The inspector MUST show a compact context line and only non-empty message, +technical, property, exception, or structured sections. It must not show a +large repeated summary header or permanently visible empty tabs. + +#### Scenario: Sparse entry is inspected + +- **WHEN** the selected entry has no technical, property, exception, or nested + structured data +- **THEN** the inspector shows compact context and one localized empty state + without blank tab bodies + +#### Scenario: Technical entry is inspected + +- **WHEN** the selected entry has technical detail, an exception, or structured + values +- **THEN** the corresponding populated sections are available, while raw data + and copy actions remain secondary + +### Requirement: Structured values remain expandable + +Nested dictionaries and enumerable values MUST render as recursive nodes. A node +with children must expose an accessible disclosure control bound two-way to its +mutable expansion state. The projection must enforce a finite depth limit. + +#### Scenario: Expand a nested value + +- **WHEN** the selected entry contains nested structured state +- **THEN** the inspector shows a disclosure control and preserves the user's + expansion choice while that entry remains selected + +#### Scenario: Depth limit is reached + +- **WHEN** structured data exceeds the supported nesting depth +- **THEN** the terminal node shows a stable depth-limit marker and the view + remains responsive + +### Requirement: Search and severity filtering stay primary + +The LogTool MUST provide case-insensitive search and severity filtering without +changing retained provider history. Search must cover the summary, category, +operation, event identity, technical detail, exception text, and structured/raw +values. The filter surface must label its controls and expose an active state. + +#### Scenario: Search finds hidden diagnostic text + +- **WHEN** the query occurs only in exception text or structured state +- **THEN** the entry remains visible and the row exposes a localized indication + or highlight that the match is in technical data + +#### Scenario: Search highlights a summary + +- **WHEN** the query occurs in the visible summary +- **THEN** the matching segment is rendered with the log highlight style and + clearing the query removes stale highlights + +#### Scenario: Severity filter changes membership + +- **WHEN** the user selects a severity filter +- **THEN** only matching retained entries are shown, the count updates, and no + replacement entry is selected automatically + +### Requirement: Wide and narrow layouts remain usable + +The inspector MUST use a bounded width beside a star-sized list when the tool +is wide. When the list and inspector minimum widths cannot coexist, the +inspector must replace the list with a localized back action. Hidden columns, +fixed-width overflow, and clipped text are not valid responsive behavior. + +#### Scenario: Inspector opens at the wide size + +- **WHEN** details are opened in a wide tool window +- **THEN** the list remains visible, the inspector is bounded, and both surfaces + have independent scrolling where needed + +#### Scenario: Inspector opens at the narrow size + +- **WHEN** details are opened at the supported narrow width +- **THEN** the inspector fits within the window, exposes a back or close action, + and no control or text is clipped horizontally + +### Requirement: Localization and accessibility are preserved + +User-facing labels, empty states, status text, tooltips, and automation names +MUST resolve from the shared locale resources. Icon-only actions MUST have +accessible names. Keyboard navigation MUST reach the row details action, the +inspector close/back action, structured disclosures, and the filter surface. +#### Scenario: Locale changes while the tool is open + +- **WHEN** the active UI locale changes +- **THEN** list labels, inspector labels, filter labels, and accessibility names + refresh without losing the selected entry or expansion state + +#### Scenario: Keyboard inspection workflow + +- **WHEN** a keyboard user moves to a row and presses Enter or Space +- **THEN** the selected row opens its inspector and Escape returns focus to the + list diff --git a/openspec/changes/archive/2026-08-31-log-tool-enhancements/tasks.md b/openspec/changes/archive/2026-08-31-log-tool-enhancements/tasks.md new file mode 100644 index 00000000..9978db64 --- /dev/null +++ b/openspec/changes/archive/2026-08-31-log-tool-enhancements/tasks.md @@ -0,0 +1,75 @@ +## 1. Scope And Boundary Cleanup + +- [x] 1.1 Record the current list, provider, localization, and host composition + behavior before changing the view. +- [x] 1.2 Remove the previous draft-only grouping, time-range, and cursor-history + contracts, commands, bindings, and tests from this change. +- [x] 1.3 Keep `IApplicationLogService.Entries`, `EntryAdded`, `Cleared`, and the + bounded provider capacity contract unchanged. +- [x] 1.4 Keep `IApplicationLogExporter` optional and host-injected when manual + export is enabled. Keep automatic Serilog archive settings unchanged. + +## 2. Projection And ViewModel State + +- [x] 2.1 Make the initial state list-first with a null selection and a closed + inspector. Remove automatic selection on load, filtering, and live updates. +- [x] 2.2 Add explicit open, close, and narrow back commands with selection, + focus, and scroll-preservation rules. +- [x] 2.3 Build concise summary and optional context projections. Omit empty or + duplicated operation, category, and event identity fields. +- [x] 2.4 Cache structured nodes per entry, bind mutable expansion state two-way, + and keep the finite depth-limit behavior. +- [x] 2.5 Implement case-insensitive search across visible and technical fields, + severity filtering, visible counts, and rendered highlight runs. +- [x] 2.6 Keep live additions and capacity eviction incremental. Do not steal + focus or open the inspector when a new entry arrives. +- [x] 2.7 Keep copy, clear, and export commands secondary. Preserve all view state + after export success or recoverable failure and expose localized status. + +## 3. Responsive Avalonia View + +- [x] 3.1 Make the list fill the content width when the inspector is closed. Do + not reserve a hidden detail column or splitter. +- [x] 3.2 Render stable one-line rows with severity marker, local time, concise + summary, optional context, and an accessible explicit details action. +- [x] 3.3 Render the inspector with a compact context line and populated sections + only. Keep raw data and copy actions in the inspector or action overflow. +- [x] 3.4 Bind structured disclosure controls to `IsExpanded` and provide + keyboard-accessible names and focus behavior. +- [x] 3.5 Add a wide split state and a narrow inspector-only state with a back or + close action. Verify no horizontal clipping at supported widths. +- [x] 3.6 Replace the crowded footer with search, count, severity filter, and a + labeled secondary overflow surface. Use stable Grid sizing for the toolbar. + +## 4. Localization And Host Wiring + +- [x] 4.1 Add or update concise row, inspector, filter, overflow, status, and + accessibility strings in `en-US`, `zh-CN`, and `ja-JP` locale AXAML files. +- [x] 4.2 Run `python3 scripts/axaml-to-json.py` and + `python3 scripts/axaml-to-json.py --check`; do not edit generated Wasm JSON + by hand. +- [x] 4.3 Register the optional exporter through the existing desktop + composition without adding filesystem access to the ViewModel. +- [x] 4.4 Verify that `LoggingModule` still creates `settings/logs/` and keeps + the existing rolling Serilog sink and retention behavior. + +## 5. Tests And Documentation + +- [x] 5.1 Add Avalonia unit tests for list-first state, explicit inspection, + close/reopen, selection preservation, filtering, search highlights, live + updates, eviction, structured expansion, and secondary command state. +- [x] 5.2 Add infrastructure exporter tests for UTF-8 JSON/CSV output, quoting, + visible scope, and recoverable failures when export remains enabled. +- [x] 5.3 Add Headless workflow tests for row selection, details action, keyboard + and Escape behavior, populated/empty inspector sections, overflow actions, + localization, and narrow replacement layout. +- [x] 5.4 Capture default, wide, narrow, and localized screenshots under + `artifacts/` and review bounds, clipping, focus surfaces, and information + hierarchy. +- [x] 5.5 Update `docs/code-map/avalonia.md` and `docs/code-map/testing.md` with + the final ownership and test boundaries. +- [x] 5.6 Run the focused Avalonia unit project, the Headless project in a + separate process, focused infrastructure tests, the Avalonia build, and the + full solution tests sequentially. +- [x] 5.7 Run `openspec validate "log-tool-enhancements" --strict` and review all + changed artifacts before archive. diff --git a/openspec/specs/log-export-archive/spec.md b/openspec/specs/log-export-archive/spec.md new file mode 100644 index 00000000..ecd00a04 --- /dev/null +++ b/openspec/specs/log-export-archive/spec.md @@ -0,0 +1,64 @@ +# log-export-archive Specification + +## Purpose +TBD - created by archiving change log-tool-enhancements. Update Purpose after archive. +## Requirements +### Requirement: Export is a secondary action + +The LogTool MUST expose an explicit export action from a labeled secondary +overflow surface. JSON and CSV choices must be available within that surface. +The primary list and search toolbar must remain usable without opening export. + +#### Scenario: Export choices stay out of the primary toolbar + +- **WHEN** the log tool opens with no menu open +- **THEN** search, count, and the severity filter remain visible while export + format choices are hidden in the secondary action surface + +#### Scenario: Export a selected format + +- **WHEN** the user chooses JSON or CSV and activates export +- **THEN** the host exporter receives that format and a snapshot of the current + visible log membership + +### Requirement: Export preserves the visible state + +Export MUST use the current severity-filtered and search-filtered membership by +default. It must not change selection, inspector visibility, filter values, or +the provider's bounded history. + +#### Scenario: Export after filtering + +- **WHEN** the user applies a severity filter or search query and exports +- **THEN** the output request contains only entries visible under those filters + in deterministic timestamp order + +#### Scenario: Export succeeds or fails + +- **WHEN** the exporter returns success or a recoverable failure +- **THEN** the list, selected row, inspector state, and filter values remain + unchanged and a compact localized status is shown + +### Requirement: Export failures are recoverable + +The export boundary MUST convert expected path, permission, encoding, and file +write failures into a failure result. The UI command must remain usable after a +failure and must not clear retained entries. + +#### Scenario: Export target cannot be written + +- **WHEN** the host cannot create or write the export target +- **THEN** the command completes without an uncaught file-system exception, + shows localized failure feedback, and leaves the log view usable + +### Requirement: Existing automatic archives remain unchanged + +The desktop logging composition MUST keep its existing rolling Serilog files +below `settings/logs/`. This change must not add a second archive workflow or +change retention settings. + +#### Scenario: Desktop logging starts + +- **WHEN** the desktop composition starts with a settings directory +- **THEN** the existing log directory and rolling sink behavior remain intact + diff --git a/openspec/specs/log-tool-viewing/spec.md b/openspec/specs/log-tool-viewing/spec.md new file mode 100644 index 00000000..fe265dac --- /dev/null +++ b/openspec/specs/log-tool-viewing/spec.md @@ -0,0 +1,163 @@ +# log-tool-viewing Specification + +## Purpose +TBD - created by archiving change log-tool-enhancements. Update Purpose after archive. +## Requirements +### Requirement: The log tool opens as a list-first surface + +The LogTool MUST open with no selected entry and no inspector. The retained log +list must occupy the available content width until the user requests details. + +#### Scenario: Open an empty or populated log tool + +- **WHEN** the log tool is created with zero or more retained entries +- **THEN** the inspector is hidden, no entry is selected automatically, and the + list or localized empty state is visible + +#### Scenario: A live entry arrives + +- **WHEN** a new accepted entry arrives while the tool is open +- **THEN** the list updates without selecting the new entry, moving keyboard + focus, or opening the inspector + +### Requirement: Rows provide concise summaries + +Each retained entry MUST render as a stable compact row with a severity marker, +localized severity accessible text, local time, and a truncated summary. Optional +operation, category, or event identity may appear as muted context when it adds +information. Empty values must not reserve space. + +#### Scenario: Routine entry is rendered + +- **WHEN** an entry has a summary and timestamp but no extra identity +- **THEN** the row shows one readable summary line, a severity marker, and the + local time without empty badges or columns + +#### Scenario: Diagnostic identity is useful + +- **WHEN** an entry has category, operation, or event identity that is not in + the summary +- **THEN** the row exposes that identity as compact secondary context and the + inspector remains closed until explicitly requested + +### Requirement: Details require an explicit action + +Each row MUST expose an accessible details action. Selecting a row alone MUST +not open the inspector. Activating the details action, Enter, or Space must +select the entry and open the inspector. + +#### Scenario: Select without inspecting + +- **WHEN** the user clicks a row body +- **THEN** the row becomes selected and the list remains the only visible data + surface + +#### Scenario: Open and close the inspector + +- **WHEN** the user activates the selected row details action +- **THEN** the inspector displays that entry without removing the list on a + wide window +- **WHEN** the user closes the inspector or presses Escape +- **THEN** the list remains at its current filter and scroll state, the selected + row remains selected, and focus returns to the row or its details action + +### Requirement: The inspector uses progressive disclosure + +The inspector MUST show a compact context line and only non-empty message, +technical, property, exception, or structured sections. It must not show a +large repeated summary header or permanently visible empty tabs. + +#### Scenario: Sparse entry is inspected + +- **WHEN** the selected entry has no technical, property, exception, or nested + structured data +- **THEN** the inspector shows compact context and one localized empty state + without blank tab bodies + +#### Scenario: Technical entry is inspected + +- **WHEN** the selected entry has technical detail, an exception, or structured + values +- **THEN** the corresponding populated sections are available, while raw data + and copy actions remain secondary + +### Requirement: Structured values remain expandable + +Nested dictionaries and enumerable values MUST render as recursive nodes. A node +with children must expose an accessible disclosure control bound two-way to its +mutable expansion state. The projection must enforce a finite depth limit. + +#### Scenario: Expand a nested value + +- **WHEN** the selected entry contains nested structured state +- **THEN** the inspector shows a disclosure control and preserves the user's + expansion choice while that entry remains selected + +#### Scenario: Depth limit is reached + +- **WHEN** structured data exceeds the supported nesting depth +- **THEN** the terminal node shows a stable depth-limit marker and the view + remains responsive + +### Requirement: Search and severity filtering stay primary + +The LogTool MUST provide case-insensitive search and severity filtering without +changing retained provider history. Search must cover the summary, category, +operation, event identity, technical detail, exception text, and structured/raw +values. The filter surface must label its controls and expose an active state. + +#### Scenario: Search finds hidden diagnostic text + +- **WHEN** the query occurs only in exception text or structured state +- **THEN** the entry remains visible and the row exposes a localized indication + or highlight that the match is in technical data + +#### Scenario: Search highlights a summary + +- **WHEN** the query occurs in the visible summary +- **THEN** the matching segment is rendered with the log highlight style and + clearing the query removes stale highlights + +#### Scenario: Severity filter changes membership + +- **WHEN** the user selects a severity filter +- **THEN** only matching retained entries are shown, the count updates, and no + replacement entry is selected automatically + +### Requirement: Wide and narrow layouts remain usable + +The inspector MUST use a bounded width beside a star-sized list when the tool +is wide. When the list and inspector minimum widths cannot coexist, the +inspector must replace the list with a localized back action. Hidden columns, +fixed-width overflow, and clipped text are not valid responsive behavior. + +#### Scenario: Inspector opens at the wide size + +- **WHEN** details are opened in a wide tool window +- **THEN** the list remains visible, the inspector is bounded, and both surfaces + have independent scrolling where needed + +#### Scenario: Inspector opens at the narrow size + +- **WHEN** details are opened at the supported narrow width +- **THEN** the inspector fits within the window, exposes a back or close action, + and no control or text is clipped horizontally + +### Requirement: Localization and accessibility are preserved + +User-facing labels, empty states, status text, tooltips, and automation names +MUST resolve from the shared locale resources. Icon-only actions MUST have +accessible names. Keyboard navigation MUST reach the row details action, the +inspector close/back action, structured disclosures, and the filter surface. +#### Scenario: Locale changes while the tool is open + +- **WHEN** the active UI locale changes +- **THEN** list labels, inspector labels, filter labels, and accessibility names + refresh without losing the selected entry or expansion state + +#### Scenario: Keyboard inspection workflow + +- **WHEN** a keyboard user moves to a row and presses Enter or Space +- **THEN** the selected row opens its inspector and Escape returns focus to the + list + diff --git a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/en-US.axaml b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/en-US.axaml index cae50695..98654aaa 100644 --- a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/en-US.axaml +++ b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/en-US.axaml @@ -32,6 +32,19 @@ Warnings Errors Filter by level + Level + Reset filters + More actions + Export format + Open details + Match found in details. + {level}: {summary}, {time}. {context} + No log entries yet. + No entries match the current search or filter. + Message + Context + No additional details for this entry. + Back to log list Copy summary Copy details Technical details @@ -45,6 +58,12 @@ Properties Raw data Search log entries… + JSON + CSV + Exported to {value} + Export failed: {value} + Close details + Export logs This entry has no nested structured data. This entry has no exception data. Save diff --git a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/ja-JP.axaml b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/ja-JP.axaml index 69896a37..d8c7f63f 100644 --- a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/ja-JP.axaml +++ b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/ja-JP.axaml @@ -32,6 +32,19 @@ 警告 エラー レベルでフィルター + レベル + フィルターをリセット + その他の操作 + エクスポート形式 + 詳細を開く + 詳細に一致する内容があります。 + {level}:{summary}、{time}。{context} + ログはまだありません。 + 検索またはフィルターに一致するログはありません。 + メッセージ + コンテキスト + このログに追加の詳細はありません。 + ログ一覧に戻る 概要をコピー 詳細をコピー 技術詳細 @@ -45,6 +58,12 @@ プロパティ 生データ ログを検索… + JSON + CSV + {value} にエクスポートしました + エクスポートに失敗しました: {value} + 詳細を閉じる + ログをエクスポート このログにネストされた構造化データはありません。 このログに例外データはありません。 保存 diff --git a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/zh-CN.axaml b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/zh-CN.axaml index 7224e201..a13b8d35 100644 --- a/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/zh-CN.axaml +++ b/src/ChapterTool.Avalonia.UI/Localization/Resources/Locales/zh-CN.axaml @@ -32,6 +32,19 @@ 警告 错误 按级别筛选 + 级别 + 重置筛选 + 更多操作 + 导出格式 + 打开详情 + 详情中有匹配内容。 + {level}:{summary},{time}。{context} + 暂时没有日志记录。 + 没有符合搜索或筛选条件的记录。 + 消息 + 上下文 + 该记录没有更多详情。 + 返回日志列表 复制摘要 复制详情 技术详情 @@ -45,6 +58,12 @@ 属性 原始数据 搜索日志… + JSON + CSV + 已导出到 {value} + 导出失败:{value} + 关闭详情 + 导出日志 该日志没有嵌套结构化数据。 该日志没有异常数据。 保存 diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs index 0c22bbff..37f2b5cb 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogEntryViewModel.cs @@ -9,12 +9,29 @@ namespace ChapterTool.Avalonia.UI.ViewModels.Tools; public sealed record LogPropertyViewModel(string Name, string Value); -public sealed record LogStructuredNodeViewModel( - string Name, - string Value, - IReadOnlyList Children, - bool IsInitiallyExpanded) +public sealed record LogHighlightRun(string Text, bool IsMatch); + +public sealed class LogStructuredNodeViewModel( + string name, + string value, + IReadOnlyList children, + bool isInitiallyExpanded) + : ObservableViewModel { + public string Name { get; } = name; + + public string Value { get; } = value; + + public IReadOnlyList Children { get; } = children; + + public bool IsInitiallyExpanded { get; } = isInitiallyExpanded; + + public bool IsExpanded + { + get; + set => SetProperty(ref field, value); + } = isInitiallyExpanded; + public bool HasChildren => Children.Count > 0; } @@ -24,14 +41,35 @@ public sealed class LogEntryViewModel( IAppLocalizer contentLocalizer) : ObservableViewModel { + private const int MaxSearchDepth = 32; + private const int MaxSearchValues = 4096; + private const int MaxStructuredDepth = 32; + private const int MaxStructuredNodes = 4096; + private const int MaxCompactSummaryLength = 180; + + private static readonly Dictionary SpecialMessageMap = new(StringComparer.Ordinal) + { + ["Log.SettingsLoaded"] = "Settings loaded", + ["Log.LoadingSource"] = "Loading source", + ["Log.SavingChapters"] = "Saving chapters", + ["Log.AppendingMpls"] = "Appending MPLS", + ["Log.Diagnostic"] = "Diagnostic", + ["Log.TemplateLoaded"] = "Template loaded", + ["Log.TemplateLoadFailed"] = "Template load failed", + ["Log.LanguageSet"] = "Language changed", + ["Log.UnexpectedError"] = "Unexpected error" + }; + private string? rawText; private string? searchableText; + private string? additionalSearchableText; + private string? compactSummary; public ApplicationLogEntry Entry => entry; - public string Timestamp => entry.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); + public string Timestamp => entry.Timestamp.ToLocalTime().ToString("HH:mm:ss.fff"); - public string Time => entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"); + public string Time => entry.Timestamp.ToLocalTime().ToString("HH:mm:ss.fff"); public string LevelText => localizer.GetString(LevelKey(entry.Level)); @@ -51,6 +89,14 @@ public string Summary } } + public bool HasSummary => !string.IsNullOrWhiteSpace(Summary); + + /// + /// Gets the short message used by the list. Technical arguments stay in the + /// inspector so a long path or import payload cannot take over the feed. + /// + public string CompactSummary => compactSummary ??= CreateCompactSummary(); + public string Category => entry.Category ?? string.Empty; public string EventName => entry.EventName ?? string.Empty; @@ -63,11 +109,68 @@ public string Summary public bool HasOperation => !string.IsNullOrWhiteSpace(Operation); - public string Context => string.IsNullOrWhiteSpace(EventName) - ? Category - : string.IsNullOrWhiteSpace(Category) - ? EventDisplay - : $"{Category} / {EventDisplay}"; + public bool HasContext => !string.IsNullOrWhiteSpace(Context); + + /// Gets only identity that helps scan a row. The full category stays in the inspector. + public string RowContext + { + get + { + var parts = new List(capacity: 2); + AddContextPart(parts, Operation); + AddContextPart(parts, EventDisplay); + return string.Join(" / ", parts); + } + } + + public bool HasRowContext => !string.IsNullOrWhiteSpace(RowContext); + + public string Context + { + get + { + var parts = new List(capacity: 3); + AddContextPart(parts, Operation); + AddContextPart(parts, Category); + AddContextPart(parts, EventDisplay); + return string.Join(" / ", parts); + } + } + + public string IdentityText => Context; + + public string AccessibleName => localizer.Format( + "Tool.Log.EntryAccessibleName", + new Dictionary + { + ["level"] = LevelText, + ["summary"] = CompactSummary, + ["time"] = Timestamp, + ["context"] = Context + }) + + (HasAdditionalSearchMatch ? $" {SearchMatchIndicatorText}" : string.Empty); + + public string DetailsActionText => localizer.GetString("Tool.Log.OpenDetails"); + + public string SearchMatchIndicatorText => localizer.GetString("Tool.Log.SearchMatchAdditional"); + + public bool HasAdditionalSearchMatch + { + get; + private set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(AccessibleName)); + } + } + } + + public IReadOnlyList SummaryRuns + { + get; + private set => SetProperty(ref field, value); + } = []; public string Details { @@ -89,7 +192,9 @@ public string Details } } - public bool HasDetails => !string.IsNullOrWhiteSpace(Details); + public bool HasDetails => HasInspectorContent; + + public bool HasInspectorContent => HasTechnicalDetail || HasException || HasStructuredProperties || HasStructuredTree; public string TechnicalDetail => entry.TechnicalDetail?.Trim() ?? string.Empty; @@ -105,14 +210,16 @@ public string Details public string RawText => rawText ??= LogRawValueFormatter.Format(entry, Summary); public IReadOnlyList StructuredProperties => - entry.StructuredState is not { Count: > 0 } + field ??= entry.StructuredState is not { Count: > 0 } ? [] : CreateStructuredProperties(); public bool HasStructuredProperties => StructuredProperties.Count > 0; + public bool HasNoStructuredProperties => !HasStructuredProperties; + public IReadOnlyList StructuredTree => - entry.StructuredState is not { Count: > 0 } + field ??= entry.StructuredState is not { Count: > 0 } ? [] : CreateNodes( entry.StructuredState @@ -122,6 +229,34 @@ public string Details public bool HasStructuredTree => StructuredTree.Count > 0; + /// Gets or sets a value indicating whether stores the outer structured-data disclosure state for this entry. + public bool IsStructuredExpanded + { + get; + set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(ShowNoDetails)); + } + } + } + + /// Gets or sets a value indicating whether stores the raw-data disclosure state for this entry. + public bool IsRawExpanded + { + get; + set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(ShowNoDetails)); + } + } + } + + public bool ShowNoDetails => !HasInspectorContent && !IsRawExpanded; + public bool IsInformation => entry.Level == LogLevel.Information; public bool IsWarning => entry.Level == LogLevel.Warning; @@ -147,12 +282,58 @@ public bool MatchesSearch(string query) return Contains(SearchableText, query); } + public void ApplySearchHighlight(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + HasAdditionalSearchMatch = false; + SummaryRuns = [new LogHighlightRun(CompactSummary, false)]; + return; + } + + HasAdditionalSearchMatch = !Contains(CompactSummary, query) + && Contains(AdditionalSearchableText, query); + + var runs = new List(); + var offset = 0; + while (offset < CompactSummary.Length) + { + var match = CompactSummary.IndexOf(query, offset, StringComparison.OrdinalIgnoreCase); + if (match < 0) + { + runs.Add(new LogHighlightRun(CompactSummary[offset..], false)); + break; + } + + if (match > offset) + { + runs.Add(new LogHighlightRun(CompactSummary[offset..match], false)); + } + + runs.Add(new LogHighlightRun(CompactSummary.Substring(match, query.Length), true)); + offset = match + query.Length; + } + + SummaryRuns = runs; + } + public void RefreshLocalizedProperties() { - OnPropertyChanged(nameof(LevelText)); rawText = null; searchableText = null; + additionalSearchableText = null; + compactSummary = null; + + // Clear derived text before raising notifications so bindings never read a stale + // accessible name or summary during a culture switch. Keep structured nodes intact + // because their expansion state belongs to the selected entry. + OnPropertyChanged(nameof(LevelText)); + OnPropertyChanged(nameof(DetailsActionText)); + OnPropertyChanged(nameof(SearchMatchIndicatorText)); OnPropertyChanged(nameof(Summary)); + OnPropertyChanged(nameof(HasSummary)); + OnPropertyChanged(nameof(CompactSummary)); + OnPropertyChanged(nameof(AccessibleName)); OnPropertyChanged(nameof(Details)); OnPropertyChanged(nameof(RawText)); OnPropertyChanged(nameof(TechnicalDetail)); @@ -161,22 +342,244 @@ public void RefreshLocalizedProperties() OnPropertyChanged(nameof(StructuredTree)); OnPropertyChanged(nameof(HasStructuredTree)); OnPropertyChanged(nameof(Context)); + OnPropertyChanged(nameof(RowContext)); + OnPropertyChanged(nameof(IdentityText)); + OnPropertyChanged(nameof(HasContext)); + OnPropertyChanged(nameof(HasRowContext)); + ApplySearchHighlight(string.Empty); } - private string SearchableText => searchableText ??= string.Join( - Environment.NewLine, - Summary, - Category, - Operation, - EventDisplay, - entry.MessageKey, - entry.TechnicalDetail, - entry.ExceptionText, - RawText); + private string SearchableText => searchableText ??= BuildSearchableText(); + + private string AdditionalSearchableText => additionalSearchableText ??= BuildAdditionalSearchableText(); + + private string BuildSearchableText() + { + var values = new List + { + Summary, + CompactSummary, + AdditionalSearchableText + }; + + return string.Join(Environment.NewLine, values); + } + + private string BuildAdditionalSearchableText() + { + var values = new List + { + entry.Message, + Summary, + Category, + Operation, + EventDisplay, + entry.MessageKey ?? string.Empty, + entry.TechnicalDetail ?? string.Empty, + entry.ExceptionText ?? string.Empty + }; + + var budget = MaxSearchValues; + if (entry.StructuredState is not null) + { + AppendSearchablePairs(values, entry.StructuredState, ref budget); + } + + // Some callers construct ApplicationLogEntry directly and provide arguments + // without mirroring them into StructuredState. Keep those fields searchable. + if (entry.Arguments is not null) + { + AppendSearchablePairs(values, entry.Arguments, ref budget); + } + + return string.Join(Environment.NewLine, values); + } + + private static void AppendSearchablePairs( + List values, + IEnumerable> pairs, + ref int budget) + { + foreach (var pair in pairs) + { + if (budget <= 0) + { + break; + } + + values.Add(pair.Key); + AppendSearchableValue( + values, + pair.Value, + depth: 0, + new HashSet(ReferenceEqualityComparer.Instance), + ref budget); + } + } + + private static void AppendSearchableValue( + List values, + object? value, + int depth, + HashSet path, + ref int budget) + { + if (value is null || budget-- <= 0) + { + return; + } + + if (value is string text) + { + values.Add(text); + return; + } + + if (depth >= MaxSearchDepth) + { + values.Add("[depth limit]"); + return; + } + + var runtimeType = value.GetType(); + var trackReference = !runtimeType.IsValueType; + if (trackReference && !path.Add(value)) + { + values.Add("[cycle]"); + return; + } + + try + { + if (value is IReadOnlyDictionary readOnlyDictionary) + { + foreach (var pair in readOnlyDictionary) + { + if (budget <= 0) + { + break; + } + + values.Add(pair.Key); + AppendSearchableValue(values, pair.Value, depth + 1, path, ref budget); + } + + return; + } + + if (value is IDictionary dictionary) + { + foreach (DictionaryEntry pair in dictionary) + { + if (budget <= 0) + { + break; + } + + values.Add(pair.Key?.ToString() ?? string.Empty); + AppendSearchableValue(values, pair.Value, depth + 1, path, ref budget); + } + + return; + } + + if (value is IEnumerable enumerable) + { + foreach (var item in enumerable) + { + if (budget <= 0) + { + break; + } + + AppendSearchableValue(values, item, depth + 1, path, ref budget); + } + + return; + } + + values.Add(value.ToString() ?? string.Empty); + } + finally + { + if (trackReference) + { + path.Remove(value); + } + } + } private static bool Contains(string value, string query) => value.Contains(query, StringComparison.OrdinalIgnoreCase); + private string? GetSpecialMessageCompact(string? messageKey) + { + return messageKey switch + { + null => null, + "Log.ImportSummary" => CompactImportSummary(), + _ => SpecialMessageMap.GetValueOrDefault(messageKey) + }; + } + + private string CreateCompactSummary() + { + if (TryArgument("Summary", out var explicitSummary) || TryArgument("summary", out explicitSummary)) + { + return TruncateSummary(explicitSummary); + } + + if (TryArgument("message", out var message) && !string.IsNullOrWhiteSpace(message)) + { + return TruncateSummary(message); + } + + var special = GetSpecialMessageCompact(entry.MessageKey); + if (special is not null) + { + return TruncateSummary(special); + } + + return TruncateSummary(CompactPrefix(Summary)); + } + + private string CompactImportSummary() + { + var result = TryArgument("result", out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : "completed"; + var operation = !string.IsNullOrWhiteSpace(Operation) ? Operation : "Import"; + return $"{operation} {result}"; + } + + private bool TryArgument(string key, out string value) + { + if (entry.Arguments is not null + && entry.Arguments.TryGetValue(key, out var argument) + && !string.IsNullOrWhiteSpace(argument?.ToString())) + { + value = argument!.ToString()!; + return true; + } + + value = string.Empty; + return false; + } + + private static string CompactPrefix(string value) + { + var separator = value.IndexOfAny([':', '\uFF1A', '\n', '\r']); + return separator > 0 ? value[..separator].Trim() : value.Trim(); + } + + private static string TruncateSummary(string value) + { + var normalized = value.Trim(); + return normalized.Length <= MaxCompactSummaryLength + ? normalized + : normalized[..(MaxCompactSummaryLength - 1)].TrimEnd() + '\u2026'; + } + private IReadOnlyList CreateStructuredProperties() { var properties = entry.StructuredState! @@ -226,13 +629,24 @@ private static void Append(StringBuilder builder, string? value) private static IReadOnlyList CreateNodes( IEnumerable> values, - int depth = 0) + int depth = 0, + StructuredNodeBudget? budget = null, + HashSet? path = null) { - return - [ - .. values - .Select(pair => CreateNode(pair.Key, pair.Value, depth)) - ]; + budget ??= new StructuredNodeBudget(); + path ??= new HashSet(ReferenceEqualityComparer.Instance); + var nodes = new List(); + foreach (var pair in values) + { + if (!budget.TryTake()) + { + break; + } + + nodes.Add(CreateNode(pair.Key, pair.Value, depth, budget, path)); + } + + return nodes; } private static string FormatNodes(IEnumerable nodes, int depth = 0) @@ -251,22 +665,48 @@ private static string FormatNodes(IEnumerable nodes, return string.Join(Environment.NewLine, lines); } - private static LogStructuredNodeViewModel CreateNode(string name, object? value, int depth) + private static LogStructuredNodeViewModel CreateNode( + string name, + object? value, + int depth, + StructuredNodeBudget budget, + HashSet path) { - if (depth >= 32) + if (depth >= MaxStructuredDepth) { return new LogStructuredNodeViewModel(HumanizeKey(name), "[depth limit]", [], false); } - var children = CreateChildren(value, depth + 1); - return new LogStructuredNodeViewModel( - HumanizeKey(name), - FormatContainer(value, children), - children, - depth == 0 && children.Count > 0); + var runtimeType = value?.GetType(); + var trackReference = value is not null && runtimeType is { IsValueType: false }; + if (trackReference && !path.Add(value!)) + { + return new LogStructuredNodeViewModel(HumanizeKey(name), "[cycle]", [], false); + } + + try + { + var children = CreateChildren(value, depth + 1, budget, path); + return new LogStructuredNodeViewModel( + HumanizeKey(name), + FormatContainer(value, children), + children, + depth == 0 && children.Count > 0); + } + finally + { + if (trackReference) + { + path.Remove(value!); + } + } } - private static IReadOnlyList CreateChildren(object? value, int depth) + private static IReadOnlyList CreateChildren( + object? value, + int depth, + StructuredNodeBudget budget, + HashSet path) { switch (value) { @@ -275,7 +715,9 @@ private static IReadOnlyList CreateChildren(object? readOnlyDictionary .Where(static pair => !IsHiddenStructuredKey(pair.Key)) .OrderBy(static pair => pair.Key, StringComparer.Ordinal), - depth); + depth, + budget, + path); case IDictionary dictionary: { var pairs = new List>(); @@ -284,20 +726,50 @@ private static IReadOnlyList CreateChildren(object? pairs.Add(new KeyValuePair(entry.Key?.ToString() ?? string.Empty, entry.Value)); } - return CreateNodes(pairs.OrderBy(static pair => pair.Key, StringComparer.Ordinal), depth); + return CreateNodes( + pairs.OrderBy(static pair => pair.Key, StringComparer.Ordinal), + depth, + budget, + path); } } if (value is IEnumerable enumerable and not string) { var index = 0; + var children = new List(); + foreach (var item in enumerable) + { + if (!budget.TryTake()) + { + break; + } - return [.. from object? item in enumerable select CreateNode($"#{++index}", item, depth)]; + children.Add(CreateNode($"#{++index}", item, depth, budget, path)); + } + + return children; } return []; } + private sealed class StructuredNodeBudget + { + private int remaining = MaxStructuredNodes; + + public bool TryTake() + { + if (remaining <= 0) + { + return false; + } + + remaining--; + return true; + } + } + private static string FormatContainer(object? value, IReadOnlyList children) { if (children.Count == 0) @@ -348,7 +820,10 @@ private static bool IsHiddenStructuredKey(string key) => string.Equals(key, "MessageKey", StringComparison.Ordinal) || string.Equals(key, "TechnicalDetail", StringComparison.Ordinal) || string.Equals(key, "Operation", StringComparison.Ordinal) || - string.Equals(key, "operation", StringComparison.Ordinal); + string.Equals(key, "operation", StringComparison.Ordinal) || + string.Equals(key, "Summary", StringComparison.Ordinal) || + string.Equals(key, "summary", StringComparison.Ordinal) || + string.Equals(key, "message", StringComparison.Ordinal); /// /// Keys hidden from the flat overview list: infrastructure keys, the operation tag @@ -370,4 +845,13 @@ private static bool IsContainer(object? value) => string text => text, _ => value.ToString() ?? string.Empty }; + + private static void AddContextPart(List parts, string value) + { + if (!string.IsNullOrWhiteSpace(value) + && !parts.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + parts.Add(value.Trim()); + } + } } diff --git a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs index 956a4c96..21280738 100644 --- a/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs +++ b/src/ChapterTool.Avalonia.UI/ViewModels/Tools/LogToolViewModel.cs @@ -1,4 +1,6 @@ +using System.Text.Json; using ChapterTool.Avalonia.UI.Localization; +using ChapterTool.Avalonia.UI.PlatformPorts; using ChapterTool.Contracts.PlatformPorts; namespace ChapterTool.Avalonia.UI.ViewModels.Tools; @@ -13,49 +15,74 @@ public enum LogSeverityFilter public sealed record LogFilterOption(LogSeverityFilter Value, string DisplayName); +public sealed record LogExportFormatOption(LogExportFormat Value, string DisplayName); + +/// +/// Projects the bounded application log into a quiet feed and an on-demand +/// inspector. Selection and inspection are deliberately separate states. +/// public sealed class LogToolViewModel : ObservableViewModel, IDisposable { private readonly IApplicationLogService logService; private readonly IAppLocalizer localizer; private readonly IAppLocalizer contentLocalizer = new AppLocalizationManager("en-US"); private readonly IClipboardService? clipboardService; + private readonly IRuntimeCapabilities? capabilities; + private readonly IApplicationLogExporter? exporter; private readonly SynchronizationContext? synchronizationContext; private readonly List entryViewModels = []; private IReadOnlyList filteredEntries = []; private IReadOnlyList filterOptions; + private IReadOnlyList exportFormatOptions; private LogFilterOption selectedFilter; + private LogExportFormatOption selectedExportFormat; + private bool isDetailsOpen; + private bool isExporting; + private string statusText = string.Empty; + private string? statusResourceKey; + private string? statusArgument; private bool disposed; public LogToolViewModel( IApplicationLogService logService, IAppLocalizer localizer, - IClipboardService? clipboardService = null) + IClipboardService? clipboardService = null, + IApplicationLogExporter? exporter = null, + IRuntimeCapabilities? capabilities = null) { ArgumentNullException.ThrowIfNull(logService); ArgumentNullException.ThrowIfNull(localizer); this.logService = logService; this.localizer = localizer; this.clipboardService = clipboardService; + this.capabilities = capabilities; + this.exporter = exporter; synchronizationContext = SynchronizationContext.Current; filterOptions = CreateFilterOptions(); + exportFormatOptions = CreateExportFormatOptions(); selectedFilter = filterOptions[0]; - ClearCommand = new UiCommand( - (_, _) => - { - logService.Clear(); - return ValueTask.CompletedTask; - }, - _ => entryViewModels.Count > 0); + selectedExportFormat = exportFormatOptions[0]; + + ClearCommand = new UiCommand((_, _) => ClearAsync(), _ => entryViewModels.Count > 0); CopySummaryCommand = new UiCommand( async (_, cancellationToken) => await CopyAsync(SelectedEntry?.Summary, cancellationToken), - _ => clipboardService is not null && SelectedEntry is not null); + _ => CanCopySelected); CopyDetailsCommand = new UiCommand( async (_, cancellationToken) => await CopyAsync(SelectedEntry?.RawText, cancellationToken), - _ => clipboardService is not null && SelectedEntry is not null); + _ => CanCopySelected); + CloseDetailsCommand = new UiCommand((_, _) => CloseDetailsAsync(), _ => IsDetailsOpen); + OpenDetailsCommand = new UiCommand( + (parameter, _) => OpenDetailsAsync(parameter), + parameter => parameter is LogEntryViewModel entry && FilteredEntries.Contains(entry)); + ResetFiltersCommand = new UiCommand((_, _) => ResetFiltersAsync(), _ => HasActiveFilters || HasSearchQuery); + ExportCommand = new UiCommand( + (_, cancellationToken) => ExportAsync(cancellationToken), + _ => CanExport); + logService.EntryAdded += OnEntryAdded; logService.Cleared += OnCleared; localizer.CultureChanged += OnCultureChanged; - RebuildFromService(); + RebuildFromSnapshot(logService.Entries); } public IReadOnlyList FilterOptions @@ -64,31 +91,40 @@ public IReadOnlyList FilterOptions private set => SetProperty(ref filterOptions, value); } + public IReadOnlyList ExportFormatOptions + { + get => exportFormatOptions; + private set => SetProperty(ref exportFormatOptions, value); + } + public LogFilterOption SelectedFilter { get => selectedFilter; set { - if (value is null || !SetProperty(ref selectedFilter, value)) + if (value is not null && SetProperty(ref selectedFilter, value)) { - return; + RefreshProjection(); } - - RefreshFilteredEntries(); } } + public LogExportFormatOption SelectedExportFormat + { + get => selectedExportFormat; + set => SetProperty(ref selectedExportFormat, value); + } + public string SearchText { get; set { - if (!SetProperty(ref field, value)) + var normalized = value ?? string.Empty; + if (SetProperty(ref field, normalized)) { - return; + RefreshProjection(); } - - RefreshFilteredEntries(); } } = string.Empty; @@ -98,6 +134,7 @@ public IReadOnlyList FilteredEntries private set => SetProperty(ref filteredEntries, value); } + /// Gets or sets currently highlighted row. It does not open the inspector. public LogEntryViewModel? SelectedEntry { get; @@ -109,13 +146,79 @@ public LogEntryViewModel? SelectedEntry } OnPropertyChanged(nameof(HasSelectedEntry)); - CopySummaryCommand.RaiseCanExecuteChanged(); - CopyDetailsCommand.RaiseCanExecuteChanged(); + OnPropertyChanged(nameof(ShowDetails)); + OnPropertyChanged(nameof(CanCopySelected)); + OnPropertyChanged(nameof(HasSecondaryActions)); + RaiseCommandStates(); + } + } + + /// Gets a value indicating whether true only while the user is inspecting the selected row. + public bool IsDetailsOpen + { + get => isDetailsOpen; + private set + { + if (SetProperty(ref isDetailsOpen, value)) + { + OnPropertyChanged(nameof(ShowDetails)); + CloseDetailsCommand.RaiseCanExecuteChanged(); + } + } + } + + public bool IsExporting + { + get => isExporting; + private set + { + if (SetProperty(ref isExporting, value)) + { + OnPropertyChanged(nameof(CanExport)); + OnPropertyChanged(nameof(HasSecondaryActions)); + ExportCommand.RaiseCanExecuteChanged(); + } + } + } + + public string StatusText + { + get => statusText; + private set + { + if (SetProperty(ref statusText, value)) + { + OnPropertyChanged(nameof(HasStatus)); + } } } public bool HasSelectedEntry => SelectedEntry is not null; + public bool ShowDetails => IsDetailsOpen && HasSelectedEntry; + + public bool HasRetainedEntries => entryViewModels.Count > 0; + + public bool HasNoResults => HasRetainedEntries && IsEmpty; + + public bool HasSearchQuery => !string.IsNullOrWhiteSpace(SearchText); + + public bool HasStatus => !string.IsNullOrWhiteSpace(StatusText); + + public bool HasClipboard => clipboardService is not null && (capabilities?.CanWriteClipboard ?? true); + + public bool HasExporter => exporter is not null; + + public bool CanCopySelected => HasClipboard && SelectedEntry is not null; + + public bool CanExport => HasExporter && FilteredEntries.Count > 0 && !IsExporting; + + public bool HasSecondaryActions => HasRetainedEntries || CanCopySelected || CanExport; + + public int ActiveFilterCount => SelectedFilter.Value == LogSeverityFilter.All ? 0 : 1; + + public bool HasActiveFilters => SelectedFilter.Value != LogSeverityFilter.All; + public bool IsEmpty => FilteredEntries.Count == 0; public string EntryCountText => localizer.FormatPositional("Tool.Log.EntryCount", FilteredEntries.Count); @@ -126,6 +229,14 @@ public LogEntryViewModel? SelectedEntry public UiCommand CopyDetailsCommand { get; } + public UiCommand CloseDetailsCommand { get; } + + public UiCommand OpenDetailsCommand { get; } + + public UiCommand ResetFiltersCommand { get; } + + public UiCommand ExportCommand { get; } + public void Dispose() { if (disposed) @@ -139,100 +250,126 @@ public void Dispose() localizer.CultureChanged -= OnCultureChanged; } - private void OnEntryAdded(object? sender, ApplicationLogEntry entry) + private void OnEntryAdded(object? sender, ApplicationLogEntry entry) => Dispatch(() => AppendEntry(entry)); + + private void OnCleared(object? sender, EventArgs args) => Dispatch(RebuildAfterClear); + + private void Dispatch(Action action) { if (synchronizationContext is null) { - AppendEntry(entry); - return; + action(); + } + else + { + synchronizationContext.Post(_ => action(), null); } - - synchronizationContext.Post(_ => AppendEntry(entry), null); } - private void OnCleared(object? sender, EventArgs args) + private void AppendEntry(ApplicationLogEntry entry) { - if (synchronizationContext is null) + if (disposed) { - RebuildAfterClear(); return; } - synchronizationContext.Post(_ => RebuildAfterClear(), null); - } + var retained = logService.Entries.ToHashSet(ReferenceEqualityComparer.Instance); + var selectedBeforeUpdate = SelectedEntry?.Entry; + entryViewModels.RemoveAll(item => !retained.Contains(item.Entry)); + if (retained.Contains(entry) + && entryViewModels.All(item => !ReferenceEquals(item.Entry, entry))) + { + entryViewModels.Add(CreateEntry(entry)); + } - private void OnCultureChanged(object? sender, EventArgs args) - { - FilterOptions = CreateFilterOptions(); - SelectedFilter = FilterOptions.First(option => option.Value == selectedFilter.Value); - foreach (var entry in entryViewModels) + SortEntries(); + if (selectedBeforeUpdate is not null + && !entryViewModels.Any(item => ReferenceEquals(item.Entry, selectedBeforeUpdate))) { - entry.RefreshLocalizedProperties(); + SelectedEntry = null; + IsDetailsOpen = false; } - RefreshFilteredEntries(); + RefreshProjection(); } - private IReadOnlyList CreateFilterOptions() => - [ - new(LogSeverityFilter.All, localizer.GetString("Tool.Log.FilterAll")), - new(LogSeverityFilter.Information, localizer.GetString("Tool.Log.FilterInformation")), - new(LogSeverityFilter.Warning, localizer.GetString("Tool.Log.FilterWarning")), - new(LogSeverityFilter.Error, localizer.GetString("Tool.Log.FilterError")) - ]; - - private void AppendEntry(ApplicationLogEntry entry) + private async ValueTask ExportAsync(CancellationToken cancellationToken) { - if (disposed) + if (exporter is null || IsExporting) { return; } - var snapshot = logService.Entries; - var appendsEntry = snapshot.Count == entryViewModels.Count + 1 - && PrefixMatches(snapshot, entryViewModels, entryViewModels.Count) - && ReferenceEquals(snapshot[^1], entry); - var evictsThenAppends = snapshot.Count == entryViewModels.Count - && snapshot.Count > 0 - && PrefixMatches(snapshot, entryViewModels, snapshot.Count - 1, existingOffset: 1) - && ReferenceEquals(snapshot[^1], entry); - - if (!appendsEntry && !evictsThenAppends) + IsExporting = true; + try { - RebuildFromSnapshot(snapshot); - return; + var request = new ApplicationLogExportRequest( + SelectedExportFormat.Value, + [.. FilteredEntries.Select(static item => item.Entry)]); + var result = await exporter.ExportAsync(request, cancellationToken); + SetExportStatus(result.Succeeded, result.Succeeded ? result.Path : result.Error); } - - var viewModel = new LogEntryViewModel(entry, localizer, contentLocalizer); - if (evictsThenAppends) + catch (IOException exception) { - entryViewModels.RemoveAt(0); + SetExportStatus(false, exception.Message); } - - entryViewModels.Add(viewModel); - if (evictsThenAppends) + catch (UnauthorizedAccessException exception) { - RefreshFilteredEntries(); - return; + SetExportStatus(false, exception.Message); + } + catch (Exception exception) when (exception is ArgumentException or JsonException or NotSupportedException or InvalidOperationException or System.Security.SecurityException) + { + SetExportStatus(false, exception.Message); } + finally + { + IsExporting = false; + } + } + + private ValueTask ClearAsync() + { + logService.Clear(); + return ValueTask.CompletedTask; + } + + private ValueTask CloseDetailsAsync() + { + // Keep the row selected so the same event can be inspected again. + IsDetailsOpen = false; + return ValueTask.CompletedTask; + } - if (viewModel.Matches(SelectedFilter.Value) && viewModel.MatchesSearch(SearchText)) + private ValueTask OpenDetailsAsync(object? parameter) + { + if (parameter is LogEntryViewModel entry && FilteredEntries.Contains(entry)) { - FilteredEntries = [.. filteredEntries, viewModel]; - SelectedEntry ??= viewModel; - RaiseListStateChanged(); + SelectedEntry = entry; + IsDetailsOpen = true; } + + return ValueTask.CompletedTask; + } + + private ValueTask ResetFiltersAsync() + { + SearchText = string.Empty; + SelectedFilter = FilterOptions[0]; + return ValueTask.CompletedTask; } private void RebuildAfterClear() { - if (!disposed) + if (disposed) { - RebuildFromService(); + return; } - } - private void RebuildFromService() => RebuildFromSnapshot(logService.Entries); + entryViewModels.Clear(); + SelectedEntry = null; + IsDetailsOpen = false; + RefreshProjection(); + } private void RebuildFromSnapshot(IReadOnlyList entries) { @@ -241,64 +378,129 @@ private void RebuildFromSnapshot(IReadOnlyList entries) { existing[item.Entry] = item; } - entryViewModels.Clear(); foreach (var entry in entries) { entryViewModels.Add(existing.TryGetValue(entry, out var viewModel) ? viewModel - : new LogEntryViewModel(entry, localizer, contentLocalizer)); + : CreateEntry(entry)); } - RefreshFilteredEntries(); + SortEntries(); + RefreshProjection(); } - private static bool PrefixMatches( - IReadOnlyList snapshot, - IReadOnlyList existing, - int count, - int existingOffset = 0) + private void OnCultureChanged(object? sender, EventArgs args) { - for (var index = 0; index < count; index++) + var severity = SelectedFilter.Value; + var format = SelectedExportFormat.Value; + FilterOptions = CreateFilterOptions(); + ExportFormatOptions = CreateExportFormatOptions(); + selectedFilter = FilterOptions.Single(option => option.Value == severity); + selectedExportFormat = ExportFormatOptions.Single(option => option.Value == format); + OnPropertyChanged(nameof(SelectedFilter)); + OnPropertyChanged(nameof(SelectedExportFormat)); + if (statusResourceKey is not null) { - if (!ReferenceEquals(snapshot[index], existing[index + existingOffset].Entry)) - { - return false; - } + StatusText = localizer.FormatPositional(statusResourceKey, statusArgument ?? string.Empty); + } + foreach (var entry in entryViewModels) + { + entry.RefreshLocalizedProperties(); } - return true; + RefreshProjection(); } - private void RefreshFilteredEntries() + private void RefreshProjection() { var previousEntry = SelectedEntry?.Entry; + foreach (var entry in entryViewModels) + { + entry.ApplySearchHighlight(SearchText); + } + FilteredEntries = [ .. entryViewModels - .Where(entry => entry.Matches(SelectedFilter.Value) && entry.MatchesSearch(SearchText)) + .Where(entry => entry.Matches(SelectedFilter.Value)) + .Where(entry => entry.MatchesSearch(SearchText)) + .OrderByDescending(static entry => entry.Entry.Timestamp) ]; - SelectedEntry = FilteredEntries.FirstOrDefault(entry => ReferenceEquals(entry.Entry, previousEntry)) - ?? FilteredEntries.LastOrDefault(); + + var stillVisible = FilteredEntries.FirstOrDefault(entry => ReferenceEquals(entry.Entry, previousEntry)); + if (previousEntry is not null && stillVisible is null) + { + SelectedEntry = null; + IsDetailsOpen = false; + } + + // Filtering and live updates must not select a replacement entry. Keep + // an explicit selection only while that same entry remains visible. + else if (previousEntry is not null && !ReferenceEquals(SelectedEntry, stillVisible)) + { + SelectedEntry = stillVisible; + } + + OnPropertyChanged(nameof(HasActiveFilters)); + OnPropertyChanged(nameof(ActiveFilterCount)); + OnPropertyChanged(nameof(HasSearchQuery)); RaiseListStateChanged(); } private void RaiseListStateChanged() { OnPropertyChanged(nameof(IsEmpty)); + OnPropertyChanged(nameof(HasRetainedEntries)); + OnPropertyChanged(nameof(HasNoResults)); OnPropertyChanged(nameof(EntryCountText)); + OnPropertyChanged(nameof(HasSecondaryActions)); + OnPropertyChanged(nameof(CanCopySelected)); + OnPropertyChanged(nameof(CanExport)); + RaiseCommandStates(); + } + + private void RaiseCommandStates() + { ClearCommand.RaiseCanExecuteChanged(); CopySummaryCommand.RaiseCanExecuteChanged(); CopyDetailsCommand.RaiseCanExecuteChanged(); + CloseDetailsCommand.RaiseCanExecuteChanged(); + OpenDetailsCommand.RaiseCanExecuteChanged(); + ResetFiltersCommand.RaiseCanExecuteChanged(); + ExportCommand.RaiseCanExecuteChanged(); } private async ValueTask CopyAsync(string? text, CancellationToken cancellationToken) { - if (clipboardService is null || string.IsNullOrWhiteSpace(text)) + if (clipboardService is not null && !string.IsNullOrWhiteSpace(text)) { - return; + await clipboardService.SetTextAsync(text, cancellationToken); } + } + + private LogEntryViewModel CreateEntry(ApplicationLogEntry entry) => new(entry, localizer, contentLocalizer); - await clipboardService.SetTextAsync(text, cancellationToken); + private void SetExportStatus(bool succeeded, string? value) + { + statusResourceKey = succeeded ? "Tool.Log.ExportSucceeded" : "Tool.Log.ExportFailed"; + statusArgument = value ?? string.Empty; + StatusText = localizer.FormatPositional(statusResourceKey, statusArgument); } + + private void SortEntries() => entryViewModels.Sort(static (left, right) => right.Entry.Timestamp.CompareTo(left.Entry.Timestamp)); + + private IReadOnlyList CreateFilterOptions() => + [ + new(LogSeverityFilter.All, localizer.GetString("Tool.Log.FilterAll")), + new(LogSeverityFilter.Information, localizer.GetString("Tool.Log.FilterInformation")), + new(LogSeverityFilter.Warning, localizer.GetString("Tool.Log.FilterWarning")), + new(LogSeverityFilter.Error, localizer.GetString("Tool.Log.FilterError")) + ]; + + private IReadOnlyList CreateExportFormatOptions() => + [ + new(LogExportFormat.Json, localizer.GetString("Tool.Log.ExportJson")), + new(LogExportFormat.Csv, localizer.GetString("Tool.Log.ExportCsv")) + ]; } diff --git a/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml b/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml index cf82b5a9..e29f9c56 100644 --- a/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml +++ b/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml @@ -3,40 +3,240 @@ xmlns:i="https://github.com/projektanker/icons.avalonia" xmlns:vm="using:ChapterTool.Avalonia.UI.ViewModels.Tools" x:Class="ChapterTool.Avalonia.UI.Views.Tools.LogToolView" + x:Name="RootView" x:DataType="vm:LogToolViewModel" - x:CompileBindings="True"> - - - - - - - + x:CompileBindings="True" + Focusable="True" + DataContextChanged="OnDataContextChanged" + SizeChanged="OnSizeChanged" + KeyDown="OnKeyDown"> + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + BorderThickness="1" + ClipToBounds="True"> + + - - - @@ -44,68 +244,76 @@ - + - - - - - + - - - + + + + + + + - - + + @@ -113,306 +321,154 @@ - + - + TextAlignment="Center" /> - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - + + + + + + + + - + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - + BorderThickness="0,1,0,0" + IsVisible="{Binding HasStatus}"> + diff --git a/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml.cs b/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml.cs index 2d51bca1..338bbb26 100644 --- a/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml.cs +++ b/src/ChapterTool.Avalonia.UI/Views/Tools/LogToolView.axaml.cs @@ -1,12 +1,204 @@ +using System.ComponentModel; +using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Highlighting; +using ChapterTool.Avalonia.UI.ViewModels.Tools; namespace ChapterTool.Avalonia.UI.Views.Tools; /// Provides the structured application log view. public sealed partial class LogToolView : UserControl { + private const double NarrowLayoutThreshold = 820; + private const double InspectorWidth = 420; + private LogToolViewModel? subscribedViewModel; + private bool previousDetailsOpen; + private TextEditor? rawEditor; + public LogToolView() { InitializeComponent(); + rawEditor = new TextEditor + { + IsReadOnly = true, + ShowLineNumbers = true, + WordWrap = false, + FontSize = 12, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Background = Brushes.Transparent, + Padding = new Thickness(6, 4), + Document = new TextDocument() + }; + rawEditor.SyntaxHighlighting = HighlightingManager.Instance.GetDefinition("Json"); + LogRawEditorHost.Content = rawEditor; + } + + private void OnDataContextChanged(object? sender, EventArgs args) + { + if (subscribedViewModel is not null) + { + subscribedViewModel.PropertyChanged -= OnViewModelPropertyChanged; + } + + subscribedViewModel = DataContext as LogToolViewModel; + if (subscribedViewModel is not null) + { + subscribedViewModel.PropertyChanged += OnViewModelPropertyChanged; + previousDetailsOpen = subscribedViewModel.ShowDetails; + } + else + { + previousDetailsOpen = false; + } + + ApplyResponsiveLayout(); + + // SizeChanged is raised before descendants receive their final bounds. + // Reapply once the layout pass has propagated the new width. + Dispatcher.UIThread.Post(ApplyResponsiveLayout); + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if (args.PropertyName is nameof(LogToolViewModel.ShowDetails) + or nameof(LogToolViewModel.HasSelectedEntry) + or nameof(LogToolViewModel.SelectedEntry)) + { + var detailsOpen = subscribedViewModel?.ShowDetails == true; + var changed = detailsOpen != previousDetailsOpen; + previousDetailsOpen = detailsOpen; + ApplyResponsiveLayout(); + UpdateRawEditor(); + if (changed) + { + HideFlyouts(); + Dispatcher.UIThread.Post(detailsOpen ? FocusInspector : () => FocusEntry(subscribedViewModel?.SelectedEntry)); + } + } + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs args) + { + if (args.PreviousSize.Width > 0 && Math.Abs(args.PreviousSize.Width - args.NewSize.Width) > 0.5) + { + HideFlyouts(); + } + + ApplyResponsiveLayout(); + Dispatcher.UIThread.Post(ApplyResponsiveLayout); + } + + private void HideFlyouts() + { + LogFilterButton.Flyout?.Hide(); + LogMoreButton.Flyout?.Hide(); + } + + private void ApplyResponsiveLayout() + { + if (LogList is null || LogListHost is null || LogLayout is null || LogDetailsPanel is null || LogContentSurface is null) + { + return; + } + + var width = LogContentSurface.Bounds.Width; + if (width <= 0) + { + return; + } + + var narrow = width <= NarrowLayoutThreshold; + var detailsOpen = DataContext is LogToolViewModel viewModel && viewModel.ShowDetails; + + LogLayout.ColumnDefinitions[0].Width = detailsOpen && narrow + ? new GridLength(0) + : new GridLength(1, GridUnitType.Star); + LogLayout.ColumnDefinitions[1].Width = !detailsOpen + ? new GridLength(0) + : narrow + ? new GridLength(1, GridUnitType.Star) + : new GridLength(InspectorWidth, GridUnitType.Pixel); + LogListHost.IsVisible = !detailsOpen || !narrow; + LogDetailsPanel.HorizontalAlignment = HorizontalAlignment.Stretch; + LogDetailsPanel.Width = double.NaN; + LogDetailsPanel.MaxWidth = narrow ? double.PositiveInfinity : InspectorWidth; + } + + private void UpdateRawEditor() + { + if (rawEditor is null) + { + return; + } + + var text = (DataContext as LogToolViewModel)?.SelectedEntry?.RawText ?? string.Empty; + if (!string.Equals(rawEditor.Text, text, StringComparison.Ordinal)) + { + rawEditor.Document = new TextDocument(text); + } + + } + + private void FocusInspector() + { + if (LogDetailsCloseButton.IsVisible) + { + LogDetailsCloseButton.Focus(); + } + } + + private void OnKeyDown(object? sender, KeyEventArgs args) + { + if (DataContext is not LogToolViewModel viewModel) + { + return; + } + + if (args.Key == Key.Escape && viewModel.ShowDetails) + { + args.Handled = true; + viewModel.CloseDetailsCommand.Execute(null); + return; + } + + if (args.Key is not (Key.Enter or Key.Space) + || viewModel.SelectedEntry is not { } selected + || !IsListInput(args.Source as Visual)) + { + return; + } + + args.Handled = true; + viewModel.OpenDetailsCommand.Execute(selected); + } + + private bool IsListInput(Visual? source) + { + return source is not null + && (ReferenceEquals(source, LogList) + || ((source.FindAncestorOfType() is { } list) && ReferenceEquals(list, LogList))); + } + + private void FocusEntry(LogEntryViewModel? entry) + { + if (entry is not null && LogList.ContainerFromItem(entry) is Control container) + { + var detailsAction = container.GetVisualDescendants() + .OfType