diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..3361701 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,65 @@ +name: Benchmarks + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +concurrency: + group: benchmarks + cancel-in-progress: false + +permissions: + contents: read + actions: read + +jobs: + + benchmarks: + + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Skip the scheduled run when HEAD is unchanged since the last successful benchmark run + - name: Detect changes since last benchmark run + id: changes + if: github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + script: | + const runs = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'benchmarks.yml', + status: 'success', + per_page: 1, + }); + const last = runs.data.workflow_runs[0]; + const changed = !last || last.head_sha !== context.sha; + core.info(`last=${last?.head_sha ?? 'none'} current=${context.sha} changed=${changed}`); + core.setOutput('changed', changed); + + - name: Install .NET Core + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.changed == 'true' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Run benchmarks + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.changed == 'true' + run: dotnet run --project LogGrokX.Benchmarks -c Release -- --filter "*" --exporters json html + + - name: Upload results + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.changed == 'true' + uses: actions/upload-artifact@v4 + with: + name: benchmark-results-${{ github.sha }} + path: BenchmarkDotNet.Artifacts/ + if-no-files-found: error + + - name: No changes + if: github.event_name == 'schedule' && steps.changes.outputs.changed != 'true' + run: echo "No changes since the last successful benchmark run; skipping." diff --git a/.github/workflows/build_upload.yml b/.github/workflows/build_upload.yml index 28af591..2a7b0f5 100644 --- a/.github/workflows/build_upload.yml +++ b/.github/workflows/build_upload.yml @@ -12,10 +12,9 @@ jobs: steps: # Install the .NET Core workload - name: Install .NET Core - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x - include-prerelease: true + dotnet-version: 10.0.x # Add MSBuild to the PATH: https://github.com/microsoft/setup-msbuild - name: Setup MSBuild.exe @@ -40,5 +39,5 @@ jobs: - name: Upload Artifact uses: actions/upload-artifact@v4 with: - name: LogGrokCore-build-${{ github.run_number }} + name: LogGrokX-build-${{ github.run_number }} path: ${{ github.workspace }}/bin/Release/**/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..78b88fa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,270 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g. 1.0.0)' + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: windows-latest + timeout-minutes: 60 + + env: + VERSION: ${{ github.event.inputs.version || github.ref_name }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Install .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + cache: true + cache-dependency-path: '**/*.csproj' + + - name: Determine version + id: version + shell: pwsh + run: | + $ver = "${{ env.VERSION }}" -replace '^[vV]', '' + if (-not $ver) { $ver = "1.0.0" } + if ($ver -notmatch '^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$') { + throw "Invalid version '$ver'. Expected SemVer, e.g. 1.2.3 or 1.2.3-beta.1" + } + $core = $ver -replace '\+.*$', '' + $isPrerelease = $core -match '-' + $tag = "${{ env.VERSION }}" + if ($tag -notmatch '^[vV]') { $tag = "v$tag" } + echo "RELEASE_VERSION=$ver" >> $env:GITHUB_OUTPUT + echo "RELEASE_TAG=$tag" >> $env:GITHUB_OUTPUT + echo "IS_PRERELEASE=$($isPrerelease.ToString().ToLower())" >> $env:GITHUB_OUTPUT + echo "Version: $ver (tag: $tag)" + + - name: Restore + run: dotnet restore + + - name: Run tests + run: dotnet test --configuration Release --no-restore --nologo --verbosity normal + + - name: Publish self-contained (x64) + run: | + dotnet publish LogGrokX/LogGrokX.csproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:CopyOutputSymbolsToPublishDirectory=false ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true ` + -p:Version=${{ steps.version.outputs.RELEASE_VERSION }} ` + --nologo ` + --output ./publish/win-x64-selfcontained + + - name: Publish framework-dependent (x64) + run: | + dotnet publish LogGrokX/LogGrokX.csproj ` + --configuration Release ` + --runtime win-x64 ` + --self-contained false ` + -p:Version=${{ steps.version.outputs.RELEASE_VERSION }} ` + --nologo ` + --output ./publish/win-x64-fdd + + - name: Publish self-contained (x86) + run: | + dotnet publish LogGrokX/LogGrokX.csproj ` + --configuration Release ` + --runtime win-x86 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:CopyOutputSymbolsToPublishDirectory=false ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true ` + -p:Version=${{ steps.version.outputs.RELEASE_VERSION }} ` + --nologo ` + --output ./publish/win-x86-selfcontained + + - name: Publish framework-dependent (x86) + run: | + dotnet publish LogGrokX/LogGrokX.csproj ` + --configuration Release ` + --runtime win-x86 ` + --self-contained false ` + -p:Version=${{ steps.version.outputs.RELEASE_VERSION }} ` + --nologo ` + --output ./publish/win-x86-fdd + + - name: Smoke test (version) + shell: pwsh + run: | + $expected = "${{ steps.version.outputs.RELEASE_VERSION }}" + $expectedNumeric = $expected -replace '^(\d+\.\d+\.\d+).*$', '$1' + foreach ($arch in @("x64", "x86")) { + $exe = "./publish/win-$arch-selfcontained/LogGrokX.exe" + if (-not (Test-Path $exe)) { throw "Executable not found: $exe" } + + $info = (Get-Item $exe).VersionInfo + if ($info.ProductVersion -ne $expected) { + throw "ProductVersion mismatch for ${arch}: expected '$expected', got '$($info.ProductVersion)'" + } + if ($info.FileVersion -ne $expectedNumeric) { + throw "FileVersion mismatch for ${arch}: expected '$expectedNumeric', got '$($info.FileVersion)'" + } + + $stdout = New-TemporaryFile + $process = Start-Process -FilePath $exe -ArgumentList "--version" -Wait -PassThru -RedirectStandardOutput $stdout + $reported = (Get-Content $stdout -Raw).Trim() + Remove-Item $stdout -Force + if ($process.ExitCode -ne 0) { throw "Smoke test exit code for ${arch}: $($process.ExitCode)" } + if ($reported -ne $expected) { + throw "Reported version mismatch for ${arch}: expected '$expected', got '$reported'" + } + Write-Host "Smoke test passed for ${arch}: $reported" + } + + - name: Install Inno Setup + shell: pwsh + run: | + choco install innosetup --version=6.7.1 -y --no-progress + echo "C:\Program Files (x86)\Inno Setup 6" >> $env:GITHUB_PATH + + - name: Build installers + shell: pwsh + run: | + $version = "${{ steps.version.outputs.RELEASE_VERSION }}" + $iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + + # Bundle the self-contained builds so the installers do not require a .NET runtime. + New-Item -ItemType Directory -Force -Path ./installer/publish-x64 | Out-Null + New-Item -ItemType Directory -Force -Path ./installer/publish-x86 | Out-Null + Copy-Item -Path ./publish/win-x64-selfcontained/* -Destination ./installer/publish-x64/ -Recurse + Copy-Item -Path ./publish/win-x86-selfcontained/* -Destination ./installer/publish-x86/ -Recurse + + & $iscc ` + /DMyAppVersion="$version" ` + /DMyArchSuffix=x64 ` + /DMyArchitecturesAllowed=x64compatible ` + /DMySourceDir=publish-x64 ` + /O"./artifacts" ` + "./installer/LogGrokX.iss" + + & $iscc ` + /DMyAppVersion="$version" ` + /DMyArchSuffix=x86 ` + /DMyArchitecturesAllowed=x86compatible ` + "/DMyArchitecturesInstallIn64BitModeLine=;" ` + "/DMyAppIdLine=AppId={{E9F2AF62-B22A-458C-88CF-1078F8A6CB65}-x86" ` + /DMySourceDir=publish-x86 ` + /O"./artifacts" ` + "./installer/LogGrokX.iss" + + - name: Sign installers + shell: pwsh + env: + CODESIGN_CERTIFICATE: ${{ secrets.CODESIGN_CERTIFICATE }} + CODESIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODESIGN_CERTIFICATE_PASSWORD }} + run: | + if (-not $env:CODESIGN_CERTIFICATE) { + Write-Host "CODESIGN_CERTIFICATE is not configured; skipping code signing." + exit 0 + } + $certPath = Join-Path $env:RUNNER_TEMP "codesign.pfx" + [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String($env:CODESIGN_CERTIFICATE)) + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\' } | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $signtool) { throw "signtool.exe was not found." } + $files = Get-ChildItem ./artifacts -File -Filter "*-setup.exe" + foreach ($file in $files) { + & $signtool.FullName sign /fd SHA256 /td SHA256 /tr "http://timestamp.digicert.com" /f $certPath /p $env:CODESIGN_CERTIFICATE_PASSWORD $file.FullName + if ($LASTEXITCODE -ne 0) { throw "Signing failed: $($file.FullName)" } + } + Remove-Item $certPath -Force + + - name: Create ZIP archives + shell: pwsh + run: | + $version = "${{ steps.version.outputs.RELEASE_VERSION }}" + Compress-Archive -Path ./publish/win-x64-selfcontained/* -DestinationPath "./artifacts/LogGrokX-$version-win-x64-selfcontained.zip" + Compress-Archive -Path ./publish/win-x64-fdd/* -DestinationPath "./artifacts/LogGrokX-$version-win-x64-portable.zip" + Compress-Archive -Path ./publish/win-x86-selfcontained/* -DestinationPath "./artifacts/LogGrokX-$version-win-x86-selfcontained.zip" + Compress-Archive -Path ./publish/win-x86-fdd/* -DestinationPath "./artifacts/LogGrokX-$version-win-x86-portable.zip" + + - name: Generate checksums + shell: pwsh + run: | + Get-ChildItem ./artifacts -File | Sort-Object Name | ForEach-Object { + $hash = (Get-FileHash -Algorithm SHA256 -Path $_.FullName).Hash + "$hash $($_.Name)" + } | Set-Content -Path ./artifacts/SHA256SUMS.txt -Encoding ascii + Get-Content ./artifacts/SHA256SUMS.txt + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }} + path: ./artifacts/* + retention-days: 14 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.version.outputs.RELEASE_TAG }} + name: LogGrokX ${{ steps.version.outputs.RELEASE_VERSION }} + draft: false + prerelease: ${{ steps.version.outputs.IS_PRERELEASE == 'true' }} + generate_release_notes: true + files: | + ./artifacts/LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-x64-setup.exe + ./artifacts/LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-x86-setup.exe + ./artifacts/LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-win-x64-selfcontained.zip + ./artifacts/LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-win-x64-portable.zip + ./artifacts/LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-win-x86-selfcontained.zip + ./artifacts/LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-win-x86-portable.zip + ./artifacts/SHA256SUMS.txt + body: | + ## Installation + + ### Installer (Recommended) + - 64-bit Windows: `LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-x64-setup.exe` + - 32-bit Windows: `LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-x86-setup.exe` + + Run the installer matching your Windows version. + - Self-contained: no .NET runtime required + - Creates Start Menu shortcut + - Optional desktop shortcut + - Optional .log/.txt file associations + - Standard uninstaller via Add/Remove Programs + + ### Portable (Self-contained) + No .NET runtime required — extract and run. + - `LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-win-x64-selfcontained.zip` (64-bit) + - `LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-win-x86-selfcontained.zip` (32-bit) + + ### Portable (Framework-dependent) + Requires [.NET 10 runtime](https://dotnet.microsoft.com/download/dotnet/10.0) — extract and run. + - `LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-win-x64-portable.zip` (64-bit) + - `LogGrokX-${{ steps.version.outputs.RELEASE_VERSION }}-win-x86-portable.zip` (32-bit) + + ## Verification + SHA-256 checksums for all assets are provided in `SHA256SUMS.txt`. + + ## Requirements + - Windows 10 or later diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5a8c104..077bc5b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -25,10 +25,9 @@ jobs: # Install the .NET Core workload - name: Install .NET Core - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x - include-prerelease: true + dotnet-version: 10.0.x # Add MSBuild to the PATH: https://github.com/microsoft/setup-msbuild - name: Setup MSBuild.exe @@ -45,3 +44,41 @@ jobs: # Execute all unit tests in the solution - name: Execute unit tests run: dotnet test --configuration ${{matrix.configuration}} --no-restore --verbosity normal + + benchmark_smoke: + + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + # Validate that the benchmarks build and run; real measurements happen in the nightly workflow + - name: Run benchmarks smoke test + run: dotnet run --project LogGrokX.Benchmarks -c Release -- --filter "*" --job Dry + + installer_compile: + + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Inno Setup + shell: pwsh + run: choco install innosetup --version=6.7.1 -y --no-progress + + - name: Compile installer script + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path ./installer/publish-x64 | Out-Null + Copy-Item ./LogGrokX/appsettings.yaml ./installer/publish-x64/ + Set-Content ./installer/publish-x64/LogGrokX.exe "stub" + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DMyAppVersion=0.0.0 /O"./artifacts" "./installer/LogGrokX.iss" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.gitignore b/.gitignore index ce145f9..8d02355 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ *.user /bin/ *_wpftmp.csproj +/publish/ +/artifacts/ +/installer/publish*/ +BenchmarkDotNet.Artifacts/ diff --git a/.opencode/command/verify.md b/.opencode/command/verify.md new file mode 100644 index 0000000..6b66830 --- /dev/null +++ b/.opencode/command/verify.md @@ -0,0 +1,13 @@ +--- +description: Rebuild the whole solution and run all tests +agent: build +--- + +Rebuild `LogGrokX.sln` and run the full test suite. + +1. Run `dotnet build LogGrokX.sln -t:Rebuild`. +2. Run `dotnet test LogGrokX.sln`. +3. Report the number of warnings/errors and the test pass/fail counts. +4. If anything fails, show the relevant compiler or test output and propose a fix. + +The build must end with 0 warnings and 0 errors. diff --git a/.opencode/skills/dotnet/SKILL.md b/.opencode/skills/dotnet/SKILL.md new file mode 100644 index 0000000..098f8d2 --- /dev/null +++ b/.opencode/skills/dotnet/SKILL.md @@ -0,0 +1,48 @@ +--- +name: dotnet +description: Use when building, testing, running, formatting, or verifying the C#/.NET solution (LogGrokX.sln), or when editing .cs, .xaml, .csproj, appsettings.yaml, or nlog.config. Covers the dotnet CLI workflow and this repo's warnings-as-errors rules. +--- + +# .NET workflow (LogGrokX) + +Windows PowerShell shell, repository root `C:\ai-projects\LogGrokCore`. + +## Verify a change + +Always finish code changes with a clean rebuild and the full test run: + +```powershell +dotnet build LogGrokX.sln -t:Rebuild +dotnet test LogGrokX.sln +``` + +For a faster inner loop, `dotnet build LogGrokX.sln` and +`dotnet test LogGrokX.sln` (incremental) are fine; `-t:Rebuild` before the +final check. + +## Running the app + +```powershell +dotnet run --project LogGrokX\LogGrokX.csproj +``` + +The app is a WPF `WinExe`; it opens a window and keeps running. It writes +diagnostic logs to `%LOCALAPPDATA%\LogGrokX\`. If it exits immediately with +code 0, another instance is already running (single-instance manager). + +## Constraints + +- `Nullable=enable` and `WarningsAsErrors` for `NU1605` plus the nullable + `CS86xx` family: a valid change has **0 warnings**. +- Prefer `dotnet build`/`dotnet test` over launching MSBuild or VS. +- Do not use `cd`; pass `workdir` to the shell tool or `--project`. +- Do not add comments to code. + +## When touching configuration + +- `appsettings.yaml` is hot-reloaded at runtime and deserialized into settings + classes; keep YAML keys matching the C# property names. +- `nlog.config` must stay NLog 6 compatible: no `concurrentWrites`, and + `${threadid}` takes no properties. +- AvalonDock layout XML uses the `Dirkster.AvalonDock.Serializer.Xml` package + and the `AvalonDock.Serializer.Xml` namespace. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5cec82c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,200 @@ +# AGENTS.md + +Guidance for AI agents working in this repository. + +## Project + +**LogGrokX** — a fast WPF log viewer for very large log files. It parses +structured log lines with configurable regex formats, builds in-memory indexes, +and supports search, filtering, colorization and marking while streaming files +that may be many gigabytes. + +- Platform: **Windows only** (WPF). +- Target framework: `net10.0-windows` / `net10.0`. +- SDK pinned by `global.json` (10.0.100, `rollForward: latestMajor`). + +## Commands + +Run from the repository root unless noted. The OS shell is Windows PowerShell. + +```powershell +dotnet restore +dotnet build LogGrokX.sln +dotnet test LogGrokX.sln +dotnet run --project LogGrokX\LogGrokX.csproj +dotnet run --project LogGrokX.Benchmarks -c Release +dotnet format LogGrokX.sln +``` + +- The build treats several warnings as errors (`NU1605` and the nullable + `CS86xx` family). A clean build must have **0 warnings**. +- Build output is redirected to `bin\Debug\` / `bin\Release\` (not the default + per-project `bin`). +- After changing code, always verify with `dotnet build` and `dotnet test`. +- Do not commit unless the user explicitly asks. + +## Layout + +| Project | Purpose | +| --- | --- | +| `LogGrokX` | WPF app: views, view models, controls, theming, DI bootstrap. | +| `LogGrokX.Data` | UI-agnostic core: stream loading, line parsing, indexes, search, virtualization. | +| `LogGrokX.Tests` | Tests for the UI layer. | +| `LogGrokX.Data.Tests` | Tests for the core data layer. | +| `LogGrokX.Benchmarks` | BenchmarkDotNet benchmarks for the core data layer. | + +Key areas in `LogGrokX.Data`: `Loader`/`LoaderImpl` (buffered line-aware +reader), `RegexBasedLineParser`, `IndexTree`/`LineIndex`/`SearchLineIndex`, +`Search`/`Pipeline`, `Virtualization`, and `MergedLineOrder`/`MergeSource`/ +`MergedLineRef`/`TimeIndex` (k-way, time-ordered merge of several parsed logs). + +`LogGrokX.Benchmarks` covers line parsing (`LineParsingBenchmark`), stream +loading (`LoaderBenchmark`) and the merge core (`MergeBenchmark`: +`MergedLineOrder.Build`, `MergedLineOrder.BuildTimeIndex`, +`TimeIndex.FindLineRange`). + +The UI layer uses **WPF-UI 4.3.0** (Fluent controls/theming) and +**AvalonDock 5** for docking. Branding/window title is **LogGrokX** plus the +build version: `BuildInfo.Version` (release `-p:Version`, default `2.1`). +JSON folding lives in `Controls/TextRender` (`TextView`, +`TextViewSharedFoldingState`, `CollapsibleRegionsMachine`, `FoldingManager`). +Thread grouping is driven by `ThreadGroupingService` (toggled from the title bar, +persisted as `ViewSettings.GroupByThread`); group boundaries are attached +properties (`IsGroupFirst` / `IsGroupLast` / `IsGroupContinuation`) on +`Controls/ListControls/BaseLogListViewItem`, computed by +`Controls/ListControls/VirtualizingStackPanel` and rendered by +`Styles/ListViewItemStyle.xaml`. The support window is `SupportWindow.xaml` / +`SupportViewModel` (opened through `OpenSupportCommand`). + +The timeline/minimap strip is `Controls/LogMinimapControl.cs` (a +`FrameworkElement` that draws the time-range handles, markers, search matches and +a hover readout). Hovering shows a vertical guide line plus the timestamp under +the cursor (or `Line N` in line-number mode) drawn directly in the canvas via +`DrawHoverTime`/`GetHoverText`, so it does not use a WPF `ToolTip`. Placement is +controlled by `TimelinePlacementService` and persisted as +`ViewSettings.TimelineAtTop`. + +The **merged files view** combines several opened documents into one +time-ordered grid and lives in `MergedView/`: `MergedViewModel` owns the merge, +filtering, timeline and navigation; `MergedDocumentItem` holds one document's +parsed lines; `MergedSchema`/`MergedComponentIndexer` align columns across +formats; `MergedSearchDocumentViewModel` and `MergedTimeRangeFilterViewModel` +mirror the single-log search and time-range panes; `MergedViewPalette` supplies +per-source colors; `MergedLineViewModel`/`ListItemProvider` adapt merged rows to +the grid. The view is toggled by `MergedFilesViewService` (title bar / Settings, +persisted as `ViewSettings.MergedFilesView`) and rendered by +`Styles/MergedViewTemplate.xaml`. Cross-format rules: identical column names are +aligned case-insensitively, plain-text/unrecognized lines put the whole line into +`Message`, and `Level`/`Severity` are unified into a single `Severity` column. +Covered by `MergedLineOrderTests`/`TimeIndexTests` (`LogGrokX.Data.Tests`) and +`MergedSchemaTests` (`LogGrokX.Tests`). + +## Conventions + +- `LangVersion=latest`, `Nullable=enable`. Prefer file-scoped namespaces. +- **Do not add comments** unless they are necessary to explain non-obvious code. +- Tests use **MSTest** (`[TestClass]`, `[TestMethod]`, `[DataRow]`), not xUnit or + NUnit. Assertion arguments are `(expected, actual)`. +- Keep changes minimal and mirror the style of neighbouring files. + +## Gotchas + +- **AvalonDock 5**: the XML layout serializer lives in the separate package + `Dirkster.AvalonDock.Serializer.Xml`; its namespace is + `AvalonDock.Serializer.Xml` (not `AvalonDock.Layout.Serialization`). +- **NLog 6**: `nlog.config` must stay compatible with NLog 6 — `concurrentWrites` + was removed, and `${threadid}` accepts no properties. +- **WPF-UI theming**: switch themes through `ApplicationThemeManager` / + `Theming/UiThemeService.cs`. Reference `DynamicResource` brushes + (`ApplicationBackgroundBrush`, `TextFillColorPrimaryBrush`, + `ControlStrokeColorDefaultBrush`, …) instead of hard-coded colors so both + themes work. WPF-UI ships *keyed* styles (e.g. `UiGridViewColumnHeaderStyle`), + so implicit styles are not always picked up — define overrides explicitly in + `Bootstrap/App.xaml`. +- **Window backdrop**: keep `WindowBackdropType="None"` on every `FluentWindow` + (`MainWindow`, `SupportWindow`, `SettingsWindow`) and `WindowBackdropType.None` + in `Theming/UiThemeService.cs`. The Mica material breaks composition of + AvalonDock's auto-hide flyout (`LayoutAutoHideWindowControl` is an `HwndHost`), + which then renders blank — most visibly in the light theme. +- **AvalonDock themes**: the main `DockingManager` uses `Vs2013LightTheme` / + `Vs2013DarkTheme`, but the search pane's inner `DockingManager` merges the + light `AvalonDock.Themes.Metro` theme. Metro keys (e.g. + `AvalonDock_ThemeMetro_BaseColor5`) therefore sometimes need theme-aware + overrides in `Styles/DocumentSearchTemplate.xaml`. +- **JSON folding state** is shared per opened document: `DocumentContainer` + registers one `TextViewSharedFoldingState` (exposed as `FoldingState` on + `LogViewModel` / `SearchDocumentViewModel` / `DocumentViewModel`) and the + templates bind it via `textRender:TextView.SharedFoldingState`. The marked-lines + view must use `Document.FoldingState` and the same `TextModel.UniqueId` as the + grid's JSON component, otherwise expansion falls out of sync. +- **JSON folding performance**: `TextView` keeps one cached `GlyphLine` per + source line (`_textLines`, plus its collapsed state) and builds glyphs only + for lines visible under the current folding, so toggling a region re-creates + just the affected lines. `TextModel.GetCollapsedTextSubstitution` memoizes the + inlined text of collapsed regions. Avoid reintroducing full re-creation of all + lines on every folding change — it makes expanding large JSON sluggish (#38). +- **Merged timeline range**: `MergedViewModel` keeps the **full** merged buffer + (`_mergedBuffer`) and derives the timeline axis (`Minimum`/`Maximum`, + `TotalLineCount`, `TimelineSegments`, `TimeRangeFilter.Refresh`) from it, while + the grid/search run on the filtered `_visibleBuffer` mapped back through + `_visibleToFull` (`VisibleLines`, `GetVisibleFullIndexMap`, `GetVisibleIndex`). + Deriving the axis from the filtered buffer makes + `MergedTimeRangeFilterViewModel.Refresh` clamp the handles to the shrunken + bounds and silently reset the time range. +- **Update check**: on startup `UpdateCheckService` resolves the latest tag from + the redirect of the `https://github.com/.../releases/latest` web page (not the + API: unauthenticated API allows 60 requests/hour per IP, shared behind corporate + NAT, #40). Only if the tag is newer than `BuildInfo.Version` (`UpdateVersion`) + and not skipped, it calls the `releases/tags/` API for notes and assets; on + 403/429 it remembers the reset time (`X-RateLimit-Reset` / `Retry-After`) and uses + `CreateFallbackRelease` (well-known asset URLs, no notes). Then it shows + `UpdateWindow` / `UpdateViewModel`. "Update" + downloads `LogGrokX---setup.exe` to `%TEMP%\LogGrokX\Update`, + verifies it against `SHA256SUMS.txt`, and `App.OnExit` starts it silently + (`/VERYSILENT /AUTOUPDATE=1`, `/ALLUSERS` + UAC for Program Files installs) + after the process exits. Portable builds (no `unins000.exe`) only open the + release page. The mode is `ViewSettings.UpdateMode` (`disabled`/`check`/`install`, + Settings -> View); `install` downloads without a dialog. The installer task + `autoinstallupdates` writes `install`/`check` to `appsettings.yaml` except during + `/AUTOUPDATE=1` runs. Local builds default to `2.1`, so they never prompt. + When an installer is downloaded, its release notes are saved to + `%LOCALAPPDATA%\LogGrokX\Data\pending-update.json`; on the next launch + `App.OnStartup` calls `UpdateCheckService.TakePendingReleaseForCurrentVersion`, + which returns the notes only if the stored version matches `BuildInfo.Version` + (a `v`-prefix is ignored), deletes the file either way, and `WhatsNewWindow` / + `WhatsNewViewModel` shows them. +- **Search results selection**: the search pane's `VirtualizingStackPanel` sets + `ReplaceSelectionOnCurrentPosition="True"` so F3/next/previous replace the + selection. Mouse and Shift+arrow selection must change `CurrentPosition` + through `SetCurrentPositionKeepingSelection`, otherwise Ctrl/Shift+click + multi-selection collapses to a single line (#43). The selected search lines + flow to the timeline as `ISearchDocument.SelectedMatchLines` -> + `SearchViewModel.CurrentSelectedMatchLines` -> `LogViewModel.SearchSelectedLines` + -> `LogMinimapControl.MatchLines` (merged view binds + `Search.CurrentSelectedMatchLines`). They are drawn like `MatchLine` but + semi-transparent; the current line (`MatchLine`) stays opaque on top. +- The app writes diagnostic logs to `%LOCALAPPDATA%\LogGrokX\`. +- Runtime configuration is `appsettings.yaml` (watched and hot-reloaded), + next to the executable. +- **Build version** is injected by the `GenerateBuildInfo` / `GenerateAppManifest` + targets in `LogGrokX.csproj`: they write `BuildInfo.g.cs`, `VersionInfo.g.cs` + and a version-stamped `LogGrokX.generated.manifest`. The manifest is fed to + the compiler via `Win32Manifest` (overriding `ApplicationManifest` alone is not + enough, the SDK snapshots it at evaluation). `LogGrokX.exe --version` prints + the version and exits before WPF starts; the release workflow smoke-tests it. +- **App icon**: `LogGrokX/app.ico` is a multi-frame icon (white glyph with a thin + dark outline on a transparent background) set as `ApplicationIcon` and used for + `Window.Icon` / the title-bar `ui:ImageIcon`. WPF decodes only the **first** + `.ico` frame (16×16), so for larger renders use `LogGrokX/app-large.png` + (256×256). Both files are `` items. + +## Verification + +Minimum bar for any change: + +```powershell +dotnet build LogGrokX.sln -t:Rebuild +dotnet test LogGrokX.sln +``` + +Both must succeed with 0 warnings and 0 errors. diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..b9711ca --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,509 @@ +# LogGrokX — Feature Guide + +This document is the deep-dive companion to the [README](./README.md). It covers +**every feature listed in the README**, explains the business value behind it, +and shows the relevant configuration or core API with a ready-to-adapt snippet. + +> All screenshots were captured from the running application using **generated +> demo logs** (`demo-app.log`, `demo-worker.log`), not real product traces. Each +> image is 1920×1080 (Full HD). + +## Table of contents + +- [⚡ Large file support](#-large-file-support) +- [🧩 Configurable log formats](#-configurable-log-formats) +- [📊 Column and field indexing](#-column-and-field-indexing) +- [🔍 Search](#-regex-search) +- [🧾 Filtering](#-filtering) +- [⏳ Time filter and timeline](#-time-filter-and-timeline) +- [📦 JSON folding](#-json-folding) +- [🎨 Color rules](#-color-rules) +- [📌 Marked lines](#-marked-lines) +- [🔠 Text zoom](#-text-zoom) +- [🎯 Centered navigation](#-centered-navigation) +- [🧵 Thread grouping](#-thread-grouping) +- [🔁 Text transformations](#-text-transformations) +- [🔐 XOR-masked logs](#-xor-masked-logs) +- [🌗 Light and dark themes](#-light-and-dark-themes) +- [🩺 Crash dumps](#-crash-dumps) +- [🛟 Support window](#-support-window) +- [📑 Multiple documents](#-multiple-documents) +- [🧬 Merged files view](#-merged-files-view) +- [🔄 Automatic updates](#-automatic-updates) + +## ⚡ Large file support + +**Business value.** Production logs routinely reach several gigabytes. LogGrokX +never loads a log into memory: it streams the file through a buffered, +line-aware reader and builds indexes in the background, while UI virtualization +keeps scrolling smooth. A file that would not open in a text editor becomes +interactive within seconds. + +![Demo](assets/images/feature-1.png) + +```csharp +using LogGrokX.Data; + +var logFile = new LogFile(@"C:\logs\app.log", xorMask: 0); + +using var loader = new Loader(logFile, lineProcessor, logger); +while (loader.IsLoading) + await Task.Delay(100); + +Console.WriteLine($"{logFile.FileSize} bytes indexed"); +``` + +`LogFile` detects the encoding (including BOM-less UTF-16) and `Loader` drives +the background read. The `IItemProvider`/`VirtualList` abstraction in +`LogGrokX.Data.Virtualization` exposes only the visible window of lines to the +grid. + +## 🧩 Configurable log formats + +**Business value.** Every product logs differently. Instead of hard-coding a +parser, LogGrokX describes each format with a named-capture .NET regex in +`appsettings.yaml`. Several formats can coexist, so a single installation can +open logs from different components without changing code. + +![Demo](assets/images/feature-7.png) + +```yaml +LogFormats: + - Regex: ^(?