diff --git a/.gitattributes b/.gitattributes index 8e520b07..d1b84608 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ *.bin binary *.png binary *.docx binary +*.dll binary src/Verify/EmptyFiles/* binary diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml new file mode 100644 index 00000000..52e5e2c9 --- /dev/null +++ b/.github/workflows/build-native.yml @@ -0,0 +1,151 @@ +name: Build native + +# Produces the diffengine_viewer binaries committed under +# src/DiffEngineViewer/runtimes/{rid}/native. +# +# They are committed rather than built during a normal build so that a plain +# `dotnet build src --configuration Release` produces a shippable package on any machine, and +# contributors never need a C++ toolchain. Run this whenever native/ changes. +on: + push: + paths: + - 'native/**' + - '.github/workflows/build-native.yml' + pull_request: + paths: + - 'native/**' + workflow_dispatch: + inputs: + commit: + description: 'Open a PR with the rebuilt binaries' + type: boolean + default: true + +concurrency: + group: build-native-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: ${{ matrix.rid }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + os: windows-latest + generator: -A x64 + - rid: win-arm64 + os: windows-latest + generator: -A ARM64 + - rid: linux-x64 + os: ubuntu-24.04 + - rid: linux-arm64 + os: ubuntu-24.04-arm + # One universal binary covers both macOS RIDs; it is copied into each below. + - rid: osx + os: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build \ + libx11-dev libxrandr-dev libxi-dev libxcursor-dev libxinerama-dev \ + libgl1-mesa-dev libglu1-mesa-dev libwayland-dev libxkbcommon-dev + + - name: Configure + shell: bash + run: | + if [ "${{ matrix.rid }}" = "osx" ]; then + cmake -S native -B build -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" + elif [ "${{ runner.os }}" = "Windows" ]; then + cmake -S native -B build ${{ matrix.generator }} + else + cmake -S native -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + fi + + - name: Build + run: cmake --build build --config Release + + - name: Collect + shell: bash + run: | + collect() { + mkdir -p "artifacts/$1/native" + cp "$2" "artifacts/$1/native/" + } + case "${{ matrix.rid }}" in + win-*) + collect "${{ matrix.rid }}" build/Release/diffengine_viewer.dll + ;; + linux-*) + strip build/libdiffengine_viewer.so + collect "${{ matrix.rid }}" build/libdiffengine_viewer.so + ;; + osx) + strip -x build/libdiffengine_viewer.dylib + # The dylib is universal, so both macOS RIDs get the same file. + collect osx-x64 build/libdiffengine_viewer.dylib + collect osx-arm64 build/libdiffengine_viewer.dylib + ;; + esac + ls -lhR artifacts + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.rid }} + path: artifacts/ + retention-days: 7 + + propose: + name: propose update + needs: build + # Also on push, not just manual dispatch. workflow_dispatch does not appear in the Actions UI + # until the workflow is on the default branch, so on a feature branch a push is the only way + # to trigger this, and leaving the binaries as bare artifacts to copy by hand is worse. + # Never on pull_request, which would mean opening a PR from a PR. + if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.commit) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download + uses: actions/download-artifact@v4 + with: + pattern: native-* + merge-multiple: true + path: src/DiffEngineViewer/runtimes + + - name: Show what changed + run: | + ls -lhR src/DiffEngineViewer/runtimes + git status --short + + # A PR rather than a direct push: these are binaries, so the diff is not reviewable and the + # change deserves an explicit approval. + - name: Open pull request + uses: peter-evans/create-pull-request@v7 + with: + # Scoped to the triggering branch, which is also the base, so a rebuild on a feature + # branch does not collide with one on main. + branch: native-binaries-${{ github.ref_name }} + title: 'Rebuild native renderer binaries' + commit-message: 'Rebuild native renderer binaries' + body: | + Rebuilt `diffengine_viewer` from `native/` for all six RIDs. + + Produced by the `build-native` workflow from ${{ github.sha }}. + add-paths: src/DiffEngineViewer/runtimes diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 00000000..1815ea93 --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,77 @@ +name: Publish NuGet + +# Packs DiffEngine, DiffEngineTray and DiffEngineViewer and pushes them to nuget.org using Trusted +# Publishing (OIDC), so no long lived API key is stored. GitHub Actions mints a short lived OIDC +# token; the NuGet/login action exchanges it for a temporary nuget.org API key, issued only because +# a matching trusted-publishing policy is registered for this repo and workflow. +# +# One-time setup: +# 1. nuget.org -> Account -> Trusted Publishing: add a policy for this GitHub owner and +# repository, scoped to this workflow file, covering DiffEngine, DiffEngineTray and +# DiffEngineViewer. +# 2. Repo -> Settings -> Secrets and variables -> Actions -> Variables: set NUGET_USER to the +# nuget.org username. A username is not sensitive, so it lives in a variable not a secret. +on: + push: + tags: + - '*' + workflow_dispatch: + +permissions: + id-token: write # required: lets the job request a GitHub OIDC token + contents: read + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + publish: + # Windows, because DiffEngineTray is WinForms and is excluded from the non-Windows + # configuration. Publishing from anywhere else would silently drop it. + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: NuGet OIDC login + uses: NuGet/login@v1 + id: login + with: + user: ${{ vars.NUGET_USER }} + + # A plain build is the pack step: ProjectDefaults sets GeneratePackageOnBuild for every + # Release package project, so each package is produced by its own build, in dependency + # order, into ./nugets. Deliberately not `dotnet pack`, which on a solution races with + # GeneratePackageOnBuild. + - name: Build and pack + run: dotnet build src --configuration Release + + # DE0001 warns when a RID has no native renderer. Shipping a package that cannot run on a + # platform it claims to support is worse than failing the release. + - name: Verify every RID has a native renderer + shell: bash + run: | + missing=0 + for rid in win-x64 win-arm64 linux-x64 linux-arm64 osx-x64 osx-arm64; do + directory="src/DiffEngineViewer/runtimes/$rid/native" + if [ -z "$(ls -A "$directory" 2>/dev/null)" ]; then + echo "::error::No native renderer for $rid. Run the build-native workflow." + missing=1 + fi + done + exit $missing + + - name: Push to nuget.org + # --skip-duplicate makes re-runs idempotent. + run: > + dotnet nuget push "nugets/*.nupkg" + --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..0af82e0a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,137 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Cancel in-progress runs if a new push lands on the same branch. +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_NOLOGO: true + +jobs: + windows: + name: windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + # A plain build is also the pack step: ProjectDefaults sets GeneratePackageOnBuild for every + # Release package project, so this produces ./nugets as a side effect. + - name: Build + run: dotnet build src --configuration Release + + - name: Test + run: dotnet test --solution src/DiffEngine.slnx --configuration Release --no-build --no-restore + + - name: AOT publish + run: dotnet publish src/DiffEngine.AotTests/DiffEngine.AotTests.csproj --configuration Release + + - name: Upload received on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: received-windows + path: '**/*.received.*' + if-no-files-found: ignore + retention-days: 14 + + - name: Upload packages + if: success() + uses: actions/upload-artifact@v4 + with: + name: nupkgs + path: nugets/*.nupkg + if-no-files-found: warn + retention-days: 30 + + unix: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + # raylib links against X11, GL and friends. xvfb plus the Mesa software rasteriser are what + # the pixel snapshots run against. + - name: Install native dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake ninja-build \ + libx11-dev libxrandr-dev libxi-dev libxcursor-dev libxinerama-dev \ + libgl1-mesa-dev libglu1-mesa-dev libwayland-dev libxkbcommon-dev \ + xvfb libgl1-mesa-dri + + # CMake fetches raylib and imgui, so without this every PR re-clones and recompiles them. + # Keyed on the shim's own sources, which is what actually changes. + - name: Cache native build + if: runner.os == 'Linux' + uses: actions/cache@v4 + with: + path: native/build + key: native-linux-x64-${{ hashFiles('native/CMakeLists.txt', 'native/src/**', 'native/include/**') }} + + # Built here rather than taken from the committed binaries, so the Linux job tests the + # current native source and the pixel baselines track it. + - name: Build native renderer + if: runner.os == 'Linux' + run: | + cmake -S native -B native/build/linux-x64 -G Ninja -DCMAKE_BUILD_TYPE=Release + cmake --build native/build/linux-x64 + mkdir -p src/DiffEngineViewer/runtimes/linux-x64/native + cp native/build/linux-x64/libdiffengine_viewer.so src/DiffEngineViewer/runtimes/linux-x64/native/ + + # Release-NotWindows drops the WinForms tray and its tests from the solution. + - name: Build + run: dotnet build src --configuration Release-NotWindows + + - name: Test + run: dotnet test --solution src/DiffEngine.slnx --configuration Release-NotWindows --no-build --no-restore + + # Pinned to one rasteriser rather than one platform: llvmpipe is pure software and so more + # reproducible than any GPU driver, and ImGui rasterises glyphs itself. + - name: Pixel snapshots + if: runner.os == 'Linux' + env: + DIFFENGINE_VIEWER_PIXEL_TESTS: 'true' + LIBGL_ALWAYS_SOFTWARE: '1' + GALLIUM_DRIVER: llvmpipe + run: > + xvfb-run -a --server-args="-screen 0 1920x1080x24" + dotnet test src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj + --configuration Release-NotWindows --no-build --no-restore + + - name: Upload received on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: received-${{ matrix.os }} + path: '**/*.received.*' + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index 10195c5b..a78ed7cf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ nul /TestResults /coverage BenchmarkDotNet.Artifacts/ +native/build/ diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 75181ae1..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,48 +0,0 @@ -image: -- Visual Studio 2022 -#- macOS -#- Ubuntu -environment: - DOTNET_CLI_TELEMETRY_OPTOUT: true - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true -build_script: -- pwsh: | - if ($isWindows) { - Invoke-WebRequest "https://dot.net/v1/dotnet-install.ps1" -OutFile "./dotnet-install.ps1" - ./dotnet-install.ps1 -JSonFile global.json -Architecture x64 -InstallDir 'C:\Program Files\dotnet' - dotnet build src --configuration Release - dotnet test --solution src/DiffEngine.slnx --configuration Release --no-build --no-restore --report-trx - $testExit = $LASTEXITCODE - dotnet publish src/DiffEngine.AotTests/DiffEngine.AotTests.csproj --configuration Release - } - else { - Invoke-WebRequest "https://dot.net/v1/dotnet-install.sh" -OutFile "./dotnet-install.sh" - sudo chmod u+x dotnet-install.sh - if ($isMacOS) { - sudo ./dotnet-install.sh --jsonfile global.json --architecture x64 --install-dir '/usr/local/share/dotnet' - } else { - sudo ./dotnet-install.sh --jsonfile global.json --architecture x64 --install-dir '/usr/share/dotnet' - } - dotnet build src --configuration Release-NotWindows - dotnet test --solution src/DiffEngine.slnx --configuration Release-NotWindows --no-build --no-restore --report-trx - $testExit = $LASTEXITCODE - dotnet publish src/DiffEngine.AotTests/DiffEngine.AotTests.csproj --configuration Release-NotWindows - } - $url = "https://ci.appveyor.com/api/testresults/mstest/$($env:APPVEYOR_JOB_ID)" - Get-ChildItem -Recurse -Filter *.trx | ForEach-Object { - Write-Host "Uploading test results: $($_.FullName)" - try { (New-Object System.Net.WebClient).UploadFile($url, $_.FullName) } - catch { Write-Host "Failed to upload test results: $_" } - } - # Captured before the publish step so a test failure is not masked by it. - if ($testExit -ne 0) { exit $testExit } -on_failure: - - ps: | - $root = (Get-Location).Path - Get-ChildItem *.received.* -Recurse | % { - $rel = $_.FullName.Substring($root.Length + 1) - Push-AppveyorArtifact $_.FullName -FileName $rel - } -test: off -artifacts: -- path: nugets\*.nupkg \ No newline at end of file diff --git a/claude.md b/claude.md index 7ed4a4e7..c07dcb6d 100644 --- a/claude.md +++ b/claude.md @@ -9,12 +9,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build and Test Commands ```bash -# Build (from repo root) +# Build (from repo root). Also packs: ProjectDefaults sets GeneratePackageOnBuild in Release. dotnet build src --configuration Release # Run all tests dotnet test --project src/DiffEngine.Tests --configuration Release dotnet test --project src/DiffEngineTray.Tests --configuration Release +dotnet test --project src/DiffEngineViewer.Tests --configuration Release # Run a single test project with filter dotnet test --project src/DiffEngine.Tests --configuration Release --filter "FullyQualifiedName~ClassName" @@ -45,10 +46,40 @@ DiffEngine is a library that manages launching and cleanup of diff tools for sna - `ResolvedTool` - A diff tool that was found on the system with its resolved executable path. - `BuildServerDetector` - Detects CI/build server environments to disable diff tool launching. +**DiffEngineViewer (`src/DiffEngineViewer/`):** +- Cross platform GUI diff tool: Dear ImGui rendered through raylib. Reviews inline snapshots and + plain two-file diffs. +- Bundled inside DiffEngine.nupkg under `tools/viewer/{rid}/`, so inline snapshots work with no + extra install. Also shipped standalone as the `DiffEngineViewer` dotnet tool. +- `ViewerSession` is a pure state machine over an immutable `SessionState`. `ScreenBuilder` + projects that into a `Screen` (already sliced to the visible rows), which `AsciiRenderer` draws + as text and the native shim draws as pixels. Both renderers consume the identical structure, + which is what makes the text snapshots meaningful. +- Does **not** reference DiffEngine. It links `Inline/*.cs` and `Tray/TrayDetector.cs` as source, + because DiffEngine publishes and embeds the viewer and a reference back would be a cycle. +- Single instance by socket bind on 3493 (`DiffEngine_ViewerPort`): whoever binds owns the window, + and a process that fails to bind forwards its patch and exits. + +**Native shim (`native/`):** +- `raylib` and `imgui` are fetched by CMake (`FetchContent`), pinned by tag in + `native/CMakeLists.txt`. Deliberately not submodules: nothing in a normal `dotnet build` touches + this folder, so a recursive clone on every checkout would serve a path almost nobody takes. +- Building it needs CMake 3.24+, a C++17 compiler and network access. Contributors do not need + any of that, because the binaries are committed. +- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: ~12 exports + taking one flat blittable frame description. The ABI is `native/include/deview.h`; bump + `DEVIEW_VERSION` whenever the structs change. +- Built binaries are **committed** to `src/DiffEngineViewer/runtimes/{rid}/native/`, so a plain + `dotnet build` produces a shippable package and contributors never need CMake. Regenerate them + with the `build-native` GitHub workflow, which opens a PR. + **DiffEngineTray (`src/DiffEngineTray/`):** - Windows Forms tray application that handles pending file diffs - `PiperServer` - TCP server (localhost) receiving move/delete payloads from DiffEngine library - `Tracker` - Manages pending file moves and deletes with concurrent dictionaries +- `InlineViewerProxy` - Pending inline snapshots are **not** stored here. The viewer owns that + queue and the tray drives it over the same socket, so one queue and one set of semantics serve + every platform rather than a Windows-only copy that can drift. - Allows accepting/discarding diffs from system tray ### Adding a New Diff Tool diff --git a/docs/diff-tool.md b/docs/diff-tool.md index e2ede2a8..c518794a 100644 --- a/docs/diff-tool.md +++ b/docs/diff-tool.md @@ -263,6 +263,72 @@ DiffTools.UseOrder(DiffTool.DeltaWalker); * `/Applications/DeltaWalker.app/Contents/MacOS/DeltaWalker` * `%PATH%DeltaWalker` +### [DiffEngineViewer](https://github.com/VerifyTests/DiffEngine) + + * Cost: Free + * Is MDI: False + * Supports auto-refresh: False + * Supports text files: True + * Use shell execute: False + * Create no window: True + * Environment variable for custom install location: `DiffEngine_DiffEngineViewer` + +#### Tool order: + +Use [tool order](diff-tool.order.md) to prioritise DiffEngineViewer over other tools. + +``` +DiffTools.UseOrder(DiffTool.DiffEngineViewer); +``` + +#### Notes: + + * Bundled inside the DiffEngine package, so it needs no install + * Also available standalone via `dotnet tool install -g DiffEngineViewer` + * Cross platform: Windows, macOS and Linux + +#### Windows settings: + + * Example target on left arguments: + ``` + "targetFile.txt" "tempFile.txt" + ``` + * Example target on right arguments: + ``` + "tempFile.txt" "targetFile.txt" + ``` + * Scanned paths: + * `%USERPROFILE%\.dotnet\tools\DiffEngineViewer.exe` + * `%PATH%DiffEngineViewer.exe` + +#### OSX settings: + + * Example target on left arguments: + ``` + "targetFile.txt" "tempFile.txt" + ``` + * Example target on right arguments: + ``` + "tempFile.txt" "targetFile.txt" + ``` + * Scanned paths: + * `$HOME/.dotnet/tools/DiffEngineViewer` + * `%PATH%DiffEngineViewer` + +#### Linux settings: + + * Example target on left arguments: + ``` + "targetFile.txt" "tempFile.txt" + ``` + * Example target on right arguments: + ``` + "tempFile.txt" "targetFile.txt" + ``` + * Scanned paths: + * `$HOME/.dotnet/tools/DiffEngineViewer` + * `%PATH%DiffEngineViewer` + ### [Diffinity](https://truehumandesign.se/s_diffinity.php) * Cost: Free with option to donate diff --git a/docs/diff-tool.order.md b/docs/diff-tool.order.md index b7acae60..12bc43f2 100644 --- a/docs/diff-tool.order.md +++ b/docs/diff-tool.order.md @@ -34,7 +34,8 @@ To change this file edit the source file and then run MarkdownSnippets. * **[SublimeMerge](/docs/diff-tool.md#sublimemerge)** Windows/OSX/Linux (Cost: Paid) * **[VisualStudioCode](/docs/diff-tool.md#visualstudiocode)** Windows/OSX/Linux (Cost: Free) * **[Cursor](/docs/diff-tool.md#cursor)** Windows/OSX/Linux (Cost: Free and Paid) - * **[VisualStudio](/docs/diff-tool.md#visualstudio)** Windows (Cost: Paid and free options) + * **[VisualStudio](/docs/diff-tool.md#visualstudio)** Windows (Cost: Paid and free options) + * **[DiffEngineViewer](/docs/diff-tool.md#diffengineviewer)** Windows/OSX/Linux (Cost: Free) ## Custom order diff --git a/docs/mdsource/doc-index.include.md b/docs/mdsource/doc-index.include.md index 852c8561..0f515cdf 100644 --- a/docs/mdsource/doc-index.include.md +++ b/docs/mdsource/doc-index.include.md @@ -1,5 +1,6 @@ * [Tools](/docs/diff-tool.md) * [Tool Order](/docs/diff-tool.order.md) * [Custom Tool](/docs/diff-tool.custom.md) + * [DiffEngineViewer](/docs/viewer.md) * [DiffEngineTray](/docs/tray.md) * [Code versus machine level settings](/docs/code-versus-machine-settings.md) \ No newline at end of file diff --git a/docs/mdsource/viewer.source.md b/docs/mdsource/viewer.source.md new file mode 100644 index 00000000..49a536a7 --- /dev/null +++ b/docs/mdsource/viewer.source.md @@ -0,0 +1,84 @@ +# DiffEngineViewer + +DiffEngineViewer is a cross platform diff tool for text files and inline snapshots. It is the +reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): +it shows the received text against the expected text, and accepting rewrites the literal in the +source file. + +Unlike every other entry in the [tool list](/docs/diff-tool.md), it does not need to be installed. +A copy ships inside the DiffEngine package, so it is always present. + +The UI is [Dear ImGui](https://github.com/ocornut/imgui) rendered through +[raylib](https://github.com/raysan5/raylib). + + +## NuGet + + * https://www.nuget.org/packages/DiffEngineViewer + +Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine +already bundles it. + +`dotnet tool install -g DiffEngineViewer` + + +## Usage + +Comparing two files: + +``` +DiffEngineViewer +``` + +Reviewing an inline snapshot, where the patch payload arrives on stdin: + +``` +DiffEngineViewer --inline --source --line +``` + +Nothing is written to disk for inline review. The patch travels over stdin, or over a loopback +socket when a viewer is already running. + + +## Keys + +| Key | Action | +| --- | --- | +| `Up` `Down` `PgUp` `PgDn` `Home` `End` | Scroll | +| `n` `p` | Next and previous change | +| `Tab` `Shift+Tab` | Next and previous pending snapshot | +| `a` | Accept | +| `Shift+A` | Accept all | +| `d` | Discard | +| `q` `Esc` | Close | + + +## Multiple pending snapshots + +A test run that fails several inline snapshots produces one window, not several. The first launch +takes ownership by binding a loopback port; later launches hand their patch to that instance and +exit. The window lists everything pending and offers **Accept all**. + +Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which +case the window hides and the tray can reopen it. + + +## With DiffEngineTray + +The viewer owns the queue and the tray is a remote control over the same socket, so both surfaces +always agree. The tray's **Pending Snapshots** group can accept, discard, open the viewer on a +particular snapshot, and close the viewer. + + +## Disabling + +Set `DiffEngine_InlineViewer` to `false` to stop inline snapshots opening a window. The viewer also +never launches when [DiffEngine is disabled](/docs/#disabled), which covers build servers, +continuous testing and AI CLIs. + + +## Platforms + +Ships for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`. On a +platform with no matching binary, resolution falls through to a globally installed +DiffEngineViewer tool, and then to whatever other diff tool is available. diff --git a/docs/readme.md b/docs/readme.md index 30bb317d..1dd7dc80 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -10,5 +10,6 @@ To change this file edit the source file and then run MarkdownSnippets. * [Tools](/docs/diff-tool.md) * [Tool Order](/docs/diff-tool.order.md) * [Custom Tool](/docs/diff-tool.custom.md) + * [DiffEngineViewer](/docs/viewer.md) * [DiffEngineTray](/docs/tray.md) * [Code versus machine level settings](/docs/code-versus-machine-settings.md) diff --git a/docs/viewer.md b/docs/viewer.md new file mode 100644 index 00000000..c28ce097 --- /dev/null +++ b/docs/viewer.md @@ -0,0 +1,91 @@ + + +# DiffEngineViewer + +DiffEngineViewer is a cross platform diff tool for text files and inline snapshots. It is the +reviewer for [inline snapshots](https://github.com/VerifyTests/Verify/blob/main/docs/inline-snapshots.md): +it shows the received text against the expected text, and accepting rewrites the literal in the +source file. + +Unlike every other entry in the [tool list](/docs/diff-tool.md), it does not need to be installed. +A copy ships inside the DiffEngine package, so it is always present. + +The UI is [Dear ImGui](https://github.com/ocornut/imgui) rendered through +[raylib](https://github.com/raysan5/raylib). + + +## NuGet + + * https://www.nuget.org/packages/DiffEngineViewer + +Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine +already bundles it. + +`dotnet tool install -g DiffEngineViewer` + + +## Usage + +Comparing two files: + +``` +DiffEngineViewer +``` + +Reviewing an inline snapshot, where the patch payload arrives on stdin: + +``` +DiffEngineViewer --inline --source --line +``` + +Nothing is written to disk for inline review. The patch travels over stdin, or over a loopback +socket when a viewer is already running. + + +## Keys + +| Key | Action | +| --- | --- | +| `Up` `Down` `PgUp` `PgDn` `Home` `End` | Scroll | +| `n` `p` | Next and previous change | +| `Tab` `Shift+Tab` | Next and previous pending snapshot | +| `a` | Accept | +| `Shift+A` | Accept all | +| `d` | Discard | +| `q` `Esc` | Close | + + +## Multiple pending snapshots + +A test run that fails several inline snapshots produces one window, not several. The first launch +takes ownership by binding a loopback port; later launches hand their patch to that instance and +exit. The window lists everything pending and offers **Accept all**. + +Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which +case the window hides and the tray can reopen it. + + +## With DiffEngineTray + +The viewer owns the queue and the tray is a remote control over the same socket, so both surfaces +always agree. The tray's **Pending Snapshots** group can accept, discard, open the viewer on a +particular snapshot, and close the viewer. + + +## Disabling + +Set `DiffEngine_InlineViewer` to `false` to stop inline snapshots opening a window. The viewer also +never launches when [DiffEngine is disabled](/docs/#disabled), which covers build servers, +continuous testing and AI CLIs. + + +## Platforms + +Ships for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64` and `osx-arm64`. On a +platform with no matching binary, resolution falls through to a globally installed +DiffEngineViewer tool, and then to whatever other diff tool is available. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt new file mode 100644 index 00000000..c72c28de --- /dev/null +++ b/native/CMakeLists.txt @@ -0,0 +1,78 @@ +cmake_minimum_required(VERSION 3.24) +project(diffengine_viewer LANGUAGES C CXX) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_C_VISIBILITY_PRESET hidden) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +endif() + +# +# Sources are fetched rather than vendored as submodules. +# +# Nothing in a normal `dotnet build` touches this directory: the built binaries are committed to +# src/DiffEngineViewer/runtimes, so only this file's own build needs raylib and imgui. Submodules +# would have imposed a recursive clone on every checkout to serve a path almost nobody takes. +# +# The versions are pinned by tag here, in the same file as the build configuration. +# +include(FetchContent) + +# raylib, static so the shipped artifact is a single file per RID. +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(BUILD_GAMES OFF CACHE BOOL "" FORCE) +set(CUSTOMIZE_BUILD ON CACHE BOOL "" FORCE) +set(SUPPORT_MODULE_RAUDIO OFF CACHE BOOL "" FORCE) +set(SUPPORT_MODULE_RMODELS OFF CACHE BOOL "" FORCE) +set(SUPPORT_FILEFORMAT_PNG ON CACHE BOOL "" FORCE) + +FetchContent_Declare( + raylib + GIT_REPOSITORY https://github.com/raysan5/raylib.git + GIT_TAG 6.0 + GIT_SHALLOW ON) + +# imgui has no CMake build of its own, so it is fetched as plain sources and compiled below. +FetchContent_Declare( + imgui + GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG v1.92.9b + GIT_SHALLOW ON) + +FetchContent_MakeAvailable(raylib) +FetchContent_MakeAvailable(imgui) + +add_library(diffengine_viewer SHARED + src/deview.cpp + ${imgui_SOURCE_DIR}/imgui.cpp + ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp + ${imgui_SOURCE_DIR}/imgui_widgets.cpp) + +target_include_directories(diffengine_viewer PRIVATE include ${imgui_SOURCE_DIR}) +target_link_libraries(diffengine_viewer PRIVATE raylib) + +# The managed side probes for a bare name; keep it identical on every platform apart from the +# platform's own prefix and extension. +set_target_properties(diffengine_viewer PROPERTIES + OUTPUT_NAME "diffengine_viewer" + C_VISIBILITY_PRESET hidden + CXX_VISIBILITY_PRESET hidden) + +if(MSVC) + target_compile_options(diffengine_viewer PRIVATE /W3 /permissive-) + target_compile_definitions(diffengine_viewer PRIVATE _CRT_SECURE_NO_WARNINGS) +else() + target_compile_options(diffengine_viewer PRIVATE -Wall -Wextra -Wno-unused-parameter) +endif() + +if(APPLE) + set_target_properties(diffengine_viewer PROPERTIES SUFFIX ".dylib") +endif() diff --git a/native/include/deview.h b/native/include/deview.h new file mode 100644 index 00000000..3d57b18a --- /dev/null +++ b/native/include/deview.h @@ -0,0 +1,170 @@ +/* + * DiffEngineViewer native renderer. + * + * This is a renderer for a screen model, not an ImGui binding. All application logic, state and + * layout live in C#; the managed side marshals one flat, blittable description of the frame and + * this library turns it into ImGui calls. That keeps the export surface at a dozen functions + * instead of cimgui's ~1000, makes interop one call per frame instead of thousands, and means the + * structure the snapshot tests verify is the exact structure drawn here. + * + * Every string is a byte offset and length into DeviewScreen.strings, a single UTF-8 blob, so a + * frame is one allocation on the managed side and no per-string marshalling. + */ +#ifndef DEVIEW_H +#define DEVIEW_H + +#include + +#if defined(_WIN32) +#define DEVIEW_API __declspec(dllexport) +#else +#define DEVIEW_API __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Keep in sync with RowKind.cs */ +enum DeviewRowKind { + DEVIEW_ROW_UNCHANGED = 0, + DEVIEW_ROW_ADDED = 1, + DEVIEW_ROW_REMOVED = 2, + DEVIEW_ROW_MODIFIED = 3, + DEVIEW_ROW_FILLER = 4 +}; + +enum DeviewButtonFlags { + DEVIEW_BUTTON_ENABLED = 1 << 0 +}; + +enum DeviewQueueFlags { + DEVIEW_QUEUE_SELECTED = 1 << 0, + DEVIEW_QUEUE_FAILED = 1 << 1 +}; + +typedef struct DeviewRow { + int32_t kind; + /* -1 when the row is filler and has no line number. */ + int32_t lineNumber; + int32_t textOffset; + int32_t textLength; +} DeviewRow; + +typedef struct DeviewPane { + int32_t headerOffset; + int32_t headerLength; + int32_t rowOffset; + int32_t rowCount; + int32_t scrollTop; + int32_t totalRows; +} DeviewPane; + +typedef struct DeviewButton { + int32_t labelOffset; + int32_t labelLength; + int32_t flags; +} DeviewButton; + +typedef struct DeviewQueueItem { + int32_t labelOffset; + int32_t labelLength; + int32_t flags; +} DeviewQueueItem; + +typedef struct DeviewScreen { + const uint8_t* strings; + int32_t stringsLength; + + const DeviewPane* panes; + int32_t paneCount; + + const DeviewRow* rows; + int32_t rowCount; + + const DeviewButton* buttons; + int32_t buttonCount; + + const DeviewQueueItem* queue; + int32_t queueCount; + + int32_t titleOffset; + int32_t titleLength; + int32_t subtitleOffset; + int32_t subtitleLength; + int32_t statusOffset; + int32_t statusLength; +} DeviewScreen; + +/* Keep in sync with CommandKind.cs */ +enum DeviewKey { + DEVIEW_KEY_NONE = 0, + DEVIEW_KEY_SCROLL_UP = 1, + DEVIEW_KEY_SCROLL_DOWN = 2, + DEVIEW_KEY_PAGE_UP = 3, + DEVIEW_KEY_PAGE_DOWN = 4, + DEVIEW_KEY_HOME = 5, + DEVIEW_KEY_END = 6, + DEVIEW_KEY_NEXT_CHANGE = 7, + DEVIEW_KEY_PREVIOUS_CHANGE = 8, + DEVIEW_KEY_NEXT_ITEM = 9, + DEVIEW_KEY_PREVIOUS_ITEM = 10, + DEVIEW_KEY_ACCEPT = 11, + DEVIEW_KEY_DISCARD = 12, + DEVIEW_KEY_ACCEPT_ALL = 13, + DEVIEW_KEY_QUIT = 14 +}; + +typedef struct DeviewInput { + int32_t key; + /* Index into DeviewScreen.buttons, or -1. */ + int32_t clickedButton; + /* Index into DeviewScreen.queue, or -1. */ + int32_t clickedQueueItem; + int32_t scrollDelta; + /* Set when the user asked to close the window; the managed side decides hide versus exit. */ + int32_t closeRequested; + int32_t columns; + int32_t rows; +} DeviewInput; + +/* + * Returns 1 on success. fontTtf may be NULL, in which case ImGui's built in font is used. + * hidden starts the window offscreen, which the pixel snapshot tests rely on. + */ +DEVIEW_API int32_t deview_init( + int32_t width, + int32_t height, + const char* title, + const uint8_t* fontTtf, + int32_t fontLength, + float fontSize, + int32_t hidden); + +/* Draws one frame. Returns 0 once the window has been closed. */ +DEVIEW_API int32_t deview_present(const DeviewScreen* screen); + +DEVIEW_API void deview_poll_input(DeviewInput* input); + +/* Renders one frame offscreen and writes it to pngPath. Returns 1 on success. */ +DEVIEW_API int32_t deview_capture( + const DeviewScreen* screen, + int32_t width, + int32_t height, + const char* pngPath); + +DEVIEW_API void deview_set_hidden(int32_t hidden); + +DEVIEW_API void deview_focus(void); + +DEVIEW_API void deview_shutdown(void); + +/* Bumped whenever the structs above change, so a stale native library is detected not crashed. */ +#define DEVIEW_VERSION 1 +DEVIEW_API int32_t deview_version(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/native/src/deview.cpp b/native/src/deview.cpp new file mode 100644 index 00000000..4553d462 --- /dev/null +++ b/native/src/deview.cpp @@ -0,0 +1,699 @@ +/* + * DiffEngineViewer native renderer: raylib for the window and GL, Dear ImGui for the widgets. + * + * The ImGui backend here is deliberately minimal. Panes do not scroll inside ImGui, because the + * managed side already slices each frame to the visible rows, so there is no scroll state to keep + * in sync and no keyboard navigation to wire up. That leaves only three things a backend must do: + * honour texture requests, feed mouse input, and turn ImDrawData into rlgl calls. + */ +#include "deview.h" + +#include "imgui.h" +#include "raylib.h" +#include "rlgl.h" + +#include +#include + +/* + * raylib latches GLFW's close flag and exposes no way to clear it, but the window has to survive a + * close when a tray is running, otherwise every later frame would report closing again. raylib + * statically links GLFW into this library so the symbols resolve, and glfwGetCurrentContext + * returns raylib's own window without needing the GLFW headers. + */ +extern "C" void* glfwGetCurrentContext(void); +extern "C" void glfwSetWindowShouldClose(void* window, int value); + +namespace +{ +void ClearCloseFlag() +{ + void* handle = glfwGetCurrentContext(); + if (handle != nullptr) + { + glfwSetWindowShouldClose(handle, 0); + } +} + +struct State +{ + bool initialised = false; + bool windowOpen = false; + ImGuiContext* context = nullptr; + DeviewInput input{}; +}; + +State state; + +void ResetInput() +{ + state.input.key = DEVIEW_KEY_NONE; + state.input.clickedButton = -1; + state.input.clickedQueueItem = -1; + state.input.scrollDelta = 0; + state.input.closeRequested = 0; +} + +/* Every string is an offset into one UTF-8 blob. Bad offsets are a crash, not a glitch, so the + * whole boundary is bounds checked rather than trusted. */ +bool Slice(const DeviewScreen* screen, int offset, int length, const char** begin, const char** end) +{ + if (screen->strings == nullptr || + offset < 0 || + length < 0 || + offset > screen->stringsLength || + offset + length > screen->stringsLength) + { + return false; + } + + *begin = reinterpret_cast(screen->strings) + offset; + *end = *begin + length; + return true; +} + +void Text(const DeviewScreen* screen, int offset, int length) +{ + const char* begin; + const char* end; + if (Slice(screen, offset, length, &begin, &end)) + { + ImGui::TextUnformatted(begin, end); + } + else + { + ImGui::TextUnformatted(""); + } +} + +std::string Copy(const DeviewScreen* screen, int offset, int length) +{ + const char* begin; + const char* end; + if (!Slice(screen, offset, length, &begin, &end)) + { + return {}; + } + + return {begin, static_cast(end - begin)}; +} + +ImU32 RowColour(int kind) +{ + switch (kind) + { + case DEVIEW_ROW_ADDED: + return IM_COL32(126, 214, 139, 255); + case DEVIEW_ROW_REMOVED: + return IM_COL32(233, 129, 129, 255); + case DEVIEW_ROW_MODIFIED: + return IM_COL32(231, 197, 113, 255); + default: + return IM_COL32(212, 212, 212, 255); + } +} + +ImU32 RowBackground(int kind) +{ + switch (kind) + { + case DEVIEW_ROW_ADDED: + return IM_COL32(38, 74, 44, 255); + case DEVIEW_ROW_REMOVED: + return IM_COL32(84, 40, 40, 255); + case DEVIEW_ROW_MODIFIED: + return IM_COL32(74, 64, 32, 255); + default: + return 0; + } +} + +char RowMarker(int kind) +{ + switch (kind) + { + case DEVIEW_ROW_ADDED: + return '+'; + case DEVIEW_ROW_REMOVED: + return '-'; + case DEVIEW_ROW_MODIFIED: + return '~'; + default: + return ' '; + } +} + +/* ---- texture protocol (ImGuiBackendFlags_RendererHasTextures) ---- */ + +void UpdateTexture(ImTextureData* texture) +{ + if (texture->Status == ImTextureStatus_WantCreate) + { + const int format = texture->Format == ImTextureFormat_Alpha8 + ? RL_PIXELFORMAT_UNCOMPRESSED_GRAYSCALE + : RL_PIXELFORMAT_UNCOMPRESSED_R8G8B8A8; + const unsigned int id = rlLoadTexture(texture->GetPixels(), texture->Width, texture->Height, format, 1); + texture->SetTexID(static_cast(id)); + texture->SetStatus(ImTextureStatus_OK); + return; + } + + if (texture->Status == ImTextureStatus_WantUpdates) + { + /* Re-uploading the whole texture keeps the source rows contiguous, which a sub rectangle + * of a wider buffer is not. Atlas updates only happen when new glyphs appear, so the extra + * bandwidth is irrelevant next to the copy that avoiding it would need. */ + const int format = texture->Format == ImTextureFormat_Alpha8 + ? RL_PIXELFORMAT_UNCOMPRESSED_GRAYSCALE + : RL_PIXELFORMAT_UNCOMPRESSED_R8G8B8A8; + rlUpdateTexture( + static_cast(texture->TexID), + 0, + 0, + texture->Width, + texture->Height, + format, + texture->GetPixels()); + texture->SetStatus(ImTextureStatus_OK); + return; + } + + if (texture->Status == ImTextureStatus_WantDestroy) + { + rlUnloadTexture(static_cast(texture->TexID)); + texture->SetTexID(ImTextureID_Invalid); + texture->SetStatus(ImTextureStatus_Destroyed); + } +} + +/* ---- ImDrawData through rlgl ---- */ + +void RenderTriangles( + unsigned int count, + unsigned int indexStart, + const ImVector& indices, + const ImVector& vertices, + ImTextureID textureId) +{ + if (count < 3) + { + return; + } + + rlBegin(RL_TRIANGLES); + rlSetTexture(static_cast(textureId)); + + for (unsigned int index = 0; index <= count - 3; index += 3) + { + for (unsigned int corner = 0; corner < 3; corner++) + { + const ImDrawVert& vertex = vertices[indices[indexStart + index + corner]]; + const ImColor colour = ImColor(vertex.col); + rlColor4f(colour.Value.x, colour.Value.y, colour.Value.z, colour.Value.w); + rlTexCoord2f(vertex.uv.x, vertex.uv.y); + rlVertex2f(vertex.pos.x, vertex.pos.y); + } + } + + rlEnd(); +} + +void RenderDrawData(ImDrawData* drawData) +{ + for (ImTextureData* texture : drawData->Textures ? *drawData->Textures : ImVector()) + { + if (texture->Status != ImTextureStatus_OK) + { + UpdateTexture(texture); + } + } + + rlDrawRenderBatchActive(); + rlDisableBackfaceCulling(); + + const float height = static_cast(GetScreenHeight()); + for (int list = 0; list < drawData->CmdListsCount; list++) + { + const ImDrawList* commands = drawData->CmdLists[list]; + for (const ImDrawCmd& command : commands->CmdBuffer) + { + if (command.UserCallback != nullptr) + { + command.UserCallback(commands, &command); + continue; + } + + /* ImGui clips in framebuffer space with the origin top left; rlgl scissors from the + * bottom left. */ + rlEnableScissorTest(); + rlScissor( + static_cast(command.ClipRect.x), + static_cast(height - command.ClipRect.w), + static_cast(command.ClipRect.z - command.ClipRect.x), + static_cast(command.ClipRect.w - command.ClipRect.y)); + + RenderTriangles( + command.ElemCount, + command.IdxOffset, + commands->IdxBuffer, + commands->VtxBuffer, + command.GetTexID()); + + rlDrawRenderBatchActive(); + } + } + + rlSetTexture(0); + rlDisableScissorTest(); + rlEnableBackfaceCulling(); +} + +/* ---- input ---- */ + +void PumpInput() +{ + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(static_cast(GetScreenWidth()), static_cast(GetScreenHeight())); + io.DeltaTime = GetFrameTime() > 0.0f ? GetFrameTime() : 1.0f / 60.0f; + + const Vector2 mouse = GetMousePosition(); + io.AddMousePosEvent(mouse.x, mouse.y); + io.AddMouseButtonEvent(ImGuiMouseButton_Left, IsMouseButtonDown(MOUSE_BUTTON_LEFT)); + io.AddMouseButtonEvent(ImGuiMouseButton_Right, IsMouseButtonDown(MOUSE_BUTTON_RIGHT)); + io.AddMouseButtonEvent(ImGuiMouseButton_Middle, IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)); + + const Vector2 wheel = GetMouseWheelMoveV(); + io.AddMouseWheelEvent(wheel.x, wheel.y); +} + +int ReadKey() +{ + if (IsKeyPressed(KEY_UP)) return DEVIEW_KEY_SCROLL_UP; + if (IsKeyPressed(KEY_DOWN)) return DEVIEW_KEY_SCROLL_DOWN; + if (IsKeyPressed(KEY_PAGE_UP)) return DEVIEW_KEY_PAGE_UP; + if (IsKeyPressed(KEY_PAGE_DOWN)) return DEVIEW_KEY_PAGE_DOWN; + if (IsKeyPressed(KEY_HOME)) return DEVIEW_KEY_HOME; + if (IsKeyPressed(KEY_END)) return DEVIEW_KEY_END; + if (IsKeyPressed(KEY_N)) return DEVIEW_KEY_NEXT_CHANGE; + if (IsKeyPressed(KEY_P)) return DEVIEW_KEY_PREVIOUS_CHANGE; + if (IsKeyPressed(KEY_TAB)) return IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT) + ? DEVIEW_KEY_PREVIOUS_ITEM + : DEVIEW_KEY_NEXT_ITEM; + if (IsKeyPressed(KEY_A)) return IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT) + ? DEVIEW_KEY_ACCEPT_ALL + : DEVIEW_KEY_ACCEPT; + if (IsKeyPressed(KEY_D)) return DEVIEW_KEY_DISCARD; + if (IsKeyPressed(KEY_Q) || IsKeyPressed(KEY_ESCAPE)) return DEVIEW_KEY_QUIT; + return DEVIEW_KEY_NONE; +} + +/* ---- the frame ---- */ + +void DrawRow(const DeviewScreen* screen, const DeviewPane& pane, int index, int column) +{ + if (index >= pane.rowCount) + { + return; + } + + const DeviewRow& row = screen->rows[pane.rowOffset + index]; + if (row.kind == DEVIEW_ROW_FILLER) + { + ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, IM_COL32(28, 28, 28, 255), column); + return; + } + + const ImU32 background = RowBackground(row.kind); + if (background != 0) + { + ImGui::TableSetBgColor(ImGuiTableBgTarget_CellBg, background, column); + } + + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(130, 130, 130, 255)); + if (row.lineNumber >= 0) + { + ImGui::Text("%c %4d", RowMarker(row.kind), row.lineNumber); + } + else + { + ImGui::Text("%c ", RowMarker(row.kind)); + } + + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, RowColour(row.kind)); + Text(screen, row.textOffset, row.textLength); + ImGui::PopStyleColor(); +} + +void BuildFrame(const DeviewScreen* screen) +{ + const ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->WorkPos); + ImGui::SetNextWindowSize(viewport->WorkSize); + ImGui::Begin( + "##deview", + nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoBringToFrontOnFocus | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar); + + Text(screen, screen->titleOffset, screen->titleLength); + const std::string subtitle = Copy(screen, screen->subtitleOffset, screen->subtitleLength); + if (!subtitle.empty()) + { + const float width = ImGui::CalcTextSize(subtitle.c_str()).x; + ImGui::SameLine(ImGui::GetContentRegionAvail().x - width); + ImGui::TextDisabled("%s", subtitle.c_str()); + } + + ImGui::Separator(); + + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("##body", ImVec2(0, -footer), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + + const bool hasQueue = screen->queueCount > 0; + const int columns = hasQueue ? 3 : 2; + if (screen->paneCount >= 2 && + ImGui::BeginTable("##panes", columns, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchSame)) + { + const DeviewPane& left = screen->panes[0]; + const DeviewPane& right = screen->panes[1]; + if (hasQueue) + { + ImGui::TableSetupColumn("Pending", ImGuiTableColumnFlags_WidthFixed, 220.0f); + } + + ImGui::TableSetupColumn(Copy(screen, left.headerOffset, left.headerLength).c_str()); + ImGui::TableSetupColumn(Copy(screen, right.headerOffset, right.headerLength).c_str()); + ImGui::TableHeadersRow(); + + int bodyRows = left.rowCount > right.rowCount ? left.rowCount : right.rowCount; + if (screen->queueCount > bodyRows) + { + bodyRows = screen->queueCount; + } + + for (int index = 0; index < bodyRows; index++) + { + ImGui::TableNextRow(); + int column = 0; + if (hasQueue) + { + ImGui::TableSetColumnIndex(column++); + if (index < screen->queueCount) + { + const DeviewQueueItem& item = screen->queue[index]; + const std::string label = Copy(screen, item.labelOffset, item.labelLength); + const bool selected = (item.flags & DEVIEW_QUEUE_SELECTED) != 0; + if (item.flags & DEVIEW_QUEUE_FAILED) + { + ImGui::PushStyleColor(ImGuiCol_Text, RowColour(DEVIEW_ROW_REMOVED)); + } + + ImGui::PushID(index); + if (ImGui::Selectable(label.c_str(), selected)) + { + state.input.clickedQueueItem = index; + } + + ImGui::PopID(); + if (item.flags & DEVIEW_QUEUE_FAILED) + { + ImGui::PopStyleColor(); + } + } + } + + ImGui::TableSetColumnIndex(column); + DrawRow(screen, left, index, column); + ImGui::TableSetColumnIndex(column + 1); + DrawRow(screen, right, index, column + 1); + } + + ImGui::EndTable(); + } + + ImGui::EndChild(); + ImGui::Separator(); + + for (int index = 0; index < screen->buttonCount; index++) + { + const DeviewButton& button = screen->buttons[index]; + const std::string label = Copy(screen, button.labelOffset, button.labelLength); + const bool enabled = (button.flags & DEVIEW_BUTTON_ENABLED) != 0; + if (index > 0) + { + ImGui::SameLine(); + } + + if (!enabled) + { + ImGui::BeginDisabled(); + } + + ImGui::PushID(index); + if (ImGui::Button(label.c_str())) + { + state.input.clickedButton = index; + } + + ImGui::PopID(); + if (!enabled) + { + ImGui::EndDisabled(); + } + } + + const std::string status = Copy(screen, screen->statusOffset, screen->statusLength); + if (!status.empty()) + { + const float width = ImGui::CalcTextSize(status.c_str()).x; + ImGui::SameLine(); + const float available = ImGui::GetContentRegionAvail().x; + if (available > width) + { + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + available - width); + } + + ImGui::TextDisabled("%s", status.c_str()); + } + + ImGui::End(); +} + +void ApplyStyle() +{ + ImGuiStyle& style = ImGui::GetStyle(); + ImGui::StyleColorsDark(); + style.WindowRounding = 0.0f; + style.WindowBorderSize = 0.0f; + style.WindowPadding = ImVec2(8.0f, 6.0f); + style.FramePadding = ImVec2(8.0f, 3.0f); + style.ItemSpacing = ImVec2(6.0f, 2.0f); + style.CellPadding = ImVec2(6.0f, 1.0f); + style.ScrollbarSize = 12.0f; +} +} + +extern "C" +{ +int32_t deview_version(void) +{ + return DEVIEW_VERSION; +} + +int32_t deview_init( + int32_t width, + int32_t height, + const char* title, + const uint8_t* fontTtf, + int32_t fontLength, + float fontSize, + int32_t hidden) +{ + if (state.initialised) + { + return 1; + } + + SetTraceLogLevel(LOG_WARNING); + /* No MSAA. ImGui draws axis aligned quads with pre-antialiased glyph textures, so multisampling + * buys nothing visually, and it is a real source of difference between a GPU and the software + * rasteriser the pixel snapshots are pinned to. */ + unsigned int flags = FLAG_WINDOW_RESIZABLE; + if (hidden != 0) + { + flags |= FLAG_WINDOW_HIDDEN; + } + + SetConfigFlags(flags); + InitWindow(width, height, title == nullptr ? "DiffEngineViewer" : title); + if (!IsWindowReady()) + { + return 0; + } + + SetExitKey(KEY_NULL); + SetTargetFPS(60); + + state.context = ImGui::CreateContext(); + ImGui::SetCurrentContext(state.context); + ImGuiIO& io = ImGui::GetIO(); + io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; + io.IniFilename = nullptr; + io.LogFilename = nullptr; + ApplyStyle(); + + if (fontTtf != nullptr && fontLength > 0) + { + /* ImGui frees font data with its own allocator, so hand it a copy rather than memory + * owned by the managed heap. */ + void* copy = IM_ALLOC(static_cast(fontLength)); + memcpy(copy, fontTtf, static_cast(fontLength)); + ImFontConfig config; + config.FontDataOwnedByAtlas = true; + io.Fonts->AddFontFromMemoryTTF(copy, fontLength, fontSize <= 0.0f ? 15.0f : fontSize, &config); + } + + ResetInput(); + state.initialised = true; + state.windowOpen = true; + return 1; +} + +int32_t deview_present(const DeviewScreen* screen) +{ + if (!state.initialised || !state.windowOpen || screen == nullptr) + { + return 0; + } + + if (WindowShouldClose()) + { + /* Reported once to the managed side, which decides between hiding and exiting depending + * on whether a tray is running. Cleared immediately so a hidden window can be shown again + * rather than closing itself on its first frame back. */ + state.input.closeRequested = 1; + ClearCloseFlag(); + } + + ImGui::SetCurrentContext(state.context); + PumpInput(); + ImGui::NewFrame(); + BuildFrame(screen); + ImGui::Render(); + + BeginDrawing(); + ClearBackground(Color{24, 24, 24, 255}); + RenderDrawData(ImGui::GetDrawData()); + EndDrawing(); + + state.input.columns = GetScreenWidth(); + state.input.rows = GetScreenHeight(); + return 1; +} + +void deview_poll_input(DeviewInput* input) +{ + if (input == nullptr) + { + return; + } + + if (state.initialised) + { + state.input.key = ReadKey(); + const Vector2 wheel = GetMouseWheelMoveV(); + state.input.scrollDelta = static_cast(wheel.y); + state.input.columns = GetScreenWidth(); + state.input.rows = GetScreenHeight(); + } + + *input = state.input; + ResetInput(); +} + +int32_t deview_capture(const DeviewScreen* screen, int32_t width, int32_t height, const char* pngPath) +{ + if (!state.initialised || screen == nullptr || pngPath == nullptr) + { + return 0; + } + + ImGui::SetCurrentContext(state.context); + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(static_cast(width), static_cast(height)); + io.DeltaTime = 1.0f / 60.0f; + + RenderTexture2D target = LoadRenderTexture(width, height); + if (!IsRenderTextureValid(target)) + { + return 0; + } + + ImGui::NewFrame(); + BuildFrame(screen); + ImGui::Render(); + + BeginTextureMode(target); + ClearBackground(Color{24, 24, 24, 255}); + RenderDrawData(ImGui::GetDrawData()); + EndTextureMode(); + + Image image = LoadImageFromTexture(target.texture); + /* Render textures come back bottom up. */ + ImageFlipVertical(&image); + const bool exported = ExportImage(image, pngPath); + UnloadImage(image); + UnloadRenderTexture(target); + ResetInput(); + return exported ? 1 : 0; +} + +void deview_set_hidden(int32_t hidden) +{ + if (!state.initialised) + { + return; + } + + if (hidden != 0) + { + SetWindowState(FLAG_WINDOW_HIDDEN); + return; + } + + ClearWindowState(FLAG_WINDOW_HIDDEN); +} + +void deview_focus(void) +{ + if (!state.initialised) + { + return; + } + + ClearWindowState(FLAG_WINDOW_HIDDEN); + SetWindowFocused(); +} + +void deview_shutdown(void) +{ + if (!state.initialised) + { + return; + } + + if (state.context != nullptr) + { + ImGui::SetCurrentContext(state.context); + ImGui::DestroyContext(state.context); + state.context = nullptr; + } + + CloseWindow(); + state.initialised = false; + state.windowOpen = false; +} +} diff --git a/readme.md b/readme.md index cc3a5458..7e951a7e 100644 --- a/readme.md +++ b/readme.md @@ -55,6 +55,7 @@ DiffEngine manages launching and cleanup of diff tools. It is designed to be use * [Tools](/docs/diff-tool.md) * [Tool Order](/docs/diff-tool.order.md) * [Custom Tool](/docs/diff-tool.custom.md) + * [DiffEngineViewer](/docs/viewer.md) * [DiffEngineTray](/docs/tray.md) * [Code versus machine level settings](/docs/code-versus-machine-settings.md) @@ -70,6 +71,7 @@ DiffEngine manages launching and cleanup of diff tools. It is designed to be use * **[BeyondCompare](/docs/diff-tool.md#beyondcompare)** Windows/OSX/Linux (Cost: Paid) * **[Cursor](/docs/diff-tool.md#cursor)** Windows/OSX/Linux (Cost: Free and Paid) * **[DeltaWalker](/docs/diff-tool.md#deltawalker)** Windows/OSX (Cost: Paid) + * **[DiffEngineViewer](/docs/diff-tool.md#diffengineviewer)** Windows/OSX/Linux (Cost: Free) * **[Diffinity](/docs/diff-tool.md#diffinity)** Windows (Cost: Free with option to donate) * **[ExamDiff](/docs/diff-tool.md#examdiff)** Windows (Cost: Paid) * **[Guiffy](/docs/diff-tool.md#guiffy)** Windows/OSX (Cost: Paid) diff --git a/src/DiffEngine.Tests/defaultOrder.include.md b/src/DiffEngine.Tests/defaultOrder.include.md index 38a27feb..ab54a8bd 100644 --- a/src/DiffEngine.Tests/defaultOrder.include.md +++ b/src/DiffEngine.Tests/defaultOrder.include.md @@ -23,3 +23,4 @@ * **[VisualStudioCode](/docs/diff-tool.md#visualstudiocode)** Windows/OSX/Linux (Cost: Free) * **[Cursor](/docs/diff-tool.md#cursor)** Windows/OSX/Linux (Cost: Free and Paid) * **[VisualStudio](/docs/diff-tool.md#visualstudio)** Windows (Cost: Paid and free options) + * **[DiffEngineViewer](/docs/diff-tool.md#diffengineviewer)** Windows/OSX/Linux (Cost: Free) diff --git a/src/DiffEngine.Tests/diffToolList.include.md b/src/DiffEngine.Tests/diffToolList.include.md index 153aaf5c..59b0f31c 100644 --- a/src/DiffEngine.Tests/diffToolList.include.md +++ b/src/DiffEngine.Tests/diffToolList.include.md @@ -2,6 +2,7 @@ * **[BeyondCompare](/docs/diff-tool.md#beyondcompare)** Windows/OSX/Linux (Cost: Paid) * **[Cursor](/docs/diff-tool.md#cursor)** Windows/OSX/Linux (Cost: Free and Paid) * **[DeltaWalker](/docs/diff-tool.md#deltawalker)** Windows/OSX (Cost: Paid) + * **[DiffEngineViewer](/docs/diff-tool.md#diffengineviewer)** Windows/OSX/Linux (Cost: Free) * **[Diffinity](/docs/diff-tool.md#diffinity)** Windows (Cost: Free with option to donate) * **[ExamDiff](/docs/diff-tool.md#examdiff)** Windows (Cost: Paid) * **[Guiffy](/docs/diff-tool.md#guiffy)** Windows/OSX (Cost: Paid) diff --git a/src/DiffEngine.Tests/diffTools.include.md b/src/DiffEngine.Tests/diffTools.include.md index 2bc1a52d..bfffe936 100644 --- a/src/DiffEngine.Tests/diffTools.include.md +++ b/src/DiffEngine.Tests/diffTools.include.md @@ -128,6 +128,72 @@ DiffTools.UseOrder(DiffTool.DeltaWalker); * `/Applications/DeltaWalker.app/Contents/MacOS/DeltaWalker` * `%PATH%DeltaWalker` +### [DiffEngineViewer](https://github.com/VerifyTests/DiffEngine) + + * Cost: Free + * Is MDI: False + * Supports auto-refresh: False + * Supports text files: True + * Use shell execute: False + * Create no window: True + * Environment variable for custom install location: `DiffEngine_DiffEngineViewer` + +#### Tool order: + +Use [tool order](diff-tool.order.md) to prioritise DiffEngineViewer over other tools. + +``` +DiffTools.UseOrder(DiffTool.DiffEngineViewer); +``` + +#### Notes: + + * Bundled inside the DiffEngine package, so it needs no install + * Also available standalone via `dotnet tool install -g DiffEngineViewer` + * Cross platform: Windows, macOS and Linux + +#### Windows settings: + + * Example target on left arguments: + ``` + "targetFile.txt" "tempFile.txt" + ``` + * Example target on right arguments: + ``` + "tempFile.txt" "targetFile.txt" + ``` + * Scanned paths: + * `%USERPROFILE%\.dotnet\tools\DiffEngineViewer.exe` + * `%PATH%DiffEngineViewer.exe` + +#### OSX settings: + + * Example target on left arguments: + ``` + "targetFile.txt" "tempFile.txt" + ``` + * Example target on right arguments: + ``` + "tempFile.txt" "targetFile.txt" + ``` + * Scanned paths: + * `$HOME/.dotnet/tools/DiffEngineViewer` + * `%PATH%DiffEngineViewer` + +#### Linux settings: + + * Example target on left arguments: + ``` + "targetFile.txt" "tempFile.txt" + ``` + * Example target on right arguments: + ``` + "tempFile.txt" "targetFile.txt" + ``` + * Scanned paths: + * `$HOME/.dotnet/tools/DiffEngineViewer` + * `%PATH%DiffEngineViewer` + ### [Diffinity](https://truehumandesign.se/s_diffinity.php) * Cost: Free with option to donate diff --git a/src/DiffEngine.slnx b/src/DiffEngine.slnx index be336828..72b230ec 100644 --- a/src/DiffEngine.slnx +++ b/src/DiffEngine.slnx @@ -5,8 +5,11 @@ - + + + + @@ -19,11 +22,23 @@ + + + + + + + + diff --git a/src/DiffEngine/Definitions.cs b/src/DiffEngine/Definitions.cs index 9af1f297..6d9db7a2 100644 --- a/src/DiffEngine/Definitions.cs +++ b/src/DiffEngine/Definitions.cs @@ -33,5 +33,6 @@ static Definitions() => Implementation.VisualStudioCode(), Implementation.Cursor(), Implementation.VisualStudio(), + Implementation.DiffEngineViewer(), ]; } diff --git a/src/DiffEngine/DiffEngine.csproj b/src/DiffEngine/DiffEngine.csproj index 156605d5..82fa027c 100644 --- a/src/DiffEngine/DiffEngine.csproj +++ b/src/DiffEngine/DiffEngine.csproj @@ -20,5 +20,70 @@ + + + + + + $(MSBuildProjectDirectory)\obj\viewer\ + true + AddViewerToPackage;$(GenerateNuspecDependsOn) + + + + + + + + + + + + + + + + + + + + + <_ViewerFile Include="$(ViewerStageDir)**\*" /> + + + <_PackageFiles Include="@(_ViewerFile)" + BuildAction="None" + PackagePath="tools\viewer" /> + + diff --git a/src/DiffEngine/DiffRunner_Inline.cs b/src/DiffEngine/DiffRunner_Inline.cs new file mode 100644 index 00000000..07c12b54 --- /dev/null +++ b/src/DiffEngine/DiffRunner_Inline.cs @@ -0,0 +1,104 @@ +namespace DiffEngine; + +public enum InlineResult +{ + /// + /// Handed to the viewer, either by forwarding to a running one or by launching it. + /// + Queued, + + /// + /// , which also covers build servers, continuous testing and + /// AI CLIs. + /// + Disabled, + + /// + /// No DiffEngineViewer could be resolved. Callers that want a fallback should use it here. + /// + NoViewerFound +} + +public static partial class DiffRunner +{ + /// + /// Set DiffEngine_InlineViewer to false to stop inline snapshots opening a window. + /// + public const string InlineViewerVariable = "DiffEngine_InlineViewer"; + + /// + /// Sends a pending inline snapshot to DiffEngineViewer for review. + /// + /// Takes the patch itself rather than a file, so nothing is written to disk: an already + /// running viewer receives it over a loopback socket, and a newly launched one receives it on + /// stdin. + /// + /// + public static InlineResult AddInline(InlinePatch patch) + { + var check = CheckInline(); + if (check != InlineResult.Queued) + { + return check; + } + + var payload = InlinePatchFile.Build(patch); + if (ViewerClient.TrySend(ViewerPayload.Inline(payload))) + { + return InlineResult.Queued; + } + + return ViewerLauncher.Launch(patch, payload) ? InlineResult.Queued : InlineResult.NoViewerFound; + } + + /// + public static async Task AddInlineAsync(InlinePatch patch, Cancel cancel = default) + { + var check = CheckInline(); + if (check != InlineResult.Queued) + { + return check; + } + + var payload = InlinePatchFile.Build(patch); + if (await ViewerClient.TrySendAsync(ViewerPayload.Inline(payload), cancel)) + { + return InlineResult.Queued; + } + + var launched = await ViewerLauncher.LaunchAsync(patch, payload, cancel); + return launched ? InlineResult.Queued : InlineResult.NoViewerFound; + } + + /// + /// Drops a pending inline snapshot from the viewer's queue, for when a previously failing test + /// starts passing. Does nothing when no viewer is running. + /// + public static void SettleInline(string sourceFile, int line) + { + if (Disabled) + { + return; + } + + ViewerClient.TrySend(ViewerPayload.Settle(sourceFile, line)); + } + + static InlineResult CheckInline() + { + if (Disabled) + { + return InlineResult.Disabled; + } + + var value = Environment.GetEnvironmentVariable(InlineViewerVariable); + if (value != null && + bool.TryParse(value, out var enabled) && + !enabled) + { + return InlineResult.NoViewerFound; + } + + return InlineResult.Queued; + } +} diff --git a/src/DiffEngine/DiffRunner_InlineMove.cs b/src/DiffEngine/DiffRunner_InlineMove.cs deleted file mode 100644 index be791630..00000000 --- a/src/DiffEngine/DiffRunner_InlineMove.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace DiffEngine; - -public enum InlineMoveResult -{ - Sent, - Disabled, - TrayNotRunning, - - /// - /// The running tray predates inline snapshot support. - /// Update with: dotnet tool update -g DiffEngineTray - /// - TrayTooOld -} - -public static partial class DiffRunner -{ - static readonly Version minInlineTrayVersion = new(20, 0, 0); - - /// - /// Notifies the tray of a pending inline snapshot edit. - /// - /// The staged received text file. Used as the tracking key and the diff left side. - /// The .cs source file the snapshot will be spliced into. - /// The staged patch file (see ). - /// Optional staged expected text file, used as the diff right side. - public static InlineMoveResult AddInlineMove( - string tempFile, - string targetFile, - string patchFile, - string? stagedVerifiedFile = null) - { - var check = CheckInlineMove(); - if (check != InlineMoveResult.Sent) - { - return check; - } - - PiperClient.SendInlineMove(tempFile, targetFile, patchFile, stagedVerifiedFile); - return InlineMoveResult.Sent; - } - - /// - public static async Task AddInlineMoveAsync( - string tempFile, - string targetFile, - string patchFile, - string? stagedVerifiedFile = null, - Cancel cancel = default) - { - var check = CheckInlineMove(); - if (check != InlineMoveResult.Sent) - { - return check; - } - - await PiperClient.SendInlineMoveAsync(tempFile, targetFile, patchFile, stagedVerifiedFile, cancel); - return InlineMoveResult.Sent; - } - - static InlineMoveResult CheckInlineMove() - { - if (Disabled) - { - return InlineMoveResult.Disabled; - } - - if (!TrayDetector.IsRunning()) - { - return InlineMoveResult.TrayNotRunning; - } - - if (!TrayVersionFile.TryRead(out var version) || - version < minInlineTrayVersion) - { - return InlineMoveResult.TrayTooOld; - } - - return InlineMoveResult.Sent; - } -} diff --git a/src/DiffEngine/DiffTool.cs b/src/DiffEngine/DiffTool.cs index ab96dddf..0ab10a4c 100644 --- a/src/DiffEngine/DiffTool.cs +++ b/src/DiffEngine/DiffTool.cs @@ -29,4 +29,8 @@ public enum DiffTool VisualStudioCode, VisualStudio, Cursor, + + // Last, so it is the fallback used only when nothing else is installed. Bundled inside the + // DiffEngine package, so unlike every other entry it is always present. + DiffEngineViewer, } \ No newline at end of file diff --git a/src/DiffEngine/Implementation/DiffEngineViewer.cs b/src/DiffEngine/Implementation/DiffEngineViewer.cs new file mode 100644 index 00000000..6ffc12e1 --- /dev/null +++ b/src/DiffEngine/Implementation/DiffEngineViewer.cs @@ -0,0 +1,59 @@ +static partial class Implementation +{ + public static Definition DiffEngineViewer() + { + var launchArguments = new LaunchArguments( + Left: (temp, target) => $"\"{target}\" \"{temp}\"", + Right: (temp, target) => $"\"{temp}\" \"{target}\""); + + return new( + Tool: DiffTool.DiffEngineViewer, + Url: "https://github.com/VerifyTests/DiffEngine", + AutoRefresh: false, + IsMdi: false, + SupportsText: true, + RequiresTarget: true, + BinaryExtensions: [], + Cost: "Free", + OsSupport: new( + Windows: new( + "DiffEngineViewer.exe", + launchArguments, + SearchDirectories(@"%USERPROFILE%\.dotnet\tools\")), + Linux: new( + "DiffEngineViewer", + launchArguments, + SearchDirectories("$HOME/.dotnet/tools/")), + Osx: new( + "DiffEngineViewer", + launchArguments, + SearchDirectories("$HOME/.dotnet/tools/"))), + UseShellExecute: false, + // Console subsystem, so without this a window flashes on every launch. + CreateNoWindow: true, + Notes: """ + * Bundled inside the DiffEngine package, so it needs no install + * Also available standalone via `dotnet tool install -g DiffEngineViewer` + * Cross platform: Windows, macOS and Linux + """); + } + + /// + /// The bundled copy is preferred over a globally installed tool, because it is always version + /// matched to the library that is about to launch it. + /// + static string[] SearchDirectories(string toolsDirectory) + { + var bundled = BundledViewerDirectory.Find(); + if (bundled == null) + { + return [toolsDirectory]; + } + + return + [ + bundled, + toolsDirectory + ]; + } +} diff --git a/src/DiffEngine/InternalsVisibleTo.cs b/src/DiffEngine/InternalsVisibleTo.cs index 6f581cf8..b94a22c2 100644 --- a/src/DiffEngine/InternalsVisibleTo.cs +++ b/src/DiffEngine/InternalsVisibleTo.cs @@ -1,4 +1,5 @@ [assembly: InternalsVisibleTo("DiffEngine.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] [assembly: InternalsVisibleTo("VersionTests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] [assembly: InternalsVisibleTo("DiffEngineTray, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] -[assembly: InternalsVisibleTo("DiffEngineTray.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] \ No newline at end of file +[assembly: InternalsVisibleTo("DiffEngineTray.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] +[assembly: InternalsVisibleTo("DiffEngineViewer.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] \ No newline at end of file diff --git a/src/DiffEngine/Tray/PiperClient.cs b/src/DiffEngine/Tray/PiperClient.cs index f50f245e..eb355937 100644 --- a/src/DiffEngine/Tray/PiperClient.cs +++ b/src/DiffEngine/Tray/PiperClient.cs @@ -1,4 +1,4 @@ -static class PiperClient +static class PiperClient { public static int Port = 3492; @@ -79,49 +79,6 @@ public static string BuildMovePayload(string tempFile, string targetFile, string return builder.ToString(); } - public static void SendInlineMove( - string tempFile, - string targetFile, - string patchFile, - string? stagedVerified) => - Send(BuildInlineMovePayload(tempFile, targetFile, patchFile, stagedVerified)); - - public static Task SendInlineMoveAsync( - string tempFile, - string targetFile, - string patchFile, - string? stagedVerified, - Cancel cancel = default) - { - var payload = BuildInlineMovePayload(tempFile, targetFile, patchFile, stagedVerified); - return SendAsync(payload, cancel); - } - - public static string BuildInlineMovePayload(string tempFile, string targetFile, string patchFile, string? stagedVerified) - { - var builder = new StringBuilder( - $$""" - { - "Type":"InlineMove", - "Temp":"{{tempFile.JsonEscape()}}", - "Target":"{{targetFile.JsonEscape()}}", - "PatchFile":"{{patchFile.JsonEscape()}}" - """); - - if (stagedVerified != null) - { - builder.Append( - $""" - , - "StagedVerified":"{stagedVerified.JsonEscape()}" - """); - } - - builder.AppendLine(); - builder.Append('}'); - return builder.ToString(); - } - static void Send(string payload) { try diff --git a/src/DiffEngine/Viewer/BundledViewerDirectory.cs b/src/DiffEngine/Viewer/BundledViewerDirectory.cs new file mode 100644 index 00000000..231205cd --- /dev/null +++ b/src/DiffEngine/Viewer/BundledViewerDirectory.cs @@ -0,0 +1,79 @@ +namespace DiffEngine; + +/// +/// Locates the copy of DiffEngineViewer bundled inside DiffEngine.nupkg, so inline snapshots work +/// with no extra install. +/// +/// buildTransitive/DiffEngine.targets writes the package's tools/viewer path into the consuming +/// project's runtimeconfig as DiffEngine.ViewerDirectory. Only projects that produce a +/// runtimeconfig can carry it, which rules out net462 to net48; those fall back to the globally +/// installed dotnet tool. +/// +/// +static class BundledViewerDirectory +{ + public const string Key = "DiffEngine.ViewerDirectory"; + +#if NET6_0_OR_GREATER + public static string? Find() + { + if (AppContext.GetData(Key) is not string root || + root.Length == 0) + { + return null; + } + + foreach (var rid in Rids()) + { + var directory = Path.Combine(root, rid); + if (Directory.Exists(directory)) + { + return directory; + } + } + + return null; + } + + static IEnumerable Rids() + { + // The framework's own value first. On Alpine that is linux-musl-x64, a RID we do not + // ship, so the probe misses and the caller falls through to the dotnet tool rather than + // resolving a glibc build against musl. + yield return RuntimeInformation.RuntimeIdentifier; + + var architecture = RuntimeInformation.OSArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + _ => null + }; + + if (architecture == null) + { + yield break; + } + + if (OperatingSystem.IsWindows()) + { + yield return $"win-{architecture}"; + } + else if (OperatingSystem.IsMacOS()) + { + yield return $"osx-{architecture}"; + } + else if (OperatingSystem.IsLinux()) + { + yield return $"linux-{architecture}"; + } + } +#else + /// + /// .NET Framework consumers have no runtimeconfig to carry the path, so there is nothing to + /// find and resolution falls through to the dotnet tool location. + /// + public static string? Find() => + null; +#endif +} diff --git a/src/DiffEngine/Viewer/ViewerClient.cs b/src/DiffEngine/Viewer/ViewerClient.cs new file mode 100644 index 00000000..f989423f --- /dev/null +++ b/src/DiffEngine/Viewer/ViewerClient.cs @@ -0,0 +1,105 @@ +namespace DiffEngine; + +/// +/// Sends to an already running viewer. A refused connection means no viewer owns the port, which +/// the caller turns into a launch. +/// +static class ViewerClient +{ + public const int DefaultPort = 3493; + public const string PortVariable = "DiffEngine_ViewerPort"; + + public static int Port + { + get + { + var value = Environment.GetEnvironmentVariable(PortVariable); + if (int.TryParse(value, out var port) && + port > 0 && + port < 65536) + { + return port; + } + + return DefaultPort; + } + } + + static readonly TimeSpan timeout = TimeSpan.FromSeconds(3); + + /// + /// True when the viewer acknowledged. A refused connection means no viewer is running. + /// + public static bool TrySend(string payload) => + TryExchange(payload, out var response) && + response.Contains("status: ok"); + + /// + /// True when a reply arrived, whatever it says. Callers that need the body, such as the + /// tray listing pending snapshots, use this rather than . + /// + public static bool TryExchange(string payload, out string response) + { + response = ""; + try + { + using var client = new TcpClient(); + if (!client.ConnectAsync(IPAddress.Loopback, Port).Wait(timeout)) + { + return false; + } + + response = Write(client, payload); + return true; + } + catch (Exception exception) + when (Ignorable(exception)) + { + return false; + } + } + + public static async Task TrySendAsync(string payload, Cancel cancel) + { + try + { + using var client = new TcpClient(); +#if NET6_0_OR_GREATER + await client.ConnectAsync(IPAddress.Loopback, Port, cancel); +#else + cancel.ThrowIfCancellationRequested(); + using (cancel.Register(client.Close)) + { + await client.ConnectAsync(IPAddress.Loopback, Port); + } +#endif + return Write(client, payload).Contains("status: ok"); + } + // Cancellation is the caller's business; a missing viewer is not. + catch (Exception exception) + when (exception is not OperationCanceledException && Ignorable(exception)) + { + return false; + } + } + + static string Write(TcpClient client, string payload) + { + client.SendTimeout = (int) timeout.TotalMilliseconds; + client.ReceiveTimeout = (int) timeout.TotalMilliseconds; + var stream = client.GetStream(); + var bytes = Encoding.UTF8.GetBytes(payload); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(); + + // Half close so the viewer sees the end of the request without losing the socket it + // replies on. The reply is only an acknowledgement, so it is read and dropped. + client.Client.Shutdown(SocketShutdown.Send); + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + static bool Ignorable(Exception exception) => + exception is SocketException or IOException or ObjectDisposedException || + exception is AggregateException { InnerException: SocketException or IOException }; +} diff --git a/src/DiffEngine/Viewer/ViewerLauncher.cs b/src/DiffEngine/Viewer/ViewerLauncher.cs new file mode 100644 index 00000000..3ee233fc --- /dev/null +++ b/src/DiffEngine/Viewer/ViewerLauncher.cs @@ -0,0 +1,87 @@ +namespace DiffEngine; + +/// +/// Starts DiffEngineViewer with a patch on stdin. +/// +/// Resolution goes through the normal tool discovery, so the bundled copy, a globally installed +/// dotnet tool and a DiffEngine_DiffEngineViewer override all work the same way. +/// +/// +static class ViewerLauncher +{ + public static bool Launch(InlinePatch patch, string payload) + { + var process = Start(patch); + if (process == null) + { + return false; + } + + try + { + process.StandardInput.Write(payload); + process.StandardInput.Close(); + return true; + } + catch (IOException) + { + // The viewer died before reading, so treat it as not launched. + return false; + } + } + + public static async Task LaunchAsync(InlinePatch patch, string payload, Cancel cancel) + { + var process = Start(patch); + if (process == null) + { + return false; + } + + try + { +#if NET6_0_OR_GREATER + await process.StandardInput.WriteAsync(payload.AsMemory(), cancel); +#else + await process.StandardInput.WriteAsync(payload); +#endif + process.StandardInput.Close(); + return true; + } + catch (IOException) + { + return false; + } + } + + static Process? Start(InlinePatch patch) + { + if (!DiffTools.TryFindByName(DiffTool.DiffEngineViewer, out var tool)) + { + return null; + } + + // The source and line go on the command line, not just in the payload, so each launch is + // distinguishable: ProcessCleanup matches on command line, and it makes the process + // readable in a task manager. + var arguments = $"--inline --source \"{patch.SourceFile}\" --line {patch.LineHint}"; + var info = new ProcessStartInfo(tool.ExePath, arguments) + { + UseShellExecute = false, + CreateNoWindow = true, + // The patch is written here rather than passed as an argument: snapshots routinely + // exceed the command line length limit and would need escaping. + RedirectStandardInput = true + }; + + try + { + return Process.Start(info); + } + catch (Exception exception) + { + Trace.WriteLine($"Failed to launch DiffEngineViewer: {exception}"); + return null; + } + } +} diff --git a/src/DiffEngine/Viewer/ViewerPayload.cs b/src/DiffEngine/Viewer/ViewerPayload.cs new file mode 100644 index 00000000..fac319c6 --- /dev/null +++ b/src/DiffEngine/Viewer/ViewerPayload.cs @@ -0,0 +1,66 @@ +namespace DiffEngine; + +/// +/// Builds messages for a running DiffEngineViewer. Used by DiffEngine to queue snapshots, and by +/// DiffEngineTray to drive the queue. +/// +/// The format is duplicated here rather than shared with the viewer, because the viewer is net10 +/// only while this assembly targets down to net462 and stays AOT compatible. ViewerProtocolTests +/// parses these with the viewer's own reader so the two cannot drift. +/// +/// +static class ViewerPayload +{ + public const int Version = 1; + + public static string Build(string verb, string? key = null, string? body = null) + { + var builder = new StringBuilder($"version: {Version}\nverb: {verb}\n"); + if (key != null) + { + builder.Append($"key: {Encode(key)}\n"); + } + + if (body != null) + { + builder.Append($"body: {Encode(body)}\n"); + } + + return builder.ToString(); + } + + public static string Inline(string patchFilePayload) => + Build("inline", body: patchFilePayload); + + public static string Settle(string sourceFile, int line) => + Build("settle", Key(sourceFile, line)); + + /// + /// Must match QueueEntry.KeyForInline in the viewer. + /// + public static string Key(string sourceFile, int line) => + $"{sourceFile.ToLowerInvariant()}|{line}"; + + static string Encode(string value) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + + public static bool TryDecode(string value, out string decoded) + { + if (value.Length == 0) + { + decoded = ""; + return true; + } + + try + { + decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value)); + return true; + } + catch (FormatException) + { + decoded = ""; + return false; + } + } +} diff --git a/src/DiffEngine/buildTransitive/DiffEngine.targets b/src/DiffEngine/buildTransitive/DiffEngine.targets new file mode 100644 index 00000000..c10b23b5 --- /dev/null +++ b/src/DiffEngine/buildTransitive/DiffEngine.targets @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs b/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs index 8ce6ab29..f5b30342 100644 --- a/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs +++ b/src/DiffEngineTray.Tests/DiffRunnerCanKillTest.cs @@ -54,7 +54,7 @@ static async Task CaptureMove(Func> launch) { MovePayload? received = null; var source = new CancelSource(); - var server = PiperServer.Start(move => received = move, _ => { }, _ => { }, source.Token); + var server = PiperServer.Start(move => received = move, _ => { }, source.Token); try { var result = await launch(); diff --git a/src/DiffEngineTray.Tests/FakeViewer.cs b/src/DiffEngineTray.Tests/FakeViewer.cs new file mode 100644 index 00000000..b5a11cc5 --- /dev/null +++ b/src/DiffEngineTray.Tests/FakeViewer.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +/// +/// Stands in for a running DiffEngineViewer, so the tray's half of the protocol is exercised over +/// a real socket rather than a mocked proxy. +/// +/// Binds an ephemeral port and points DiffEngine_ViewerPort at it, which keeps a viewer that +/// happens to be running on this machine out of the way. +/// +/// +sealed class FakeViewer : IDisposable +{ + readonly TcpListener listener; + readonly CancelSource cancel = new(); + readonly string? previousPort; + readonly Task listening; + + public FakeViewer(params string[] names) + { + foreach (var name in names) + { + Queue.Add(new($"c:\\repo\\{name.ToLowerInvariant()}|1", name, null)); + } + + listener = new(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint) listener.LocalEndpoint).Port; + previousPort = Environment.GetEnvironmentVariable(ViewerClient.PortVariable); + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, port.ToString()); + listening = Task.Run(Listen); + } + + public List Queue { get; } = []; + public List Verbs { get; } = []; + + /// + /// When false, every acting verb reports failure, which is how the tray's warning path is + /// reached without arranging a locked file. + /// + public bool Succeed { get; set; } = true; + + public string? FailureMessage { get; set; } = "the file is locked"; + + async Task Listen() + { + while (!cancel.IsCancellationRequested) + { + try + { + using var client = await listener.AcceptTcpClientAsync(cancel.Token); + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + var request = await reader.ReadToEndAsync(cancel.Token); + var bytes = Encoding.UTF8.GetBytes(Respond(request)); + await stream.WriteAsync(bytes, cancel.Token); + await stream.FlushAsync(cancel.Token); + } + catch (Exception exception) + when (exception is OperationCanceledException or ObjectDisposedException or SocketException) + { + return; + } + } + } + + string Respond(string request) + { + var verb = Read(request, "verb"); + var key = Decode(Read(request, "key")); + Verbs.Add(key == null ? verb : $"{verb}:{key}"); + + var builder = new StringBuilder("version: 1\n"); + if (verb is "accept" or "discard" or "acceptall" or "discardall" && + !Succeed) + { + builder.Append("status: error\n"); + Append(builder, "message", FailureMessage); + return builder.ToString(); + } + + switch (verb) + { + case "accept": + case "discard": + Queue.RemoveAll(_ => _.Key == key); + break; + case "acceptall": + case "discardall": + Queue.Clear(); + break; + } + + builder.Append("status: ok\n"); + if (verb == "list") + { + foreach (var item in Queue) + { + var status = item.Status == null ? "" : Encode(item.Status); + builder.Append($"item: {Encode(item.Key)}|{Encode(item.Name)}|{status}\n"); + } + } + + return builder.ToString(); + } + + static void Append(StringBuilder builder, string name, string? value) + { + if (value != null) + { + builder.Append($"{name}: {Encode(value)}\n"); + } + } + + static string Read(string request, string name) + { + foreach (var line in request.Split('\n')) + { + var trimmed = line.TrimEnd('\r'); + if (trimmed.StartsWith($"{name}: ", StringComparison.Ordinal)) + { + return trimmed[(name.Length + 2)..]; + } + } + + return ""; + } + + static string? Decode(string value) => + value.Length == 0 ? null : Encoding.UTF8.GetString(Convert.FromBase64String(value)); + + static string Encode(string value) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + + public void Dispose() + { + cancel.Cancel(); + listener.Stop(); + try + { + listening.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + // Cancellation unwinds through the listener; nothing to report. + } + + cancel.Dispose(); + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, previousPort); + } +} diff --git a/src/DiffEngineTray.Tests/MenuBuilderTest.FullWithInline.verified.png b/src/DiffEngineTray.Tests/MenuBuilderTest.FullWithInline.verified.png index b1b466f8..0c4aeda6 100644 Binary files a/src/DiffEngineTray.Tests/MenuBuilderTest.FullWithInline.verified.png and b/src/DiffEngineTray.Tests/MenuBuilderTest.FullWithInline.verified.png differ diff --git a/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png b/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png index 4552fb8b..b8c1eef4 100644 Binary files a/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png and b/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png differ diff --git a/src/DiffEngineTray.Tests/MenuBuilderTest.cs b/src/DiffEngineTray.Tests/MenuBuilderTest.cs index 5d0a5171..81226bb8 100644 --- a/src/DiffEngineTray.Tests/MenuBuilderTest.cs +++ b/src/DiffEngineTray.Tests/MenuBuilderTest.cs @@ -119,8 +119,8 @@ public async Task FullGrouped() [Test] public async Task OnlyInline() { + using var viewer = new FakeViewer("Sample.cs:12", "Other.cs:40"); await using var tracker = new RecordingTracker(); - tracker.AddInlineMove(file1, "Tests.cs", file2, file3); var menu = MenuBuilder.Build( emptyAction, emptyAction, @@ -131,10 +131,10 @@ public async Task OnlyInline() [Test] public async Task FullWithInline() { + using var viewer = new FakeViewer("Sample.cs:12"); await using var tracker = new RecordingTracker(); tracker.AddDelete(file1); tracker.AddMove(file3, file3, "theExe", "theArguments", true, null); - tracker.AddInlineMove(file4, "Tests.cs", file2, null); var menu = MenuBuilder.Build( emptyAction, emptyAction, diff --git a/src/DiffEngineTray.Tests/ModuleInitializer.cs b/src/DiffEngineTray.Tests/ModuleInitializer.cs index 81cb8b1b..a4491c52 100644 --- a/src/DiffEngineTray.Tests/ModuleInitializer.cs +++ b/src/DiffEngineTray.Tests/ModuleInitializer.cs @@ -1,9 +1,27 @@ -public static class ModuleInitializer +using System.Net; +using System.Net.Sockets; + +public static class ModuleInitializer { [ModuleInitializer] public static void Initialize() { VerifyWinForms.Initialize(); VerifierSettings.UseSsimForPng(); + PointAtAClosedPort(); } -} \ No newline at end of file + + /// + /// The tray asks the viewer for pending snapshots. Without this a DiffEngineViewer running on + /// the developer's machine would answer, and tests that expect nothing pending would see its + /// queue. FakeViewer overrides this for the tests that do want a viewer. + /// + static void PointAtAClosedPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint) listener.LocalEndpoint).Port; + listener.Stop(); + Environment.SetEnvironmentVariable(ViewerClient.PortVariable, port.ToString()); + } +} diff --git a/src/DiffEngineTray.Tests/PiperTest.InlineMove.verified.txt b/src/DiffEngineTray.Tests/PiperTest.InlineMove.verified.txt deleted file mode 100644 index 34a4e90d..00000000 --- a/src/DiffEngineTray.Tests/PiperTest.InlineMove.verified.txt +++ /dev/null @@ -1,6 +0,0 @@ -{ - Temp: Foo, - Target: Bar.cs, - PatchFile: patch.txt, - StagedVerified: verified.txt -} \ No newline at end of file diff --git a/src/DiffEngineTray.Tests/PiperTest.InlineMoveJson.verified.txt b/src/DiffEngineTray.Tests/PiperTest.InlineMoveJson.verified.txt deleted file mode 100644 index dfdcf094..00000000 --- a/src/DiffEngineTray.Tests/PiperTest.InlineMoveJson.verified.txt +++ /dev/null @@ -1,7 +0,0 @@ -{ -"Type":"InlineMove", -"Temp":"theTempFilePath", -"Target":"theTargetFilePath", -"PatchFile":"thePatchFilePath", -"StagedVerified":"theStagedVerifiedPath" -} \ No newline at end of file diff --git a/src/DiffEngineTray.Tests/PiperTest.InlineMoveJsonNoStagedVerified.verified.txt b/src/DiffEngineTray.Tests/PiperTest.InlineMoveJsonNoStagedVerified.verified.txt deleted file mode 100644 index 34f1da1e..00000000 --- a/src/DiffEngineTray.Tests/PiperTest.InlineMoveJsonNoStagedVerified.verified.txt +++ /dev/null @@ -1,6 +0,0 @@ -{ -"Type":"InlineMove", -"Temp":"theTempFilePath", -"Target":"theTargetFilePath", -"PatchFile":"thePatchFilePath" -} \ No newline at end of file diff --git a/src/DiffEngineTray.Tests/PiperTest.cs b/src/DiffEngineTray.Tests/PiperTest.cs index ffda195c..498495d7 100644 --- a/src/DiffEngineTray.Tests/PiperTest.cs +++ b/src/DiffEngineTray.Tests/PiperTest.cs @@ -63,7 +63,7 @@ public async Task Delete() { DeletePayload received = null!; var source = new CancelSource(); - var task = PiperServer.Start(_ => { }, s => received = s, _ => { }, source.Token); + var task = PiperServer.Start(_ => { }, s => received = s, source.Token); await PiperClient.SendDeleteAsync("Foo", source.Token); await Task.Delay(1000, source.Token); await source.CancelAsync(); @@ -76,7 +76,7 @@ public async Task Move() { MovePayload received = null!; var source = new CancelSource(); - var task = PiperServer.Start(s => received = s, _ => { }, _ => { }, source.Token); + var task = PiperServer.Start(s => received = s, _ => { }, source.Token); await PiperClient.SendMoveAsync("Foo", "Bar", "theExe", "TheArguments \"s\"", true, 10, source.Token); await Task.Delay(1000, source.Token); await source.CancelAsync(); @@ -108,7 +108,7 @@ public async Task ClientDisconnectsAbruptly() { DeletePayload? received = null; var source = new CancelSource(); - var task = PiperServer.Start(_ => { }, s => received = s, _ => { }, source.Token); + var task = PiperServer.Start(_ => { }, s => received = s, source.Token); // Connect and immediately close with RST (no data sent), // simulating a client that was canceled mid-connection. @@ -154,43 +154,12 @@ await Verify(Logs) .ScrubLinesContaining("PiperClient"); } - [Test] - public Task InlineMoveJson() => - Verify( - PiperClient.BuildInlineMovePayload( - "theTempFilePath", - "theTargetFilePath", - "thePatchFilePath", - "theStagedVerifiedPath")); - - [Test] - public Task InlineMoveJsonNoStagedVerified() => - Verify( - PiperClient.BuildInlineMovePayload( - "theTempFilePath", - "theTargetFilePath", - "thePatchFilePath", - null)); - - [Test] - public async Task InlineMove() - { - InlineMovePayload received = null!; - var source = new CancelSource(); - var task = PiperServer.Start(_ => { }, _ => { }, s => received = s, source.Token); - await PiperClient.SendInlineMoveAsync("Foo", "Bar.cs", "patch.txt", "verified.txt", source.Token); - await Task.Delay(1000, source.Token); - await source.CancelAsync(); - await task; - await Verify(received); - } - [Test] public async Task UnknownTypeIgnored() { DeletePayload? received = null; var source = new CancelSource(); - var task = PiperServer.Start(_ => { }, s => received = s, _ => { }, source.Token); + var task = PiperServer.Start(_ => { }, s => received = s, source.Token); // A payload type from a future client version must not throw using (var client = new TcpClient()) diff --git a/src/DiffEngineTray.Tests/RecordingTracker.cs b/src/DiffEngineTray.Tests/RecordingTracker.cs index faf3b536..6ee436c9 100644 --- a/src/DiffEngineTray.Tests/RecordingTracker.cs +++ b/src/DiffEngineTray.Tests/RecordingTracker.cs @@ -1,4 +1,4 @@ -class RecordingTracker(LockedFilesResolver? lockedFilesResolver = null, Action? acceptFailed = null, Action? inlineFailed = null) : +class RecordingTracker(LockedFilesResolver? lockedFilesResolver = null, Action? acceptFailed = null, Action? inlineFailed = null) : Tracker( () => { @@ -14,7 +14,7 @@ public async Task AssertEmpty() { await Assert.That(Deletes).IsEmpty(); await Assert.That(Moves).IsEmpty(); - await Assert.That(InlineMoves).IsEmpty(); + await Assert.That(Snapshots).IsEmpty(); await Assert.That(TrackingAny).IsFalse(); } -} \ No newline at end of file +} diff --git a/src/DiffEngineTray.Tests/TrackerInlineMoveTest.cs b/src/DiffEngineTray.Tests/TrackerInlineMoveTest.cs deleted file mode 100644 index c3be7ff5..00000000 --- a/src/DiffEngineTray.Tests/TrackerInlineMoveTest.cs +++ /dev/null @@ -1,221 +0,0 @@ -public class TrackerInlineMoveTest -{ - static string sourceContent = "class C\n{\n void M() => VerifyInline(value, \"old\");\n}"; - - static (string temp, string patch, string verified, string cs) WriteStaging(string newContent = "new") - { - var directory = Path.Combine(Path.GetTempPath(), $"TrackerInlineMoveTest_{Guid.NewGuid():N}"); - Directory.CreateDirectory(directory); - var cs = Path.Combine(directory, "Tests.cs"); - File.WriteAllText(cs, sourceContent); - var temp = Path.Combine(directory, "Tests.Test.received.txt"); - File.WriteAllText(temp, newContent); - var verified = Path.Combine(directory, "Tests.Test.expected.txt"); - File.WriteAllText(verified, "old"); - var patch = Path.Combine(directory, "Tests.Test.inlinepatch"); - InlinePatchFile.Write(patch, new(cs, 3, "\"old\"", newContent)); - return (temp, patch, verified, cs); - } - - static void Cleanup(string temp) - { - var directory = Path.GetDirectoryName(temp)!; - if (Directory.Exists(directory)) - { - Directory.Delete(directory, true); - } - } - - [Test] - public async Task AddSingle() - { - var (temp, patch, verified, _) = WriteStaging(); - try - { - await using var tracker = new RecordingTracker(); - tracker.AddInlineMove(temp, "Tests.cs", patch, verified); - await Assert.That(tracker.InlineMoves).HasSingleItem(); - await Assert.That(tracker.TrackingAny).IsTrue(); - } - finally - { - Cleanup(temp); - } - } - - [Test] - public async Task AddSameUpdates() - { - var (temp, patch, verified, _) = WriteStaging(); - try - { - await using var tracker = new RecordingTracker(); - tracker.AddInlineMove(temp, "Tests.cs", patch, verified); - tracker.AddInlineMove(temp, "Tests.cs", patch, verified); - await Assert.That(tracker.InlineMoves).HasSingleItem(); - } - finally - { - Cleanup(temp); - } - } - - [Test] - public async Task AcceptAppliesPatchAndCleansStaging() - { - var (temp, patch, verified, cs) = WriteStaging(); - try - { - await using var tracker = new RecordingTracker(); - var move = tracker.AddInlineMove(temp, cs, patch, verified); - tracker.Accept(move); - await tracker.AssertEmpty(); - await Assert.That(File.ReadAllText(cs)).Contains("new"); - await Assert.That(File.Exists(temp)).IsFalse(); - await Assert.That(File.Exists(patch)).IsFalse(); - await Assert.That(File.Exists(verified)).IsFalse(); - } - finally - { - Cleanup(temp); - } - } - - [Test] - public async Task AcceptStalePatchDiscardsAndNotifies() - { - var (temp, patch, verified, cs) = WriteStaging(); - try - { - // Simulate the source changing after the test run - File.WriteAllText(cs, "class C { }"); - string? message = null; - await using var tracker = new RecordingTracker(inlineFailed: (_, m) => message = m); - var move = tracker.AddInlineMove(temp, cs, patch, verified); - tracker.Accept(move); - await tracker.AssertEmpty(); - await Assert.That(message!).Contains("Re-run the test"); - await Assert.That(File.Exists(temp)).IsFalse(); - } - finally - { - Cleanup(temp); - } - } - - [Test] - public async Task DiscardCleansStaging() - { - var (temp, patch, verified, cs) = WriteStaging(); - try - { - await using var tracker = new RecordingTracker(); - var move = tracker.AddInlineMove(temp, cs, patch, verified); - tracker.Discard(move); - await tracker.AssertEmpty(); - await Assert.That(File.Exists(temp)).IsFalse(); - await Assert.That(File.Exists(patch)).IsFalse(); - // Source untouched - await Assert.That(File.ReadAllText(cs)).Contains("old"); - } - finally - { - Cleanup(temp); - } - } - - [Test] - public async Task AcceptAllMixed() - { - var (temp, patch, verified, cs) = WriteStaging(); - try - { - await using var tracker = new RecordingTracker(); - tracker.AddInlineMove(temp, cs, patch, verified); - tracker.AcceptAll(); - await tracker.AssertEmpty(); - await Assert.That(File.ReadAllText(cs)).Contains("new"); - } - finally - { - Cleanup(temp); - } - } - - [Test] - public async Task ClearRemovesInlineMoves() - { - var (temp, patch, verified, cs) = WriteStaging(); - try - { - await using var tracker = new RecordingTracker(); - tracker.AddInlineMove(temp, cs, patch, verified); - tracker.Clear(); - await tracker.AssertEmpty(); - // Clear does not delete staging files (matches TrackedMove behavior) - await Assert.That(File.Exists(temp)).IsTrue(); - } - finally - { - Cleanup(temp); - } - } -} - -public class TrayVersionFileTest -{ - [Test] - public async Task RoundTrip() - { - TrayVersionFile.Write("20.1.3+abc123"); - try - { - var read = TrayVersionFile.TryRead(out var version); - await Assert.That(read).IsTrue(); - await Assert.That(version).IsEqualTo(new Version(20, 1, 3)); - } - finally - { - TrayVersionFile.Delete(); - } - } - - [Test] - public async Task PrereleaseSuffixStripped() - { - TrayVersionFile.Write("21.0.0-beta.1"); - try - { - var read = TrayVersionFile.TryRead(out var version); - await Assert.That(read).IsTrue(); - await Assert.That(version).IsEqualTo(new Version(21, 0, 0)); - } - finally - { - TrayVersionFile.Delete(); - } - } - - [Test] - public async Task MissingFileFails() - { - TrayVersionFile.Delete(); - var read = TrayVersionFile.TryRead(out _); - await Assert.That(read).IsFalse(); - } - - [Test] - public async Task GarbageFails() - { - TrayVersionFile.Write("garbage"); - try - { - var read = TrayVersionFile.TryRead(out _); - await Assert.That(read).IsFalse(); - } - finally - { - TrayVersionFile.Delete(); - } - } -} diff --git a/src/DiffEngineTray.Tests/TrackerSnapshotTest.cs b/src/DiffEngineTray.Tests/TrackerSnapshotTest.cs new file mode 100644 index 00000000..db082345 --- /dev/null +++ b/src/DiffEngineTray.Tests/TrackerSnapshotTest.cs @@ -0,0 +1,140 @@ +/// +/// The tray no longer stores pending inline snapshots; the viewer owns the queue and the tray +/// drives it over the socket. These drive a real rather than staging +/// files on disk. +/// +public class TrackerSnapshotTest +{ + [Test] + public async Task ListsWhatTheViewerHasPending() + { + using var viewer = new FakeViewer("Sample.cs:1", "Other.cs:1"); + await using var tracker = new RecordingTracker(); + + await Assert.That(tracker.Snapshots.Count).IsEqualTo(2); + await Assert.That(tracker.TrackingAny).IsTrue(); + } + + [Test] + public async Task NoViewerMeansNothingPending() + { + await using var tracker = new RecordingTracker(); + + await tracker.AssertEmpty(); + } + + [Test] + public async Task AcceptForwardsTheKeyAndRefreshes() + { + using var viewer = new FakeViewer("Sample.cs:1", "Other.cs:1"); + await using var tracker = new RecordingTracker(); + var snapshot = tracker.Snapshots.Single(_ => _.Name == "Sample.cs:1"); + + tracker.Accept(snapshot); + + await Assert.That(viewer.Verbs).Contains($"accept:{snapshot.Key}"); + await Assert.That(tracker.Snapshots.Count).IsEqualTo(1); + await Assert.That(tracker.Snapshots[0].Name).IsEqualTo("Other.cs:1"); + } + + [Test] + public async Task DiscardForwardsTheKey() + { + using var viewer = new FakeViewer("Sample.cs:1"); + await using var tracker = new RecordingTracker(); + var snapshot = tracker.Snapshots.Single(); + + tracker.Discard(snapshot); + + await Assert.That(viewer.Verbs).Contains($"discard:{snapshot.Key}"); + await Assert.That(tracker.Snapshots).IsEmpty(); + } + + [Test] + public async Task AcceptAllForwardsOnce() + { + using var viewer = new FakeViewer("Sample.cs:1", "Other.cs:1", "Third.cs:1"); + await using var tracker = new RecordingTracker(); + + tracker.AcceptAllSnapshots(); + + await Assert.That(viewer.Verbs).Contains("acceptall"); + await Assert.That(tracker.Snapshots).IsEmpty(); + } + + [Test] + public async Task AcceptAllWithNothingPendingDoesNotCallTheViewer() + { + using var viewer = new FakeViewer(); + await using var tracker = new RecordingTracker(); + + tracker.AcceptAllSnapshots(); + + await Assert.That(viewer.Verbs).DoesNotContain("acceptall"); + } + + [Test] + public async Task AFailedAcceptNotifiesAndLeavesItPending() + { + using var viewer = new FakeViewer("Sample.cs:1") + { + Succeed = false + }; + var failures = new List(); + await using var tracker = new RecordingTracker(inlineFailed: failures.Add); + var snapshot = tracker.Snapshots.Single(); + + tracker.Accept(snapshot); + + await Assert.That(failures).HasSingleItem(); + await Assert.That(failures[0]).Contains("Sample.cs:1"); + await Assert.That(failures[0]).Contains("the file is locked"); + await Assert.That(tracker.Snapshots).HasSingleItem(); + } + + /// + /// A viewer that went away between the menu opening and the click must report rather than + /// throw. + /// + [Test] + public async Task ActingAfterTheViewerExitedNotifies() + { + PendingSnapshot snapshot; + using (var viewer = new FakeViewer("Sample.cs:1")) + { + await using var listing = new RecordingTracker(); + snapshot = listing.Snapshots.Single(); + } + + var failures = new List(); + await using var tracker = new RecordingTracker(inlineFailed: failures.Add); + tracker.Accept(snapshot); + + await Assert.That(failures).HasSingleItem(); + await Assert.That(failures[0]).Contains("not running"); + } + + /// + /// Clear drops what the tray tracks. The viewer is a separate process the user can still act + /// on, so its queue is deliberately left alone. + /// + [Test] + public async Task ClearLeavesTheViewerQueueAlone() + { + using var viewer = new FakeViewer("Sample.cs:1"); + await using var tracker = new RecordingTracker(); + + tracker.Clear(); + + await Assert.That(viewer.Queue).HasSingleItem(); + await Assert.That(viewer.Verbs).DoesNotContain("discardall"); + } + + [Test] + public async Task GroupComesFromTheSourceFile() + { + var snapshot = new PendingSnapshot(@"c:\repo\solution\tests\sample.cs|42", "sample.cs:42", null); + + await Assert.That(snapshot.Source).IsEqualTo(@"c:\repo\solution\tests\sample.cs"); + } +} diff --git a/src/DiffEngineTray.Tests/TrayVersionFileTest.cs b/src/DiffEngineTray.Tests/TrayVersionFileTest.cs new file mode 100644 index 00000000..b4879392 --- /dev/null +++ b/src/DiffEngineTray.Tests/TrayVersionFileTest.cs @@ -0,0 +1,57 @@ +public class TrayVersionFileTest +{ + [Test] + public async Task RoundTrip() + { + TrayVersionFile.Write("20.1.3+abc123"); + try + { + var read = TrayVersionFile.TryRead(out var version); + await Assert.That(read).IsTrue(); + await Assert.That(version).IsEqualTo(new Version(20, 1, 3)); + } + finally + { + TrayVersionFile.Delete(); + } + } + + [Test] + public async Task PrereleaseSuffixStripped() + { + TrayVersionFile.Write("21.0.0-beta.1"); + try + { + var read = TrayVersionFile.TryRead(out var version); + await Assert.That(read).IsTrue(); + await Assert.That(version).IsEqualTo(new Version(21, 0, 0)); + } + finally + { + TrayVersionFile.Delete(); + } + } + + [Test] + public async Task MissingFileFails() + { + TrayVersionFile.Delete(); + var read = TrayVersionFile.TryRead(out _); + await Assert.That(read).IsFalse(); + } + + [Test] + public async Task GarbageFails() + { + TrayVersionFile.Write("garbage"); + try + { + var read = TrayVersionFile.TryRead(out _); + await Assert.That(read).IsFalse(); + } + finally + { + TrayVersionFile.Delete(); + } + } +} diff --git a/src/DiffEngineTray/DiffToolLauncher.cs b/src/DiffEngineTray/DiffToolLauncher.cs index a4ba1c4d..9931dcb2 100644 --- a/src/DiffEngineTray/DiffToolLauncher.cs +++ b/src/DiffEngineTray/DiffToolLauncher.cs @@ -6,10 +6,6 @@ static class DiffToolLauncher public static void Launch(TrackedMove move) => Launch(move.Exe!, move.Arguments!, move.CanKill, move.Process, _ => move.Process = _); - // Inline diff processes are always tray owned, so always killable - public static void Launch(TrackedInlineMove move) => - Launch(move.Exe!, move.Arguments!, canKill: true, move.Process, _ => move.Process = _); - static void Launch(string exe, string arguments, bool canKill, Process? process, Action assign) { if (process is { HasExited: false }) diff --git a/src/DiffEngineTray/InlineViewerProxy.cs b/src/DiffEngineTray/InlineViewerProxy.cs new file mode 100644 index 00000000..2898df16 --- /dev/null +++ b/src/DiffEngineTray/InlineViewerProxy.cs @@ -0,0 +1,99 @@ +/// +/// The tray's half of the viewer protocol. Every call is a short loopback round trip, and a +/// refused connection simply means no viewer is running, which is the same as nothing pending. +/// +static class InlineViewerProxy +{ + public static IReadOnlyList List() + { + if (!ViewerClient.TryExchange(ViewerPayload.Build("list"), out var response)) + { + return []; + } + + var items = new List(); + foreach (var line in response.Split('\n')) + { + var trimmed = line.TrimEnd('\r'); + if (!trimmed.StartsWith("item: ", StringComparison.Ordinal)) + { + continue; + } + + if (TryReadItem(trimmed["item: ".Length..], out var item)) + { + items.Add(item); + } + } + + return items; + } + + static bool TryReadItem(string value, [NotNullWhen(true)] out PendingSnapshot? item) + { + item = null; + var parts = value.Split('|'); + if (parts.Length != 3) + { + return false; + } + + if (!ViewerPayload.TryDecode(parts[0], out var key) || + !ViewerPayload.TryDecode(parts[1], out var name) || + !ViewerPayload.TryDecode(parts[2], out var status)) + { + return false; + } + + item = new(key, name, status.Length == 0 ? null : status); + return true; + } + + public static bool Accept(PendingSnapshot snapshot, out string? message) => + Send("accept", snapshot.Key, out message); + + public static bool Discard(PendingSnapshot snapshot, out string? message) => + Send("discard", snapshot.Key, out message); + + public static bool AcceptAll(out string? message) => + Send("acceptall", null, out message); + + public static void Focus(PendingSnapshot snapshot) => + Send("focus", snapshot.Key, out _); + + public static void Quit() => + Send("quit", null, out _); + + static bool Send(string verb, string? key, out string? message) + { + message = null; + if (!ViewerClient.TryExchange(ViewerPayload.Build(verb, key), out var response)) + { + message = "The snapshot viewer is not running."; + return false; + } + + message = ReadMessage(response); + return response.Contains("status: ok"); + } + + static string? ReadMessage(string response) + { + foreach (var line in response.Split('\n')) + { + var trimmed = line.TrimEnd('\r'); + if (!trimmed.StartsWith("message: ", StringComparison.Ordinal)) + { + continue; + } + + if (ViewerPayload.TryDecode(trimmed["message: ".Length..], out var message) && + message.Length > 0) + { + return message; + } + } + + return null; + } +} diff --git a/src/DiffEngineTray/MenuBuilder.cs b/src/DiffEngineTray/MenuBuilder.cs index e48ca565..d2c3b187 100644 --- a/src/DiffEngineTray/MenuBuilder.cs +++ b/src/DiffEngineTray/MenuBuilder.cs @@ -58,11 +58,9 @@ static void DisposePreviousItems(ToolStripItemCollection items) static IEnumerable BuildTrackingMenuItems(Tracker tracker) { - if (!tracker.TrackingAny) - { - yield break; - } - + // Read everything first and decide from the counts. TrackingAny is backed by the scan + // cache, which drives the icon, and the snapshot half of it can be up to one scan behind + // what the viewer actually has queued. var deletes = tracker .Deletes .OrderBy(_ => _.File) @@ -73,16 +71,20 @@ static IEnumerable BuildTrackingMenuItems(Tracker tracker) .OrderBy(_ => _.Temp) .ToList(); - var inlineMoves = tracker - .InlineMoves - .OrderBy(_ => _.Temp) + var snapshots = tracker + .Snapshots + .OrderBy(_ => _.Name) .ToList(); - var count = moves.Count + deletes.Count + inlineMoves.Count; + var count = moves.Count + deletes.Count + snapshots.Count; + if (count == 0) + { + yield break; + } yield return new ToolStripSeparator(); - foreach (var item in BuildGroupedMenuItems(tracker, deletes, moves, inlineMoves)) + foreach (var item in BuildGroupedMenuItems(tracker, deletes, moves, snapshots)) { yield return item; } @@ -95,12 +97,12 @@ static IEnumerable BuildGroupedMenuItems( Tracker tracker, List deletes, List moves, - List inlineMoves) + List snapshots) { var groups = deletes .Select(_ => _.Group) .Concat(moves.Select(_ => _.Group)) - .Concat(inlineMoves.Select(_ => _.Group)) + .Concat(snapshots.Select(_ => _.Group)) .Distinct() .ToList(); @@ -116,7 +118,7 @@ static IEnumerable BuildGroupedMenuItems( moves .Where(_ => _.Group == group) .ToList(), - inlineMoves + snapshots .Where(_ => _.Group == group) .ToList())) { @@ -136,7 +138,7 @@ static IEnumerable BuildMovesAndDeletes( Tracker tracker, List deletes, List moves, - List inlineMoves) + List snapshots) { if (name != null) { @@ -170,40 +172,39 @@ static IEnumerable BuildMovesAndDeletes( } } - if (inlineMoves.Count != 0) + if (snapshots.Count != 0) { yield return new MenuButton( - $"Pending Snapshots ({inlineMoves.Count}):", - () => tracker.Accept(inlineMoves), + $"Pending Snapshots ({snapshots.Count}):", + tracker.AcceptAllSnapshots, Images.Accept); - foreach (var move in inlineMoves) + foreach (var snapshot in snapshots) { - yield return BuildInlineMove( - move, - () => tracker.Accept(move), - () => tracker.Discard(move)); + yield return BuildSnapshot( + snapshot, + () => tracker.Accept(snapshot), + () => tracker.Discard(snapshot)); } + + yield return new MenuButton("Close snapshot viewer", InlineViewerProxy.Quit); } yield return new ToolStripSeparator(); } - static ToolStripDropDownButton BuildInlineMove(TrackedInlineMove move, Action accept, Action discard) + static ToolStripDropDownButton BuildSnapshot(PendingSnapshot snapshot, Action accept, Action discard) { - var targetName = Path.GetFileName(move.Target); - var menu = new ToolStripDropDownButton($"{move.Name} > {targetName} (inline)") + var failed = snapshot.Status == null ? "" : " !"; + var menu = new ToolStripDropDownButton($"{snapshot.Name} (inline){failed}") { DropDownDirection = ToolStripDropDownDirection.Left }; menu.DropDownItems.Add(new MenuButton("Accept snapshot", accept)); menu.DropDownItems.Add(new MenuButton("Discard", discard)); - if (move.Exe != null) - { - menu.DropDownItems.Add(new MenuButton("Open diff tool", () => DiffToolLauncher.Launch(move))); - } - - menu.DropDownItems.Add(new MenuButton("Open source file", () => ExplorerLauncher.ShowFileInExplorer(move.Target))); - menu.DropDownItems.Add(BuildShowInExplorer(move.Temp)); + // Replaces "Open diff tool": the viewer is the diff tool, and it is already showing this + // snapshot, so the useful action is to bring it forward on that item. + menu.DropDownItems.Add(new MenuButton("Open in viewer", () => InlineViewerProxy.Focus(snapshot))); + menu.DropDownItems.Add(new MenuButton("Open source file", () => ExplorerLauncher.ShowFileInExplorer(snapshot.Source))); return menu; } diff --git a/src/DiffEngineTray/Payloads/InlineMovePayload.cs b/src/DiffEngineTray/Payloads/InlineMovePayload.cs deleted file mode 100644 index 8f72187b..00000000 --- a/src/DiffEngineTray/Payloads/InlineMovePayload.cs +++ /dev/null @@ -1,7 +0,0 @@ -class InlineMovePayload -{ - public string Temp { get; set; } = null!; - public string Target { get; set; } = null!; - public string PatchFile { get; set; } = null!; - public string? StagedVerified { get; set; } -} diff --git a/src/DiffEngineTray/PendingSnapshot.cs b/src/DiffEngineTray/PendingSnapshot.cs new file mode 100644 index 00000000..cd65d73a --- /dev/null +++ b/src/DiffEngineTray/PendingSnapshot.cs @@ -0,0 +1,45 @@ +/// +/// A pending inline snapshot, as reported by the running viewer. +/// +/// Unlike the tray does not own this: the viewer holds the queue, and +/// the tray is a remote control over the same socket. That keeps one queue and one set of +/// semantics on every platform, rather than a Windows-only copy that can drift. +/// +/// +record PendingSnapshot(string Key, string Name, string? Status) +{ + /// + /// The source file the snapshot will be spliced into, recovered from the key. + /// + public string Source + { + get + { + var separator = Key.LastIndexOf('|'); + return separator < 0 ? Key : Key[..separator]; + } + } + + /// + /// Solution directory, so snapshots group alongside moves and deletes in the menu. + /// + /// The source path arrives from another process and is not guaranteed to exist here, so a + /// missing directory means ungrouped rather than a crash while building the menu. + /// + /// + public string? Group + { + get + { + try + { + return SolutionDirectoryFinder.Find(Source); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException or ArgumentException) + { + return null; + } + } + } +} diff --git a/src/DiffEngineTray/PiperServer.cs b/src/DiffEngineTray/PiperServer.cs index 65236c7b..83d0af70 100644 --- a/src/DiffEngineTray/PiperServer.cs +++ b/src/DiffEngineTray/PiperServer.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Sockets; static class PiperServer @@ -6,7 +6,6 @@ static class PiperServer public static async Task Start( Action move, Action delete, - Action inlineMove, Cancel cancel = default) { TcpListener? listener = default; @@ -25,7 +24,7 @@ public static async Task Start( try { - await Handle(listener, move, delete, inlineMove, cancel); + await Handle(listener, move, delete, cancel); } catch (TaskCanceledException) { @@ -58,7 +57,7 @@ public static async Task Start( } } - static async Task Handle(TcpListener listener, Action move, Action delete, Action inlineMove, Cancel cancel) + static async Task Handle(TcpListener listener, Action move, Action delete, Cancel cancel) { await using (cancel.Register(listener.Stop)) { @@ -67,15 +66,8 @@ static async Task Handle(TcpListener listener, Action move, Action< var payload = await reader.ReadToEndAsync(cancel); - // InlineMove is checked before Move for specific-before-general ordering - // (not strictly load bearing: "Type":"Move" is not a substring of "Type":"InlineMove") - if (payload.Contains("\"Type\":\"InlineMove\"") || - payload.Contains("\"Type\": \"InlineMove\"")) - { - inlineMove(Serializer.Deserialize(payload)); - } - else if (payload.Contains("\"Type\":\"Move\"") || - payload.Contains("\"Type\": \"Move\"")) + if (payload.Contains("\"Type\":\"Move\"") || + payload.Contains("\"Type\": \"Move\"")) { move(Serializer.Deserialize(payload)); } diff --git a/src/DiffEngineTray/Program.cs b/src/DiffEngineTray/Program.cs index 6315ca77..773fee1b 100644 --- a/src/DiffEngineTray/Program.cs +++ b/src/DiffEngineTray/Program.cs @@ -67,7 +67,7 @@ static async Task Inner() "DiffEngineTray", $"Could not accept '{move.Name}': the file move keeps failing. The move is still pending, so accept can be retried.", ToolTipIcon.Warning), - inlineFailed: (_, message) => icon.ShowBalloonTip( + inlineFailed: message => icon.ShowBalloonTip( 10000, "DiffEngineTray", message, @@ -176,10 +176,5 @@ static Task StartServer(Tracker tracker, Cancel cancel) => payload.ProcessId); }, payload => tracker.AddDelete(payload.File), - payload => tracker.AddInlineMove( - payload.Temp, - payload.Target, - payload.PatchFile, - payload.StagedVerified), cancel); } \ No newline at end of file diff --git a/src/DiffEngineTray/TrackedInlineMove.cs b/src/DiffEngineTray/TrackedInlineMove.cs deleted file mode 100644 index 5066537d..00000000 --- a/src/DiffEngineTray/TrackedInlineMove.cs +++ /dev/null @@ -1,31 +0,0 @@ -class TrackedInlineMove -{ - public TrackedInlineMove( - string temp, - string target, - string patchFile, - string? stagedVerified, - string? group, - string? exe, - string? arguments) - { - Temp = temp; - Target = target; - PatchFile = patchFile; - StagedVerified = stagedVerified; - Group = group; - Exe = exe; - Arguments = arguments; - Name = Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(temp)); - } - - public string Temp { get; } - public string Target { get; } - public string PatchFile { get; } - public string? StagedVerified { get; } - public string? Group { get; } - public string? Exe { get; } - public string? Arguments { get; } - public string Name { get; } - public Process? Process { get; set; } -} diff --git a/src/DiffEngineTray/Tracker.cs b/src/DiffEngineTray/Tracker.cs index 181d2a5e..8456274b 100644 --- a/src/DiffEngineTray/Tracker.cs +++ b/src/DiffEngineTray/Tracker.cs @@ -5,14 +5,16 @@ class Tracker : Action inactive; LockedFilesResolver? lockedFilesResolver; Action? acceptFailed; - Action? inlineFailed; + Action? inlineFailed; ConcurrentDictionary moves = new(StringComparer.OrdinalIgnoreCase); ConcurrentDictionary deletes = new(StringComparer.OrdinalIgnoreCase); - ConcurrentDictionary inlineMoves = new(StringComparer.OrdinalIgnoreCase); + // The viewer owns the inline queue; this is the last listing the scan saw, used for the icon + // state. The menu re-reads live when it opens. + IReadOnlyList snapshots = []; AsyncTimer timer; int lastScanCount; - public Tracker(Action active, Action inactive, LockedFilesResolver? lockedFilesResolver = null, Action? acceptFailed = null, Action? inlineFailed = null) + public Tracker(Action active, Action inactive, LockedFilesResolver? lockedFilesResolver = null, Action? acceptFailed = null, Action? inlineFailed = null) { this.active = active; this.inactive = inactive; @@ -36,24 +38,11 @@ Task ScanFiles(Cancel cancel) deletes.TryRemove(delete.Key, out _); } - // Inline moves are settled when a passing re-run deletes the staging files. - // No FilesAreEqual check: comparing a text temp to a .cs target is meaningless. - foreach (var pair in inlineMoves.ToList()) - { - var inline = pair.Value; - if (File.Exists(inline.Temp) && - File.Exists(inline.PatchFile)) - { - continue; - } - - if (inlineMoves.TryRemove(pair.Key, out var removed)) - { - removed.Process?.KillAndDispose(); - } - } + // The viewer settles its own queue when a passing re-run sends a settle message, so there + // is nothing to expire here. Just refresh the listing that drives the icon state. + snapshots = InlineViewerProxy.List(); - var newCount = moves.Count + deletes.Count + inlineMoves.Count; + var newCount = moves.Count + deletes.Count + snapshots.Count; if (lastScanCount != newCount) { ToggleActive(); @@ -116,7 +105,7 @@ void ToggleActive() public bool TrackingAny => !moves.IsEmpty || !deletes.IsEmpty || - !inlineMoves.IsEmpty; + snapshots.Count > 0; public TrackedMove AddMove( string temp, @@ -217,123 +206,55 @@ static TrackedMove BuildTrackedMove(string temp, string? exe, string? arguments, return new(temp, target, exe, arguments, canKill.GetValueOrDefault(false), process, solution, extension, killLockingProcess); } - public TrackedInlineMove AddInlineMove( - string temp, - string target, - string patchFile, - string? stagedVerified) - { - var targetFile = Path.GetFileName(target); - return inlineMoves.AddOrUpdate( - temp, - addValueFactory: key => - { - Log.Information("InlineMoveAdded. Target:{target}", targetFile); - return BuildTrackedInlineMove(key, target, patchFile, stagedVerified, null); - }, - updateValueFactory: (key, existing) => - { - Log.Information("InlineMoveUpdated. Target:{target}", targetFile); - return BuildTrackedInlineMove(key, target, patchFile, stagedVerified, existing.Process); - }); - } - - static TrackedInlineMove BuildTrackedInlineMove(string temp, string target, string patchFile, string? stagedVerified, Process? process) + /// + /// Applies the snapshot in the viewer, which owns the queue and the patch. + /// + public void Accept(PendingSnapshot snapshot) { - var solution = SolutionDirectoryFinder.Find(target); - string? exe = null; - string? arguments = null; - if (stagedVerified != null) + if (InlineViewerProxy.Accept(snapshot, out var message)) { - var extension = Path.GetExtension(temp).TrimStart('.'); - if (DiffTools.TryFindByExtension(extension, out var tool)) - { - exe = tool.ExePath; - arguments = tool.GetArguments(temp, stagedVerified); - } + Log.Information("Inline snapshot accepted for `{Name}`. {Message}", snapshot.Name, message); } - - return new(temp, target, patchFile, stagedVerified, solution, exe, arguments) - { - Process = process - }; - } - - public void Accept(TrackedInlineMove move) - { - if (!inlineMoves.TryRemove(move.Temp, out var removed)) + else { - return; + Log.Warning("Inline snapshot accept failed for `{Name}`: {Message}", snapshot.Name, message); + inlineFailed?.Invoke($"Could not accept the snapshot for '{snapshot.Name}'. {message}"); } - removed.Process?.KillAndDispose(); - removed.Process = null; + Refresh(); + } - if (!InlinePatchFile.TryRead(removed.PatchFile, out var patch)) + public void Discard(PendingSnapshot snapshot) + { + if (!InlineViewerProxy.Discard(snapshot, out var message)) { - DiscardInlineStaging(removed); - Log.Warning("Could not read patch file for `{Name}`: {PatchFile}", removed.Name, removed.PatchFile); - inlineFailed?.Invoke(removed, $"Could not read the patch file for '{removed.Name}'. Re-run the test."); - return; + inlineFailed?.Invoke($"Could not discard the snapshot for '{snapshot.Name}'. {message}"); } - var result = InlineApplier.Apply(patch); - switch (result.Status) - { - case InlineApplyStatus.Applied: - case InlineApplyStatus.AlreadyApplied: - Log.Information("Inline snapshot accepted for `{Name}`. Target:{Target}", removed.Name, removed.Target); - DiscardInlineStaging(removed); - return; - case InlineApplyStatus.NotFound: - // The patch is stale; a re-run regenerates a fresh one. Discard. - Log.Warning("Inline snapshot for `{Name}` could not be applied: {Message}", removed.Name, result.Message); - DiscardInlineStaging(removed); - inlineFailed?.Invoke(removed, $"Could not apply the snapshot for '{removed.Name}': the source has changed. Re-run the test."); - return; - default: - // Retryable (eg file locked by an IDE). Keep pending - Log.Warning(result.Exception, "Inline snapshot accept failed for `{Name}`: {Message}. Kept pending", removed.Name, result.Message); - inlineMoves.TryAdd(removed.Temp, removed); - inlineFailed?.Invoke(removed, $"Could not accept the snapshot for '{removed.Name}': {result.Message}. The item is still pending, so accept can be retried."); - return; - } + Refresh(); } - public void Accept(IEnumerable toAccept) + public void AcceptAllSnapshots() { - // Sequential arbitrary order is safe: anchoring is content based, and each - // apply is its own locked read-modify-write, even into the same .cs file - foreach (var move in toAccept) + // Live read, not the scan cache: this can be called before the first scan, and acting on + // a stale empty cache would silently do nothing. + if (Snapshots.Count == 0) { - Accept(move); + return; } - } - public void Discard(TrackedInlineMove move) - { - if (inlineMoves.TryRemove(move.Temp, out var removed)) + if (!InlineViewerProxy.AcceptAll(out var message)) { - removed.Process?.KillAndDispose(); - removed.Process = null; - DiscardInlineStaging(removed); + inlineFailed?.Invoke($"Could not accept the pending snapshots. {message}"); } + + Refresh(); } - static void DiscardInlineStaging(TrackedInlineMove move) + void Refresh() { - FileEx.SafeDeleteFile(move.Temp); - FileEx.SafeDeleteFile(move.PatchFile); - if (move.StagedVerified != null) - { - FileEx.SafeDeleteFile(move.StagedVerified); - } - - var directory = Path.GetDirectoryName(move.Temp); - if (directory != null) - { - FileEx.SafeDeleteDirectory(directory); - } + snapshots = InlineViewerProxy.List(); + ToggleActive(); } public TrackedDelete AddDelete(string file) => @@ -600,13 +521,9 @@ public void Clear() moves.Clear(); - foreach (var inline in inlineMoves.Values) - { - inline.Process?.KillAndDispose(); - inline.Process = null; - } - - inlineMoves.Clear(); + // Deliberately not touching the viewer's queue: Clear drops what the tray is tracking, + // and the viewer is a separate process the user can still act on. + snapshots = []; } public void AcceptOpen() @@ -618,10 +535,9 @@ public void AcceptOpen() .Where(_ => _.Process is { HasExited: false }) .ToList()); - Accept( - inlineMoves.Values - .Where(_ => _.Process is { HasExited: false }) - .ToList()); + // Every pending snapshot is open by definition: the viewer only stays running while it + // has something to show. + AcceptAllSnapshots(); } public void AcceptAll() @@ -630,7 +546,7 @@ public void AcceptAll() AcceptMoves(moves.Values); - Accept(inlineMoves.Values.ToList()); + AcceptAllSnapshots(); } void AcceptAllDeletes() @@ -647,7 +563,18 @@ void AcceptAllDeletes() public ICollection Moves => moves.Values; - public ICollection InlineMoves => inlineMoves.Values; + /// + /// Read live rather than from the scan cache, so the menu shows the viewer's current queue at + /// the moment it opens. + /// + public IReadOnlyList Snapshots + { + get + { + snapshots = InlineViewerProxy.List(); + return snapshots; + } + } public ValueTask DisposeAsync() { diff --git a/src/DiffEngineViewer.Tests/AsciiRendererTests.cs b/src/DiffEngineViewer.Tests/AsciiRendererTests.cs new file mode 100644 index 00000000..082af963 --- /dev/null +++ b/src/DiffEngineViewer.Tests/AsciiRendererTests.cs @@ -0,0 +1,86 @@ +public class AsciiRendererTests +{ + /// + /// The grid only reads correctly if every line is exactly as wide as the border, and a cell + /// that forgets to pad silently collapses the columns to its right. + /// + [Test] + [Arguments(40, 10)] + [Arguments(96, 24)] + [Arguments(97, 25)] + [Arguments(200, 60)] + public async Task EveryLineIsTheSameWidth(int columns, int rows) + { + foreach (var state in States(columns, rows)) + { + var lines = AsciiRenderer.Render(state).Split('\n'); + foreach (var line in lines) + { + await Assert.That(line.Length).IsEqualTo(columns); + } + } + } + + [Test] + [Arguments(40, 10)] + [Arguments(96, 24)] + [Arguments(200, 60)] + public async Task LineCountMatchesTheRequestedRows(int columns, int rows) + { + foreach (var state in States(columns, rows)) + { + var lines = AsciiRenderer.Render(state).Split('\n'); + await Assert.That(lines.Length).IsEqualTo(rows); + } + } + + /// + /// A window narrower than the minimum still has to produce a legal grid rather than throw. + /// + [Test] + public async Task TinyWindowStillRenders() + { + var state = ViewerSession.Resize(Fixtures.File(), 1, 1); + + var lines = AsciiRenderer.Render(ScreenBuilder.Build(state)).Split('\n'); + + await Assert.That(lines[0].Length).IsEqualTo(40); + foreach (var line in lines) + { + await Assert.That(line.Length).IsEqualTo(40); + } + } + + [Test] + public async Task TabsAndNewlinesDoNotBreakTheGrid() + { + var state = Fixtures.File("a\tb", "a\tc"); + + var lines = AsciiRenderer.Render(ScreenBuilder.Build(state)).Split('\n'); + + await Assert.That(lines.Length).IsEqualTo(Fixtures.Rows); + foreach (var line in lines) + { + await Assert.That(line.Length).IsEqualTo(Fixtures.Columns); + } + } + + static IEnumerable States(int columns, int rows) + { + yield return Build(Fixtures.File(), columns, rows); + yield return Build(Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)), columns, rows); + yield return Build(Fixtures.File(left: ""), columns, rows); + yield return Build(Fixtures.Inline(), columns, rows); + yield return Build(Fixtures.Inline(Fixtures.Patch()), columns, rows); + yield return Build( + Fixtures.Inline( + Fixtures.Patch(), + Fixtures.Patch("OtherTests.cs", 12, null, "brand new"), + Fixtures.Patch("AVeryLongTestFileNameIndeed.cs", 4001, "\"x\"", "y")), + columns, + rows); + } + + static Screen Build(SessionState state, int columns, int rows) => + ScreenBuilder.Build(ViewerSession.Resize(state, columns, rows)); +} diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Files.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Files.verified.txt new file mode 100644 index 00000000..3eb0d192 --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Files.verified.txt @@ -0,0 +1,4 @@ +{ + Left: left.txt, + Right: right.txt +} \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Inline.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Inline.verified.txt new file mode 100644 index 00000000..bd021a1c --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Inline.verified.txt @@ -0,0 +1,5 @@ +{ + Mode: Inline, + Source: Tests.cs, + Line: 42 +} \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.InlineArgumentsReordered.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.InlineArgumentsReordered.verified.txt new file mode 100644 index 00000000..bd021a1c --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.InlineArgumentsReordered.verified.txt @@ -0,0 +1,5 @@ +{ + Mode: Inline, + Source: Tests.cs, + Line: 42 +} \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_LineIsZero.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_LineIsZero.verified.txt new file mode 100644 index 00000000..338ecda3 --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_LineIsZero.verified.txt @@ -0,0 +1 @@ +--inline requires --line, of 1 or greater. \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_LineNotANumber.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_LineNotANumber.verified.txt new file mode 100644 index 00000000..e32d887b --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_LineNotANumber.verified.txt @@ -0,0 +1 @@ +--line must be a number, got: abc \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingLine.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingLine.verified.txt new file mode 100644 index 00000000..338ecda3 --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingLine.verified.txt @@ -0,0 +1 @@ +--inline requires --line, of 1 or greater. \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingSource.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingSource.verified.txt new file mode 100644 index 00000000..a7e92eee --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingSource.verified.txt @@ -0,0 +1 @@ +--inline requires --source. \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingValue.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingValue.verified.txt new file mode 100644 index 00000000..a232763b --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingValue.verified.txt @@ -0,0 +1 @@ +Missing value for --source. \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_NoArguments.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_NoArguments.verified.txt new file mode 100644 index 00000000..64ada8a4 --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_NoArguments.verified.txt @@ -0,0 +1 @@ +No arguments. \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_OneFile.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_OneFile.verified.txt new file mode 100644 index 00000000..defe7e11 --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_OneFile.verified.txt @@ -0,0 +1 @@ +Expected two file paths, got 1 arguments. \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_ThreeFiles.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_ThreeFiles.verified.txt new file mode 100644 index 00000000..88f67f38 --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_ThreeFiles.verified.txt @@ -0,0 +1 @@ +Expected two file paths, got 3 arguments. \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_UnknownArgument.verified.txt b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_UnknownArgument.verified.txt new file mode 100644 index 00000000..ea96ce13 --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.Rejected_UnknownArgument.verified.txt @@ -0,0 +1 @@ +Unknown argument: --wat \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/CommandLineTests.cs b/src/DiffEngineViewer.Tests/CommandLineTests.cs new file mode 100644 index 00000000..f6c71b96 --- /dev/null +++ b/src/DiffEngineViewer.Tests/CommandLineTests.cs @@ -0,0 +1,33 @@ +public class CommandLineTests +{ + [Test] + public Task Files() => + Verify(CommandLine.Parse(["left.txt", "right.txt"])); + + [Test] + public Task Inline() => + Verify(CommandLine.Parse(["--inline", "--source", "Tests.cs", "--line", "42"])); + + [Test] + public Task InlineArgumentsReordered() => + Verify(CommandLine.Parse(["--inline", "--line", "42", "--source", "Tests.cs"])); + + [Test] + [Arguments("NoArguments")] + [Arguments("OneFile", "only.txt")] + [Arguments("ThreeFiles", "a.txt", "b.txt", "c.txt")] + [Arguments("MissingSource", "--inline", "--line", "42")] + [Arguments("MissingLine", "--inline", "--source", "Tests.cs")] + [Arguments("LineNotANumber", "--inline", "--source", "Tests.cs", "--line", "abc")] + [Arguments("LineIsZero", "--inline", "--source", "Tests.cs", "--line", "0")] + [Arguments("UnknownArgument", "--inline", "--wat", "1")] + [Arguments("MissingValue", "--inline", "--source")] + public async Task Rejected(string name, params string[] args) + { + var request = CommandLine.Parse(args); + + await Assert.That(request.Error).IsNotNull(); + // The usage block is appended to every error, so assert only the leading explanation. + await Verify(request.Error!.Split("\n\n")[0]).UseTextForParameters(name); + } +} diff --git a/src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj b/src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj new file mode 100644 index 00000000..f1e98cda --- /dev/null +++ b/src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + Exe + + + + + + + + + + + + + + + + diff --git a/src/DiffEngineViewer.Tests/FileScreenTests.AtEnd.verified.txt b/src/DiffEngineViewer.Tests/FileScreenTests.AtEnd.verified.txt new file mode 100644 index 00000000..dd174431 --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileScreenTests.AtEnd.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| Sample.received.txt <> Sample.verified.txt diff | ++----------------------------------------------+-----------------------------------------------+ +| Sample.received.txt | Sample.verified.txt | ++----------------------------------------------+-----------------------------------------------+ +| 25 line 25 | 25 line 25 | +| 26 line 26 | 26 line 26 | +| 27 line 27 | 27 line 27 | +| 28 line 28 | 28 line 28 | +| 29 line 29 | 29 line 29 | +| 30 line 30 | 30 line 30 | +| 31 line 31 | 31 line 31 | +| 32 line 32 | 32 line 32 | +| ~ 33 line 33 changed | ~ 33 line 33 | +| 34 line 34 | 34 line 34 | +| 35 line 35 | 35 line 35 | +| 36 line 36 | 36 line 36 | +| 37 line 37 | 37 line 37 | +| 38 line 38 | 38 line 38 | +| 39 line 39 | 39 line 39 | +| 40 line 40 | 40 line 40 | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] lines 25-40 of 40 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/FileScreenTests.Initial.verified.txt b/src/DiffEngineViewer.Tests/FileScreenTests.Initial.verified.txt new file mode 100644 index 00000000..363d0f55 --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileScreenTests.Initial.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| Sample.received.txt <> Sample.verified.txt diff | ++----------------------------------------------+-----------------------------------------------+ +| Sample.received.txt | Sample.verified.txt | ++----------------------------------------------+-----------------------------------------------+ +| 1 the quick | 1 the quick | +| ~ 2 brown dog | ~ 2 brown fox | +| 3 jumps over | 3 jumps over | +| 4 the lazy | 4 the lazy | +| 5 dog | 5 dog | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] lines 1-5 of 5 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/FileScreenTests.LeftEmpty.verified.txt b/src/DiffEngineViewer.Tests/FileScreenTests.LeftEmpty.verified.txt new file mode 100644 index 00000000..280b16bd --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileScreenTests.LeftEmpty.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| Sample.received.txt <> Sample.verified.txt diff | ++----------------------------------------------+-----------------------------------------------+ +| Sample.received.txt | Sample.verified.txt | ++----------------------------------------------+-----------------------------------------------+ +| | - 1 the quick | +| | - 2 brown fox | +| | - 3 jumps over | +| | - 4 the lazy | +| | - 5 dog | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] lines 1-5 of 5 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/FileScreenTests.LongLines.verified.txt b/src/DiffEngineViewer.Tests/FileScreenTests.LongLines.verified.txt new file mode 100644 index 00000000..a130c3a1 --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileScreenTests.LongLines.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| Sample.received.txt <> Sample.verified.txt diff | ++----------------------------------------------+-----------------------------------------------+ +| Sample.received.txt | Sample.verified.txt | ++----------------------------------------------+-----------------------------------------------+ +| 1 start | 1 start | +| ~ 2 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx> | ~ 2 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx> | +| 3 end | 3 end | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] lines 1-3 of 3 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/FileScreenTests.NextChange.verified.txt b/src/DiffEngineViewer.Tests/FileScreenTests.NextChange.verified.txt new file mode 100644 index 00000000..02b8032c --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileScreenTests.NextChange.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| Sample.received.txt <> Sample.verified.txt diff | ++----------------------------------------------+-----------------------------------------------+ +| Sample.received.txt | Sample.verified.txt | ++----------------------------------------------+-----------------------------------------------+ +| ~ 17 line 17 changed | ~ 17 line 17 | +| 18 line 18 | 18 line 18 | +| 19 line 19 | 19 line 19 | +| 20 line 20 | 20 line 20 | +| 21 line 21 | 21 line 21 | +| 22 line 22 | 22 line 22 | +| 23 line 23 | 23 line 23 | +| 24 line 24 | 24 line 24 | +| 25 line 25 | 25 line 25 | +| 26 line 26 | 26 line 26 | +| 27 line 27 | 27 line 27 | +| 28 line 28 | 28 line 28 | +| 29 line 29 | 29 line 29 | +| 30 line 30 | 30 line 30 | +| 31 line 31 | 31 line 31 | +| 32 line 32 | 32 line 32 | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] lines 17-32 of 40 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/FileScreenTests.NoDifferences.verified.txt b/src/DiffEngineViewer.Tests/FileScreenTests.NoDifferences.verified.txt new file mode 100644 index 00000000..443facfe --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileScreenTests.NoDifferences.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| Sample.received.txt <> Sample.verified.txt diff | ++----------------------------------------------+-----------------------------------------------+ +| Sample.received.txt | Sample.verified.txt | ++----------------------------------------------+-----------------------------------------------+ +| 1 the quick | 1 the quick | +| 2 brown fox | 2 brown fox | +| 3 jumps over | 3 jumps over | +| 4 the lazy | 4 the lazy | +| 5 dog | 5 dog | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] lines 1-5 of 5 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/FileScreenTests.RightEmpty.verified.txt b/src/DiffEngineViewer.Tests/FileScreenTests.RightEmpty.verified.txt new file mode 100644 index 00000000..69e30b8d --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileScreenTests.RightEmpty.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| Sample.received.txt <> Sample.verified.txt diff | ++----------------------------------------------+-----------------------------------------------+ +| Sample.received.txt | Sample.verified.txt | ++----------------------------------------------+-----------------------------------------------+ +| + 1 the quick | | +| + 2 brown dog | | +| + 3 jumps over | | +| + 4 the lazy | | +| + 5 dog | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] lines 1-5 of 5 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/FileScreenTests.Scrolled.verified.txt b/src/DiffEngineViewer.Tests/FileScreenTests.Scrolled.verified.txt new file mode 100644 index 00000000..02b8032c --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileScreenTests.Scrolled.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| Sample.received.txt <> Sample.verified.txt diff | ++----------------------------------------------+-----------------------------------------------+ +| Sample.received.txt | Sample.verified.txt | ++----------------------------------------------+-----------------------------------------------+ +| ~ 17 line 17 changed | ~ 17 line 17 | +| 18 line 18 | 18 line 18 | +| 19 line 19 | 19 line 19 | +| 20 line 20 | 20 line 20 | +| 21 line 21 | 21 line 21 | +| 22 line 22 | 22 line 22 | +| 23 line 23 | 23 line 23 | +| 24 line 24 | 24 line 24 | +| 25 line 25 | 25 line 25 | +| 26 line 26 | 26 line 26 | +| 27 line 27 | 27 line 27 | +| 28 line 28 | 28 line 28 | +| 29 line 29 | 29 line 29 | +| 30 line 30 | 30 line 30 | +| 31 line 31 | 31 line 31 | +| 32 line 32 | 32 line 32 | ++----------------------------------------------+-----------------------------------------------+ +| [Accept] [Close] lines 17-32 of 40 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/FileScreenTests.cs b/src/DiffEngineViewer.Tests/FileScreenTests.cs new file mode 100644 index 00000000..68bee4b7 --- /dev/null +++ b/src/DiffEngineViewer.Tests/FileScreenTests.cs @@ -0,0 +1,56 @@ +public class FileScreenTests +{ + [Test] + public Task Initial() => + Verify(Fixtures.Render(Fixtures.File())); + + [Test] + public Task NoDifferences() => + Verify(Fixtures.Render(Fixtures.File(Fixtures.Expected))); + + [Test] + public Task LeftEmpty() => + Verify(Fixtures.Render(Fixtures.File(left: ""))); + + [Test] + public Task RightEmpty() => + Verify(Fixtures.Render(Fixtures.File(right: ""))); + + [Test] + public Task LongLines() + { + var line = new string('x', 400); + return Verify(Fixtures.Render(Fixtures.File($"start\n{line}\nend", $"start\n{line}!\nend"))); + } + + [Test] + public Task Scrolled() + { + var state = Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)); + return Verify(Fixtures.Render(Apply(state, CommandKind.PageDown))); + } + + [Test] + public Task AtEnd() + { + var state = Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)); + return Verify(Fixtures.Render(Apply(state, CommandKind.ScrollEnd))); + } + + [Test] + public Task NextChange() + { + var state = Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)); + return Verify(Fixtures.Render(Apply(state, CommandKind.NextChange, CommandKind.NextChange))); + } + + static SessionState Apply(SessionState state, params CommandKind[] commands) + { + foreach (var command in commands) + { + state = ViewerSession.Apply(state, command, Fixtures.Applied); + } + + return state; + } +} diff --git a/src/DiffEngineViewer.Tests/Fixtures.cs b/src/DiffEngineViewer.Tests/Fixtures.cs new file mode 100644 index 00000000..2218c848 --- /dev/null +++ b/src/DiffEngineViewer.Tests/Fixtures.cs @@ -0,0 +1,88 @@ +static class Fixtures +{ + /// + /// Fixed so every screen snapshot has the same grid. 24 rows leaves 16 body rows. + /// + public const int Columns = 96; + + public const int Rows = 24; + + public const string Received = + """ + the quick + brown dog + jumps over + the lazy + dog + """; + + public const string Expected = + """ + the quick + brown fox + jumps over + the lazy + dog + """; + + /// + /// Forty lines with changes at 3, 17 and 33, so scrolling and next/previous change have + /// something to land on both inside and outside the first viewport. + /// + public static string Long(bool changed) + { + var builder = new StringBuilder(); + for (var index = 1; index <= 40; index++) + { + if (index > 1) + { + builder.Append('\n'); + } + + builder.Append($"line {index:D2}"); + if (changed && + index is 3 or 17 or 33) + { + builder.Append(" changed"); + } + } + + return builder.ToString(); + } + + public static SessionState File(string left = Received, string right = Expected) => + ViewerSession.Enqueue( + SessionState.Start(ViewerMode.File, Columns, Rows), + QueueEntry.ForFiles("Sample.received.txt", "Sample.verified.txt", left, right)); + + public static SessionState Inline(params InlinePatch[] patches) + { + var state = SessionState.Start(ViewerMode.Inline, Columns, Rows); + foreach (var patch in patches) + { + state = ViewerSession.Enqueue(state, QueueEntry.ForInline(patch)); + } + + return state; + } + + public static InlinePatch Patch( + string source = "SampleTests.cs", + int line = 42, + string? expression = "\"\"\"\n the quick\n brown fox\n jumps over\n the lazy\n dog\n \"\"\"", + string content = Received) => + new(source, line, expression, content); + + /// + /// Accept actions that report a fixed outcome, so the failure screens are reachable without + /// arranging a locked file or a rewritten source. + /// + public static ViewerActions Applying(InlineApplyResult result) => + new(_ => result, static (_, _) => { }); + + public static ViewerActions Applied => + Applying(InlineApplyResult.Applied); + + public static string Render(SessionState state) => + AsciiRenderer.Render(ScreenBuilder.Build(state)); +} diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.AfterAccept.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.AfterAccept.verified.txt new file mode 100644 index 00000000..869dafc1 --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.AfterAccept.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:88 inline 1 of 4 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (4) | received | expected | ++----------------------+-----------------------------------+-----------------------------------+ +| > SampleTests.cs:88 | ~ 1 two | ~ 1 one | +| OtherTests.cs:12 | | | +| OtherTests.cs:30 | | | +| WideNamedTests.cs> | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] [Accept all] Applied SampleTests.cs:42 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.AfterAcceptAll.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.AfterAcceptAll.verified.txt new file mode 100644 index 00000000..b251b0f5 --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.AfterAcceptAll.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| nothing pending inline | ++----------------------------------------------+-----------------------------------------------+ +| received | expected | ++----------------------------------------------+-----------------------------------------------+ +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| (Accept) (Discard) (Accept all) Accepted 5 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.AfterDiscard.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.AfterDiscard.verified.txt new file mode 100644 index 00000000..7eddcbb0 --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.AfterDiscard.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:88 inline 1 of 4 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (4) | received | expected | ++----------------------+-----------------------------------+-----------------------------------+ +| > SampleTests.cs:88 | ~ 1 two | ~ 1 one | +| OtherTests.cs:12 | | | +| OtherTests.cs:30 | | | +| WideNamedTests.cs> | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] [Accept all] Discarded SampleTests.cs:42 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.EmptyQueue.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.EmptyQueue.verified.txt new file mode 100644 index 00000000..1bf632de --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.EmptyQueue.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| nothing pending inline | ++----------------------------------------------+-----------------------------------------------+ +| received | expected | ++----------------------------------------------+-----------------------------------------------+ +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | +| | | ++----------------------------------------------+-----------------------------------------------+ +| (Accept) (Discard) (Accept all) nothing pending | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.FailedApply.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.FailedApply.verified.txt new file mode 100644 index 00000000..8e60f6ef --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.FailedApply.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:42 inline 1 of 5 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (5) | received | expected | ++----------------------+-----------------------------------+-----------------------------------+ +| > SampleTests.cs:42> | 1 the quick | 1 the quick | +| SampleTests.cs:88 | ~ 2 brown dog | ~ 2 brown fox | +| OtherTests.cs:12 | 3 jumps over | 3 jumps over | +| OtherTests.cs:30 | 4 the lazy | 4 the lazy | +| WideNamedTests.cs> | 5 dog | 5 dog | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] [Accept all] Failed to write: SampleTests.cs | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.FailedApplyThenAcceptAll.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.FailedApplyThenAcceptAll.verified.txt new file mode 100644 index 00000000..7979b452 --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.FailedApplyThenAcceptAll.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:42 inline 1 of 5 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (5) | received | expected | ++----------------------+-----------------------------------+-----------------------------------+ +| > SampleTests.cs:42> | 1 the quick | 1 the quick | +| SampleTests.cs:88> | ~ 2 brown dog | ~ 2 brown fox | +| OtherTests.cs:12 ! | 3 jumps over | 3 jumps over | +| OtherTests.cs:30 ! | 4 the lazy | 4 the lazy | +| WideNamedTests.cs> | 5 dog | 5 dog | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] [Accept all] Accepted 0, 5 failed. Failed to write: SampleTests.cs | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.NewSnapshot.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.NewSnapshot.verified.txt new file mode 100644 index 00000000..9c1ad316 --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.NewSnapshot.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:42 inline 1 of 1 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (1) | received | expected (new snapshot) | ++----------------------+-----------------------------------+-----------------------------------+ +| > SampleTests.cs:42 | + 1 the quick | | +| | + 2 brown dog | | +| | + 3 jumps over | | +| | + 4 the lazy | | +| | + 5 dog | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] (Accept all) lines 1-5 of 5 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.Queue.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.Queue.verified.txt new file mode 100644 index 00000000..c806f6b8 --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.Queue.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:42 inline 1 of 5 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (5) | received | expected | ++----------------------+-----------------------------------+-----------------------------------+ +| > SampleTests.cs:42 | 1 the quick | 1 the quick | +| SampleTests.cs:88 | ~ 2 brown dog | ~ 2 brown fox | +| OtherTests.cs:12 | 3 jumps over | 3 jumps over | +| OtherTests.cs:30 | 4 the lazy | 4 the lazy | +| WideNamedTests.cs> | 5 dog | 5 dog | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] [Accept all] lines 1-5 of 5 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.SecondItemSelected.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.SecondItemSelected.verified.txt new file mode 100644 index 00000000..51e78be4 --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.SecondItemSelected.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:88 inline 2 of 5 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (5) | received | expected | ++----------------------+-----------------------------------+-----------------------------------+ +| SampleTests.cs:42 | ~ 1 two | ~ 1 one | +| > SampleTests.cs:88 | | | +| OtherTests.cs:12 | | | +| OtherTests.cs:30 | | | +| WideNamedTests.cs> | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] [Accept all] lines 1-1 of 1 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.Single.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.Single.verified.txt new file mode 100644 index 00000000..a7983d46 --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.Single.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:42 inline 1 of 1 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (1) | received | expected | ++----------------------+-----------------------------------+-----------------------------------+ +| > SampleTests.cs:42 | 1 the quick | 1 the quick | +| | ~ 2 brown dog | ~ 2 brown fox | +| | 3 jumps over | 3 jumps over | +| | 4 the lazy | 4 the lazy | +| | 5 dog | 5 dog | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] (Accept all) lines 1-5 of 5 | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.StalePatch.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.StalePatch.verified.txt new file mode 100644 index 00000000..5e695ab0 --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.StalePatch.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:88 inline 1 of 4 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (4) | received | expected | ++----------------------+-----------------------------------+-----------------------------------+ +| > SampleTests.cs:88 | ~ 1 two | ~ 1 one | +| OtherTests.cs:12 | | | +| OtherTests.cs:30 | | | +| WideNamedTests.cs> | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] [Accept all] SampleTests.cs:42 source changed, re-run the test | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.UnparsedExpression.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.UnparsedExpression.verified.txt new file mode 100644 index 00000000..375ee7ba --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.UnparsedExpression.verified.txt @@ -0,0 +1,24 @@ ++----------------------------------------------------------------------------------------------+ +| SampleTests.cs:42 inline 1 of 1 | ++----------------------+-----------------------------------+-----------------------------------+ +| Pending (1) | received | expected (literal not parsed) | ++----------------------+-----------------------------------+-----------------------------------+ +| > SampleTests.cs:42 | ~ 1 the quick | ~ 1 $"the {value} fox" | +| | + 2 brown dog | | +| | + 3 jumps over | | +| | + 4 the lazy | | +| | + 5 dog | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | +| | | | ++----------------------+-----------------------------------+-----------------------------------+ +| [Accept] [Discard] (Accept all) Existing expected argument is not a plain string literal. S> | ++----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.cs b/src/DiffEngineViewer.Tests/InlineScreenTests.cs new file mode 100644 index 00000000..68a8f7da --- /dev/null +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.cs @@ -0,0 +1,68 @@ +public class InlineScreenTests +{ + [Test] + public Task Single() => + Verify(Fixtures.Render(Fixtures.Inline(Fixtures.Patch()))); + + [Test] + public Task EmptyQueue() => + Verify(Fixtures.Render(Fixtures.Inline())); + + [Test] + public Task NewSnapshot() => + Verify(Fixtures.Render(Fixtures.Inline(Fixtures.Patch(expression: null)))); + + [Test] + public Task UnparsedExpression() => + Verify(Fixtures.Render(Fixtures.Inline(Fixtures.Patch(expression: "$\"the {value} fox\"")))); + + [Test] + public Task Queue() => + Verify(Fixtures.Render(Pending())); + + [Test] + public Task SecondItemSelected() => + Verify(Fixtures.Render(ViewerSession.Apply(Pending(), CommandKind.NextItem, Fixtures.Applied))); + + [Test] + public Task AfterAccept() => + Verify(Fixtures.Render(ViewerSession.Apply(Pending(), CommandKind.Accept, Fixtures.Applied))); + + [Test] + public Task AfterDiscard() => + Verify(Fixtures.Render(ViewerSession.Apply(Pending(), CommandKind.Discard, Fixtures.Applied))); + + [Test] + public Task AfterAcceptAll() => + Verify(Fixtures.Render(ViewerSession.Apply(Pending(), CommandKind.AcceptAll, Fixtures.Applied))); + + [Test] + public Task StalePatch() + { + var actions = Fixtures.Applying(InlineApplyResult.NotFound("Could not locate the VerifyInline call")); + return Verify(Fixtures.Render(ViewerSession.Apply(Pending(), CommandKind.Accept, actions))); + } + + [Test] + public Task FailedApply() + { + var actions = Fixtures.Applying(InlineApplyResult.Failed("Failed to write: SampleTests.cs")); + return Verify(Fixtures.Render(ViewerSession.Apply(Pending(), CommandKind.Accept, actions))); + } + + [Test] + public Task FailedApplyThenAcceptAll() + { + var actions = Fixtures.Applying(InlineApplyResult.Failed("Failed to write: SampleTests.cs")); + var state = ViewerSession.Apply(Pending(), CommandKind.AcceptAll, actions); + return Verify(Fixtures.Render(state)); + } + + static SessionState Pending() => + Fixtures.Inline( + Fixtures.Patch(), + Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two"), + Fixtures.Patch("OtherTests.cs", 12, null, "brand new"), + Fixtures.Patch("OtherTests.cs", 30, "\"a\"", "b"), + Fixtures.Patch("WideNamedTests.cs", 501, "\"x\"", "y")); +} diff --git a/src/DiffEngineViewer.Tests/IpcTests.AcceptAnUnknownKey.verified.txt b/src/DiffEngineViewer.Tests/IpcTests.AcceptAnUnknownKey.verified.txt new file mode 100644 index 00000000..38cce331 --- /dev/null +++ b/src/DiffEngineViewer.Tests/IpcTests.AcceptAnUnknownKey.verified.txt @@ -0,0 +1,4 @@ +{ + Ok: false, + Message: No pending snapshot for missing|1 +} \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/IpcTests.List.verified.txt b/src/DiffEngineViewer.Tests/IpcTests.List.verified.txt new file mode 100644 index 00000000..f1351e5b --- /dev/null +++ b/src/DiffEngineViewer.Tests/IpcTests.List.verified.txt @@ -0,0 +1,13 @@ +{ + Ok: true, + Items: [ + { + Key: sampletests.cs|42, + Name: SampleTests.cs:42 + }, + { + Key: othertests.cs|7, + Name: OtherTests.cs:7 + } + ] +} \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/IpcTests.ListWhenEmpty.verified.txt b/src/DiffEngineViewer.Tests/IpcTests.ListWhenEmpty.verified.txt new file mode 100644 index 00000000..6c8ee9bb --- /dev/null +++ b/src/DiffEngineViewer.Tests/IpcTests.ListWhenEmpty.verified.txt @@ -0,0 +1,3 @@ +{ + Ok: true +} \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/IpcTests.cs b/src/DiffEngineViewer.Tests/IpcTests.cs new file mode 100644 index 00000000..6974129f --- /dev/null +++ b/src/DiffEngineViewer.Tests/IpcTests.cs @@ -0,0 +1,254 @@ +public class IpcTests +{ + [Test] + public async Task InlineQueuesAPatch() + { + using var fixture = new ServerFixture(); + + var response = fixture.Send(Inline(Fixtures.Patch())); + + await Assert.That(response.Ok).IsTrue(); + await Assert.That(fixture.Host.State.Queue.Count).IsEqualTo(1); + } + + [Test] + public async Task InlineReplacesTheSameKey() + { + using var fixture = new ServerFixture(); + + fixture.Send(Inline(Fixtures.Patch(content: "first"))); + fixture.Send(Inline(Fixtures.Patch(content: "second"))); + + await Assert.That(fixture.Host.State.Queue.Count).IsEqualTo(1); + await Assert.That(fixture.Host.State.Queue[0].LeftText).IsEqualTo("second"); + } + + [Test] + public async Task InlineRejectsAnUnreadableBody() + { + using var fixture = new ServerFixture(); + + var response = fixture.Send(new(ViewerVerb.Inline, Body: "not a patch")); + + await Assert.That(response.Ok).IsFalse(); + await Assert.That(fixture.Host.State.Queue).IsEmpty(); + } + + [Test] + public async Task InlineWithoutABodyIsRejected() + { + using var fixture = new ServerFixture(); + + var response = fixture.Send(new(ViewerVerb.Inline)); + + await Assert.That(response.Ok).IsFalse(); + } + + [Test] + public async Task SettleDropsTheEntry() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + + var response = fixture.Send(new(ViewerVerb.Settle, QueueEntry.KeyForInline("SampleTests.cs", 42))); + + await Assert.That(response.Ok).IsTrue(); + await Assert.That(fixture.Host.State.Queue).IsEmpty(); + } + + [Test] + public async Task SettleForAnUnknownKeyIsHarmless() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + + var response = fixture.Send(new(ViewerVerb.Settle, "nope|1")); + + await Assert.That(response.Ok).IsTrue(); + await Assert.That(fixture.Host.State.Queue.Count).IsEqualTo(1); + } + + [Test] + public Task List() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + fixture.Send(Inline(Fixtures.Patch("OtherTests.cs", 7, null, "new"))); + + return Verify(fixture.Send(new(ViewerVerb.List))); + } + + [Test] + public Task ListWhenEmpty() + { + using var fixture = new ServerFixture(); + + return Verify(fixture.Send(new(ViewerVerb.List))); + } + + [Test] + public async Task AcceptAppliesAndRemoves() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + fixture.Send(Inline(Fixtures.Patch("OtherTests.cs", 7, null, "new"))); + + var response = fixture.Send(new(ViewerVerb.Accept, QueueEntry.KeyForInline("SampleTests.cs", 42))); + + await Assert.That(response.Ok).IsTrue(); + await Assert.That(fixture.Applied.Count).IsEqualTo(1); + await Assert.That(fixture.Host.State.Queue.Count).IsEqualTo(1); + await Assert.That(fixture.Host.State.Queue[0].Name).IsEqualTo("OtherTests.cs:7"); + } + + /// + /// The tray can act on an item that is not the selected one, so accept must not silently + /// operate on whatever happens to be in view. + /// + [Test] + public async Task AcceptTargetsTheKeyNotTheSelection() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + fixture.Send(Inline(Fixtures.Patch("OtherTests.cs", 7, null, "new"))); + await Assert.That(fixture.Host.State.Selected).IsEqualTo(0); + + fixture.Send(new(ViewerVerb.Accept, QueueEntry.KeyForInline("OtherTests.cs", 7))); + + await Assert.That(fixture.Host.State.Queue.Count).IsEqualTo(1); + await Assert.That(fixture.Host.State.Queue[0].Name).IsEqualTo("SampleTests.cs:42"); + } + + [Test] + public Task AcceptAnUnknownKey() + { + using var fixture = new ServerFixture(); + + return Verify(fixture.Send(new(ViewerVerb.Accept, "missing|1"))); + } + + [Test] + public async Task AcceptAll() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + fixture.Send(Inline(Fixtures.Patch("OtherTests.cs", 7, null, "new"))); + + var response = fixture.Send(new(ViewerVerb.AcceptAll)); + + await Assert.That(response.Ok).IsTrue(); + await Assert.That(fixture.Applied.Count).IsEqualTo(2); + await Assert.That(fixture.Host.State.Queue).IsEmpty(); + } + + [Test] + public async Task DiscardRemovesWithoutApplying() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + + fixture.Send(new(ViewerVerb.Discard, QueueEntry.KeyForInline("SampleTests.cs", 42))); + + await Assert.That(fixture.Applied).IsEmpty(); + await Assert.That(fixture.Host.State.Queue).IsEmpty(); + } + + [Test] + public async Task DiscardAll() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + fixture.Send(Inline(Fixtures.Patch("OtherTests.cs", 7, null, "new"))); + + fixture.Send(new(ViewerVerb.DiscardAll)); + + await Assert.That(fixture.Applied).IsEmpty(); + await Assert.That(fixture.Host.State.Queue).IsEmpty(); + } + + [Test] + public async Task FocusSelectsAndRaises() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + fixture.Send(Inline(Fixtures.Patch("OtherTests.cs", 7, null, "new"))); + + var response = fixture.Send(new(ViewerVerb.Focus, QueueEntry.KeyForInline("OtherTests.cs", 7))); + + await Assert.That(response.Ok).IsTrue(); + await Assert.That(fixture.Host.State.Selected).IsEqualTo(1); + await Assert.That(fixture.Windows).IsEquivalentTo([WindowCommand.Focus]); + } + + [Test] + public async Task ShowAndHide() + { + using var fixture = new ServerFixture(); + + fixture.Send(new(ViewerVerb.Hide)); + fixture.Send(new(ViewerVerb.Show)); + + await Assert.That(fixture.Windows).IsEquivalentTo([WindowCommand.Hide, WindowCommand.Show]); + } + + [Test] + public async Task Quit() + { + using var fixture = new ServerFixture(); + fixture.Send(Inline(Fixtures.Patch())); + + fixture.Send(new(ViewerVerb.Quit)); + + await Assert.That(fixture.Host.State.Exit).IsTrue(); + } + + /// + /// The single instance rule. A second process must fail to bind so it forwards and exits + /// rather than opening a rival window. + /// + [Test] + public async Task ASecondBindOnTheSamePortFails() + { + using var fixture = new ServerFixture(); + + var second = ViewerServer.TryBind(fixture.Server.Port, out var rival); + rival?.Dispose(); + + await Assert.That(second).IsFalse(); + } + + [Test] + public async Task BindSucceedsWhenNothingOwnsThePort() + { + var bound = ViewerServer.TryBind(0, out var server); + using (server) + { + await Assert.That(bound).IsTrue(); + await Assert.That(server!.Port).IsGreaterThan(0); + } + } + + [Test] + public async Task SendingToANoOneReportsFailureRatherThanThrowing() + { + // Bind then immediately release, so the port is almost certainly free. + ViewerServer.TryBind(0, out var server); + var port = server!.Port; + server.Dispose(); + + await Assert.That(ViewerClient.TrySend(new(ViewerVerb.List), out _, port)).IsFalse(); + } + + [Test] + public async Task GarbageIsRejectedWithoutKillingTheServer() + { + using var fixture = new ServerFixture(); + + await Assert.That(fixture.SendRaw("total nonsense").Ok).IsFalse(); + // Still serving. + await Assert.That(fixture.Send(new(ViewerVerb.List)).Ok).IsTrue(); + } + + static ViewerMessage Inline(InlinePatch patch) => + new(ViewerVerb.Inline, Body: InlinePatchFile.Build(patch)); +} diff --git a/src/DiffEngineViewer.Tests/ModuleInitializer.cs b/src/DiffEngineViewer.Tests/ModuleInitializer.cs new file mode 100644 index 00000000..0e6d3937 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ModuleInitializer.cs @@ -0,0 +1,6 @@ +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Initialize() => + VerifierSettings.UseSsimForPng(); +} diff --git a/src/DiffEngineViewer.Tests/PixelTestAttribute.cs b/src/DiffEngineViewer.Tests/PixelTestAttribute.cs new file mode 100644 index 00000000..d4a95d76 --- /dev/null +++ b/src/DiffEngineViewer.Tests/PixelTestAttribute.cs @@ -0,0 +1,12 @@ +/// +/// Pixel snapshots need a GL context, so they are opt in. CI runs them on Linux under Xvfb with +/// Mesa llvmpipe, a pure software rasteriser and therefore more reproducible than any GPU driver. +/// Windows and macOS developers are never blocked by a missing context. +/// +public sealed class PixelTestAttribute() : SkipAttribute($"Set {Variable}=true to run pixel snapshots.") +{ + public const string Variable = "DIFFENGINE_VIEWER_PIXEL_TESTS"; + + public override Task ShouldSkip(TestRegisteredContext context) => + Task.FromResult(Environment.GetEnvironmentVariable(Variable) != "true"); +} diff --git a/src/DiffEngineViewer.Tests/PixelTests.cs b/src/DiffEngineViewer.Tests/PixelTests.cs new file mode 100644 index 00000000..e92230d5 --- /dev/null +++ b/src/DiffEngineViewer.Tests/PixelTests.cs @@ -0,0 +1,94 @@ +/// +/// Renders real frames through the native shim and verifies the pixels. +/// +/// The shim owns one process wide window, so these run serially and share a single hidden window +/// rather than opening one per test. +/// +/// +/// The verified images are the ones produced by the Linux CI job under Xvfb with Mesa llvmpipe. +/// Determinism comes from pinning the rasteriser rather than the platform: llvmpipe is pure +/// software and therefore more reproducible than any GPU driver, and ImGui rasterises glyphs with +/// its own stb_truetype so text is identical everywhere. Opting in on another machine will render +/// correctly but may not match those baselines pixel for pixel. +/// +/// +[NotInParallel] +public class PixelTests +{ + const int width = 1100; + const int height = 700; + + /// + /// Matches ViewerWindow's cell metrics, so the captured grid is the one ScreenBuilder sized. + /// + const int columns = width / 9; + + const int rows = height / 18; + + static ViewerWindow? window; + + [Before(Class)] + public static void Open() + { + if (Environment.GetEnvironmentVariable(PixelTestAttribute.Variable) != "true") + { + return; + } + + if (!ViewerWindow.TryOpen("DiffEngineViewer", width, height, true, out window, out var error)) + { + throw new(error); + } + } + + [After(Class)] + public static void Close() + { + window?.Dispose(); + window = null; + } + + [Test] + [PixelTest] + public Task FileDiff() => + Capture(Fixtures.File()); + + [Test] + [PixelTest] + public Task InlineSingle() => + Capture(Fixtures.Inline(Fixtures.Patch())); + + [Test] + [PixelTest] + public Task InlineQueue() => + Capture( + Fixtures.Inline( + Fixtures.Patch(), + Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two"), + Fixtures.Patch("OtherTests.cs", 12, null, "brand new"))); + + [Test] + [PixelTest] + public Task InlineAccepted() + { + var state = Fixtures.Inline( + Fixtures.Patch(), + Fixtures.Patch("OtherTests.cs", 12, null, "brand new")); + return Capture(ViewerSession.Apply(state, CommandKind.Accept, Fixtures.Applied)); + } + + static async Task Capture(SessionState state) + { + var screen = ScreenBuilder.Build(ViewerSession.Resize(state, columns, rows)); + var path = Path.Combine(Path.GetTempPath(), $"deview-{Guid.NewGuid():N}.png"); + try + { + await Assert.That(window!.Capture(screen, width, height, path)).IsTrue(); + await VerifyFile(path); + } + finally + { + File.Delete(path); + } + } +} diff --git a/src/DiffEngineViewer.Tests/ServerFixture.cs b/src/DiffEngineViewer.Tests/ServerFixture.cs new file mode 100644 index 00000000..b42bc553 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ServerFixture.cs @@ -0,0 +1,78 @@ +/// +/// A real on an ephemeral port, driven through a real +/// , so the tests exercise the actual socket rather than a stand in. +/// Binding port 0 keeps a live viewer on the machine out of the way. +/// +sealed class ServerFixture : IDisposable +{ + readonly CancelSource cancel = new(); + readonly Task listening; + + public ServerFixture(ViewerMode mode = ViewerMode.Inline) + { + Host = new(SessionState.Start(mode, Fixtures.Columns, Fixtures.Rows)); + if (!ViewerServer.TryBind(0, out var server)) + { + throw new("Could not bind an ephemeral port."); + } + + Server = server; + var actions = new ViewerActions( + patch => + { + Applied.Add(patch); + return InlineApplyResult.Applied; + }, + (_, _) => { }); + var handler = new MessageHandler(Host, actions, Windows.Add); + listening = server.Listen(handler.Handle, cancel.Token); + } + + public SessionHost Host { get; } + public ViewerServer Server { get; } + public List Applied { get; } = []; + public List Windows { get; } = []; + + public ViewerResponse Send(ViewerMessage message) + { + if (!ViewerClient.TrySend(message, out var response, Server.Port)) + { + throw new($"No response for {message.Verb}."); + } + + return response; + } + + public ViewerResponse SendRaw(string payload) + { + using var client = new System.Net.Sockets.TcpClient(); + client.Connect(System.Net.IPAddress.Loopback, Server.Port); + using var stream = client.GetStream(); + var bytes = Encoding.UTF8.GetBytes(payload); + stream.Write(bytes, 0, bytes.Length); + client.Client.Shutdown(System.Net.Sockets.SocketShutdown.Send); + using var reader = new StreamReader(stream, Encoding.UTF8); + if (!ViewerResponse.TryParse(reader.ReadToEnd(), out var response)) + { + throw new("Unreadable response."); + } + + return response; + } + + public void Dispose() + { + cancel.Cancel(); + Server.Dispose(); + try + { + listening.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + // Cancellation unwinds through the listener; nothing to report. + } + + cancel.Dispose(); + } +} diff --git a/src/DiffEngineViewer.Tests/ViewerProtocolTests.cs b/src/DiffEngineViewer.Tests/ViewerProtocolTests.cs new file mode 100644 index 00000000..14708942 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ViewerProtocolTests.cs @@ -0,0 +1,97 @@ +extern alias engine; + +using EnginePatch = engine::DiffEngine.InlinePatch; +using EnginePatchFile = engine::DiffEngine.InlinePatchFile; +using EnginePayload = engine::DiffEngine.ViewerPayload; + +/// +/// DiffEngine and the viewer each own their half of the wire format, because DiffEngine targets +/// down to net462 and stays AOT compatible while the viewer is net10 only. These tests are what +/// stops the two halves drifting. +/// +public class ViewerProtocolTests +{ + [Test] + public async Task EngineInlineMessageIsReadableByTheViewer() + { + var patch = new EnginePatch("Tests.cs", 42, "\"old\"", "new content"); + + var payload = EnginePayload.Inline(EnginePatchFile.Build(patch)); + + await Assert.That(ViewerMessage.TryParse(payload, out var message)).IsTrue(); + await Assert.That(message!.Verb).IsEqualTo(ViewerVerb.Inline); + await Assert.That(InlinePatchFile.TryParse(message.Body!, out var roundTripped)).IsTrue(); + await Assert.That(roundTripped!.SourceFile).IsEqualTo("Tests.cs"); + await Assert.That(roundTripped.LineHint).IsEqualTo(42); + await Assert.That(roundTripped.OriginalExpression).IsEqualTo("\"old\""); + await Assert.That(roundTripped.NewContent).IsEqualTo("new content"); + } + + /// + /// Snapshot text routinely contains quotes, braces and newlines, which is why every value on + /// the wire is base64 rather than escaped. + /// + [Test] + public async Task AwkwardSnapshotTextSurvivesTheRoundTrip() + { + var content = "line \"one\"\n\tbraces {} and | pipes\r\nversion: 1\nverb: quit\n"; + var patch = new EnginePatch("Tests.cs", 1, null, content); + + var payload = EnginePayload.Inline(EnginePatchFile.Build(patch)); + + await Assert.That(ViewerMessage.TryParse(payload, out var message)).IsTrue(); + await Assert.That(InlinePatchFile.TryParse(message!.Body!, out var roundTripped)).IsTrue(); + await Assert.That(roundTripped!.NewContent).IsEqualTo(content); + await Assert.That(roundTripped.OriginalExpression).IsNull(); + } + + [Test] + public async Task EngineSettleMessageIsReadableByTheViewer() + { + var payload = EnginePayload.Settle("Tests.cs", 42); + + await Assert.That(ViewerMessage.TryParse(payload, out var message)).IsTrue(); + await Assert.That(message!.Verb).IsEqualTo(ViewerVerb.Settle); + await Assert.That(message.Key).IsEqualTo(QueueEntry.KeyForInline("Tests.cs", 42)); + } + + /// + /// Settle only works if both sides derive the same key from the same source and line. + /// + [Test] + [Arguments("Tests.cs", 42)] + [Arguments(@"C:\Repo\Some.Tests\Sample.cs", 1)] + [Arguments("/home/user/Sample.cs", 9999)] + [Arguments("MiXeDCase.CS", 7)] + public async Task KeysAgree(string sourceFile, int line) => + await Assert.That(EnginePayload.Key(sourceFile, line)) + .IsEqualTo(QueueEntry.KeyForInline(sourceFile, line)); + + /// + /// DiffEngine's client treats this literal as the acknowledgement, so the viewer has to keep + /// emitting it. + /// + [Test] + public async Task ViewerAcknowledgementMatchesWhatTheEngineLooksFor() + { + await Assert.That(ViewerResponse.Success().Build()).Contains("status: ok"); + await Assert.That(ViewerResponse.Success("queued 1").Build()).Contains("status: ok"); + await Assert.That(ViewerResponse.Error("nope").Build()).DoesNotContain("status: ok"); + } + + /// + /// Read into locals rather than compared directly, because both sides declare these as + /// constants and a constant to constant assertion is compiled away. + /// + [Test] + public async Task ContractConstantsAgree() + { + var engineVersion = EnginePayload.Version; + var enginePort = engine::DiffEngine.ViewerClient.DefaultPort; + var engineVariable = engine::DiffEngine.ViewerClient.PortVariable; + + await Assert.That(engineVersion).IsEqualTo(Payload.Version); + await Assert.That(enginePort).IsEqualTo(ViewerPort.Default); + await Assert.That(engineVariable).IsEqualTo(ViewerPort.Variable); + } +} diff --git a/src/DiffEngineViewer.Tests/ViewerSessionTests.cs b/src/DiffEngineViewer.Tests/ViewerSessionTests.cs new file mode 100644 index 00000000..47665926 --- /dev/null +++ b/src/DiffEngineViewer.Tests/ViewerSessionTests.cs @@ -0,0 +1,202 @@ +public class ViewerSessionTests +{ + [Test] + public async Task EnqueueAppendsDistinctKeys() + { + var state = Fixtures.Inline( + Fixtures.Patch("A.cs", 1, "\"a\"", "x"), + Fixtures.Patch("B.cs", 1, "\"b\"", "y")); + + await Assert.That(state.Queue.Count).IsEqualTo(2); + await Assert.That(state.Selected).IsEqualTo(0); + } + + [Test] + public async Task EnqueueReplacesSameKey() + { + var state = Fixtures.Inline( + Fixtures.Patch("A.cs", 1, "\"a\"", "first"), + Fixtures.Patch("A.cs", 1, "\"a\"", "second")); + + await Assert.That(state.Queue.Count).IsEqualTo(1); + await Assert.That(state.Queue[0].LeftText).IsEqualTo("second"); + } + + [Test] + public async Task EnqueueKeyIgnoresPathCase() + { + var state = Fixtures.Inline( + Fixtures.Patch("A.cs", 1, "\"a\"", "first"), + Fixtures.Patch("a.CS", 1, "\"a\"", "second")); + + await Assert.That(state.Queue.Count).IsEqualTo(1); + } + + [Test] + public async Task SettleRemovesMatchingItem() + { + var state = Fixtures.Inline( + Fixtures.Patch("A.cs", 1, "\"a\"", "x"), + Fixtures.Patch("B.cs", 1, "\"b\"", "y")); + + var settled = ViewerSession.Settle(state, QueueEntry.KeyForInline("A.cs", 1)); + + await Assert.That(settled.Queue.Count).IsEqualTo(1); + await Assert.That(settled.Queue[0].Name).IsEqualTo("B.cs:1"); + await Assert.That(settled.Exit).IsFalse(); + } + + [Test] + public async Task SettleUnknownKeyChangesNothing() + { + var state = Fixtures.Inline(Fixtures.Patch("A.cs", 1, "\"a\"", "x")); + + var settled = ViewerSession.Settle(state, QueueEntry.KeyForInline("Nope.cs", 9)); + + await Assert.That(settled).IsEqualTo(state); + } + + [Test] + public async Task SettlingTheLastItemExits() + { + var state = Fixtures.Inline(Fixtures.Patch("A.cs", 1, "\"a\"", "x")); + + var settled = ViewerSession.Settle(state, QueueEntry.KeyForInline("A.cs", 1)); + + await Assert.That(settled.Queue).IsEmpty(); + await Assert.That(settled.Exit).IsTrue(); + await Assert.That(settled.Selected).IsEqualTo(-1); + } + + [Test] + public async Task AcceptingTheLastItemExits() + { + var state = Fixtures.Inline(Fixtures.Patch()); + + var accepted = ViewerSession.Apply(state, CommandKind.Accept, Fixtures.Applied); + + await Assert.That(accepted.Queue).IsEmpty(); + await Assert.That(accepted.Exit).IsTrue(); + } + + [Test] + public async Task AcceptFailureKeepsItemPending() + { + var state = Fixtures.Inline(Fixtures.Patch()); + var actions = Fixtures.Applying(InlineApplyResult.Failed("locked")); + + var accepted = ViewerSession.Apply(state, CommandKind.Accept, actions); + + await Assert.That(accepted.Queue.Count).IsEqualTo(1); + await Assert.That(accepted.Queue[0].Status).IsEqualTo("locked"); + await Assert.That(accepted.Exit).IsFalse(); + } + + [Test] + public async Task StalePatchIsDroppedNotRetried() + { + // A NotFound patch can never succeed, so it is removed rather than left pending. + var state = Fixtures.Inline(Fixtures.Patch()); + var actions = Fixtures.Applying(InlineApplyResult.NotFound("source changed")); + + var accepted = ViewerSession.Apply(state, CommandKind.Accept, actions); + + await Assert.That(accepted.Queue).IsEmpty(); + } + + [Test] + public async Task ScrollIsClampedToTheLastPage() + { + var state = Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)); + + var end = ViewerSession.Apply(state, CommandKind.ScrollEnd, Fixtures.Applied); + + var body = ScreenBuilder.BodyRows(state); + await Assert.That(end.ScrollTop).IsEqualTo(state.Queue[0].TotalRows - body); + } + + [Test] + public async Task ScrollDoesNotGoNegative() + { + var state = Fixtures.File(); + + var up = ViewerSession.Apply(state, CommandKind.PageUp, Fixtures.Applied); + + await Assert.That(up.ScrollTop).IsEqualTo(0); + } + + [Test] + public async Task ShortContentDoesNotScroll() + { + var state = Fixtures.File(); + + var down = ViewerSession.Apply(state, CommandKind.ScrollDown, Fixtures.Applied); + + await Assert.That(down.ScrollTop).IsEqualTo(0); + } + + [Test] + public async Task GrowingTheWindowPullsScrollBack() + { + var state = Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)); + var end = ViewerSession.Apply(state, CommandKind.ScrollEnd, Fixtures.Applied); + + var resized = ViewerSession.Resize(end, Fixtures.Columns, 200); + + await Assert.That(resized.ScrollTop).IsEqualTo(0); + } + + [Test] + public async Task NextChangeWalksBlocksThenStops() + { + var state = Fixtures.File(Fixtures.Long(true), Fixtures.Long(false)); + + var first = ViewerSession.Apply(state, CommandKind.NextChange, Fixtures.Applied); + var second = ViewerSession.Apply(first, CommandKind.NextChange, Fixtures.Applied); + var third = ViewerSession.Apply(second, CommandKind.NextChange, Fixtures.Applied); + var past = ViewerSession.Apply(third, CommandKind.NextChange, Fixtures.Applied); + + // Changes sit on lines 3, 17 and 33, so rows 2, 16 and 32 zero based. Row 32 is past the + // last full page of 40 rows in a 16 row viewport, so it clamps to 24 and stays there. + await Assert.That(first.ScrollTop).IsEqualTo(2); + await Assert.That(second.ScrollTop).IsEqualTo(16); + await Assert.That(third.ScrollTop).IsEqualTo(24); + await Assert.That(past.ScrollTop).IsEqualTo(24); + } + + [Test] + public async Task SelectingResetsScroll() + { + var state = Fixtures.Inline( + Fixtures.Patch("A.cs", 1, null, Fixtures.Long(true)), + Fixtures.Patch("B.cs", 1, "\"b\"", "y")); + var scrolled = ViewerSession.Apply(state, CommandKind.PageDown, Fixtures.Applied); + await Assert.That(scrolled.ScrollTop).IsGreaterThan(0); + + var selected = ViewerSession.Apply(scrolled, CommandKind.NextItem, Fixtures.Applied); + + await Assert.That(selected.Selected).IsEqualTo(1); + await Assert.That(selected.ScrollTop).IsEqualTo(0); + } + + [Test] + public async Task SelectingPastTheEndIsIgnored() + { + var state = Fixtures.Inline(Fixtures.Patch()); + + var selected = ViewerSession.Apply(state, CommandKind.NextItem, Fixtures.Applied); + + await Assert.That(selected.Selected).IsEqualTo(0); + } + + [Test] + public async Task QuitExitsWithoutTouchingTheQueue() + { + var state = Fixtures.Inline(Fixtures.Patch()); + + var quit = ViewerSession.Apply(state, CommandKind.Quit, Fixtures.Applied); + + await Assert.That(quit.Exit).IsTrue(); + await Assert.That(quit.Queue.Count).IsEqualTo(1); + } +} diff --git a/src/DiffEngineViewer/AsciiRenderer.cs b/src/DiffEngineViewer/AsciiRenderer.cs new file mode 100644 index 00000000..14448be5 --- /dev/null +++ b/src/DiffEngineViewer/AsciiRenderer.cs @@ -0,0 +1,196 @@ +/// +/// Renders a as a fixed width character grid. Pure ASCII so the verified +/// files review as ordinary text diffs, and deterministic on every platform, which is what lets +/// the screen tests run everywhere rather than only where a GPU exists. +/// +static class AsciiRenderer +{ + const int queueWidth = 22; + + /// + /// Marker, space, four digit line number, two spaces. + /// + const int gutterWidth = 8; + + public static string Render(Screen screen) + { + var columns = Math.Max(40, screen.Columns); + var queue = screen.Queue.Count > 0 ? queueWidth : 0; + var (left, right) = SplitPanes(columns, queue); + var widths = queue > 0 ? new[] { queue, left, right } : [left, right]; + + var builder = new StringBuilder(); + builder.Append('+').Append('-', columns - 2).Append("+\n"); + builder.Append(Full(Justify(screen.Title, screen.Subtitle, columns - 4), columns)).Append('\n'); + builder.Append(Separator(widths)).Append('\n'); + builder.Append(Headers(screen, widths)).Append('\n'); + builder.Append(Separator(widths)).Append('\n'); + + var body = Math.Max(1, screen.Rows - ScreenBuilder.Chrome); + for (var index = 0; index < body; index++) + { + builder.Append(Body(screen, widths, index)).Append('\n'); + } + + builder.Append(Separator(widths)).Append('\n'); + builder.Append(Full(Justify(Buttons(screen), screen.Status, columns - 4), columns)).Append('\n'); + builder.Append('+').Append('-', columns - 2).Append('+'); + return builder.ToString(); + } + + static (int left, int right) SplitPanes(int columns, int queue) + { + var bars = queue > 0 ? 4 : 3; + var remaining = Math.Max(4, columns - bars - queue); + var left = remaining / 2; + return (left, remaining - left); + } + + static string Headers(Screen screen, IReadOnlyList widths) + { + if (widths.Count == 3) + { + return Bordered( + [ + Cell($"Pending ({screen.Queue.Count})", widths[0]), + Cell(screen.Left.Header, widths[1]), + Cell(screen.Right.Header, widths[2]) + ]); + } + + return Bordered( + [ + Cell(screen.Left.Header, widths[0]), + Cell(screen.Right.Header, widths[1]) + ]); + } + + static string Body(Screen screen, IReadOnlyList widths, int index) + { + var cells = new List(widths.Count); + if (widths.Count == 3) + { + cells.Add(Cell(QueueCell(screen, index), widths[0])); + } + + cells.Add(Cell(RowCell(screen.Left, index, widths[^2]), widths[^2])); + cells.Add(Cell(RowCell(screen.Right, index, widths[^1]), widths[^1])); + return Bordered(cells); + } + + static string QueueCell(Screen screen, int index) + { + if (index >= screen.Queue.Count) + { + return ""; + } + + var item = screen.Queue[index]; + var marker = item.Selected ? '>' : ' '; + var failed = item.Status is null ? "" : " !"; + return $"{marker} {item.Label}{failed}"; + } + + static string RowCell(Pane pane, int index, int width) + { + if (index >= pane.Rows.Count) + { + return ""; + } + + var row = pane.Rows[index]; + if (row.Kind == RowKind.Filler) + { + return ""; + } + + var text = Fit(row.Text, Math.Max(1, width - 2 - gutterWidth)); + return $"{Marker(row.Kind)} {row.LineNumber,4} {text}"; + } + + static char Marker(RowKind kind) => + kind switch + { + RowKind.Added => '+', + RowKind.Removed => '-', + RowKind.Modified => '~', + _ => ' ' + }; + + static string Buttons(Screen screen) + { + var builder = new StringBuilder(); + foreach (var button in screen.Buttons) + { + if (builder.Length > 0) + { + builder.Append(' '); + } + + // Disabled buttons keep their slot so the footer does not reflow as the queue drains. + builder.Append(button.Enabled ? '[' : '('); + builder.Append(button.Label); + builder.Append(button.Enabled ? ']' : ')'); + } + + return builder.ToString(); + } + + static string Bordered(IReadOnlyList cells) => + $"|{string.Join("|", cells)}|"; + + static string Separator(IReadOnlyList widths) => + $"+{string.Join("+", widths.Select(_ => new string('-', _)))}+"; + + static string Full(string content, int columns) => + $"| {Fit(content, columns - 4)} |"; + + static string Cell(string content, int width) => + $" {Fit(content, Math.Max(1, width - 2))} "; + + static string Justify(string left, string right, int width) + { + if (right.Length == 0) + { + return Fit(left, width); + } + + var gap = width - right.Length - left.Length; + if (gap < 1) + { + return Fit($"{left} {right}", width); + } + + return $"{left}{new string(' ', gap)}{right}"; + } + + static string Fit(string text, int width) + { + // Tabs and stray newlines would break the grid, so flatten them before measuring. + var flat = Flatten(text); + if (flat.Length == width) + { + return flat; + } + + if (flat.Length < width) + { + return flat.PadRight(width); + } + + return $"{flat.AsSpan(0, width - 1)}>"; + } + + static string Flatten(string text) + { + if (text.AsSpan().IndexOfAny('\t', '\r', '\n') < 0) + { + return text; + } + + return text + .Replace("\t", " ") + .Replace("\r", "") + .Replace("\n", " "); + } +} diff --git a/src/DiffEngineViewer/Assets/JetBrainsMono-OFL.txt b/src/DiffEngineViewer/Assets/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..5ceee002 --- /dev/null +++ b/src/DiffEngineViewer/Assets/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/DiffEngineViewer/Assets/JetBrainsMono-Regular.ttf b/src/DiffEngineViewer/Assets/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..711830ed Binary files /dev/null and b/src/DiffEngineViewer/Assets/JetBrainsMono-Regular.ttf differ diff --git a/src/DiffEngineViewer/Command.cs b/src/DiffEngineViewer/Command.cs new file mode 100644 index 00000000..95afb7d8 --- /dev/null +++ b/src/DiffEngineViewer/Command.cs @@ -0,0 +1,12 @@ +/// +/// A single user action. is only meaningful for +/// . +/// +readonly record struct Command(CommandKind Kind, int Index = -1) +{ + public static implicit operator Command(CommandKind kind) => + new(kind); + + public static Command Select(int index) => + new(CommandKind.SelectItem, index); +} diff --git a/src/DiffEngineViewer/CommandKind.cs b/src/DiffEngineViewer/CommandKind.cs new file mode 100644 index 00000000..d09598c0 --- /dev/null +++ b/src/DiffEngineViewer/CommandKind.cs @@ -0,0 +1,20 @@ +enum CommandKind +{ + None, + ScrollUp, + ScrollDown, + PageUp, + PageDown, + ScrollHome, + ScrollEnd, + NextChange, + PreviousChange, + NextItem, + PreviousItem, + SelectItem, + Accept, + AcceptAll, + Discard, + DiscardAll, + Quit +} diff --git a/src/DiffEngineViewer/CommandLine.cs b/src/DiffEngineViewer/CommandLine.cs new file mode 100644 index 00000000..9a45e303 --- /dev/null +++ b/src/DiffEngineViewer/CommandLine.cs @@ -0,0 +1,76 @@ +static class CommandLine +{ + public const string Usage = """ + DiffEngineViewer + DiffEngineViewer --inline --source --line + + Inline mode reads the patch payload from stdin. + """; + + public static ViewerRequest Parse(IReadOnlyList args) + { + if (args.Count == 0) + { + return Error("No arguments."); + } + + if (args[0] == "--inline") + { + return ParseInline(args); + } + + if (args.Count != 2) + { + return Error($"Expected two file paths, got {args.Count} arguments."); + } + + return new(ViewerMode.File, args[0], args[1], null, 0, null); + } + + static ViewerRequest ParseInline(IReadOnlyList args) + { + string? source = null; + var line = 0; + for (var index = 1; index < args.Count; index++) + { + var name = args[index]; + if (name != "--source" && + name != "--line") + { + return Error($"Unknown argument: {name}"); + } + + if (index + 1 == args.Count) + { + return Error($"Missing value for {name}."); + } + + var value = args[++index]; + if (name == "--source") + { + source = value; + continue; + } + + if (!int.TryParse(value, out line)) + { + return Error($"--line must be a number, got: {value}"); + } + } + + if (source is null) + { + return Error("--inline requires --source."); + } + + if (line < 1) + { + return Error("--inline requires --line, of 1 or greater."); + } + + return new(ViewerMode.Inline, null, null, source, line, null); + } + + static ViewerRequest Error(string message) => + new(ViewerMode.File, null, null, null, 0, $"{message}\n\n{Usage}"); +} diff --git a/src/DiffEngineViewer/DiffEngineViewer.csproj b/src/DiffEngineViewer/DiffEngineViewer.csproj new file mode 100644 index 00000000..10b5f1b2 --- /dev/null +++ b/src/DiffEngineViewer/DiffEngineViewer.csproj @@ -0,0 +1,63 @@ + + + + Exe + net10.0 + true + true + A cross platform diff tool for text files and inline snapshots. + true + false + LatestMajor + + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DiffEngineViewer/DiffRows.cs b/src/DiffEngineViewer/DiffRows.cs new file mode 100644 index 00000000..635f13aa --- /dev/null +++ b/src/DiffEngineViewer/DiffRows.cs @@ -0,0 +1,37 @@ +using DiffPlex.DiffBuilder; +using DiffPlex.DiffBuilder.Model; + +/// +/// Turns two texts into two equal length row lists, padded with so +/// the panes stay vertically aligned. +/// +static class DiffRows +{ + public static (IReadOnlyList Left, IReadOnlyList Right) Build(string leftText, string rightText) + { + // DiffPlex is old/new oriented. Left is the received (new) side, right the expected (old). + var model = SideBySideDiffBuilder.Diff(rightText, leftText); + return (Convert(model.NewText.Lines), Convert(model.OldText.Lines)); + } + + static List Convert(List lines) + { + var rows = new List(lines.Count); + foreach (var line in lines) + { + rows.Add(new(line.Position, Kind(line.Type), line.Text ?? "")); + } + + return rows; + } + + static RowKind Kind(ChangeType type) => + type switch + { + ChangeType.Inserted => RowKind.Added, + ChangeType.Deleted => RowKind.Removed, + ChangeType.Modified => RowKind.Modified, + ChangeType.Imaginary => RowKind.Filler, + _ => RowKind.Unchanged + }; +} diff --git a/src/DiffEngineViewer/GlobalUsings.cs b/src/DiffEngineViewer/GlobalUsings.cs new file mode 100644 index 00000000..973b723b --- /dev/null +++ b/src/DiffEngineViewer/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using System.Diagnostics; +global using System.Diagnostics.CodeAnalysis; +global using System.Text; diff --git a/src/DiffEngineViewer/InternalsVisibleTo.cs b/src/DiffEngineViewer/InternalsVisibleTo.cs new file mode 100644 index 00000000..0541964b --- /dev/null +++ b/src/DiffEngineViewer/InternalsVisibleTo.cs @@ -0,0 +1 @@ +[assembly: InternalsVisibleTo("DiffEngineViewer.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000f0a8e4bf1639dce01be6592384e7dfc621915b7759fb5cee42ec5d351bcc43460432da1659ee618ca6cab6b8b8e56a5deb5d4ee1a49783d5c2690752502d31ccbfee9b2c697e20359b55ad100cc9370c8e983fd9496f01d761a060d0435bac7243b1832ba95757aa5adbb67df38c213d717b6751e1217cea9fa5c61e9b799dd")] diff --git a/src/DiffEngineViewer/Ipc/MessageHandler.cs b/src/DiffEngineViewer/Ipc/MessageHandler.cs new file mode 100644 index 00000000..57493c54 --- /dev/null +++ b/src/DiffEngineViewer/Ipc/MessageHandler.cs @@ -0,0 +1,148 @@ +/// +/// Maps a wire message onto the session. Split from the transport so the whole protocol, +/// including the tray facing half, is testable without a socket or a window. +/// +class MessageHandler(SessionHost host, ViewerActions actions, Action window) +{ + public ViewerResponse Handle(ViewerMessage message) + { + switch (message.Verb) + { + case ViewerVerb.Inline: + return Inline(message.Body); + case ViewerVerb.Settle: + return Settle(message.Key); + case ViewerVerb.List: + return List(); + case ViewerVerb.Accept: + return Act(message.Key, CommandKind.Accept); + case ViewerVerb.Discard: + return Act(message.Key, CommandKind.Discard); + case ViewerVerb.AcceptAll: + return All(CommandKind.AcceptAll); + case ViewerVerb.DiscardAll: + return All(CommandKind.DiscardAll); + case ViewerVerb.Focus: + return Focus(message.Key); + case ViewerVerb.Show: + window(WindowCommand.Show); + return ViewerResponse.Success(); + case ViewerVerb.Hide: + window(WindowCommand.Hide); + return ViewerResponse.Success(); + case ViewerVerb.Quit: + host.Mutate(_ => ViewerSession.Apply(_, CommandKind.Quit, actions)); + return ViewerResponse.Success("Closing"); + default: + return ViewerResponse.Error($"Unsupported verb: {message.Verb}"); + } + } + + ViewerResponse Inline(string? body) + { + if (body is null) + { + return ViewerResponse.Error("Inline requires a body"); + } + + if (!InlinePatchFile.TryParse(body, out var patch)) + { + return ViewerResponse.Error("Inline body is not a readable patch payload"); + } + + var state = host.Mutate(_ => ViewerSession.Enqueue(_, QueueEntry.ForInline(patch))); + return ViewerResponse.Success($"Queued {state.Queue.Count}"); + } + + ViewerResponse Settle(string? key) + { + if (key is null) + { + return ViewerResponse.Error("Settle requires a key"); + } + + host.Mutate(_ => ViewerSession.Settle(_, key)); + return ViewerResponse.Success(); + } + + ViewerResponse List() + { + var state = host.State; + var items = new List(state.Queue.Count); + foreach (var entry in state.Queue) + { + items.Add(new(entry.Key, entry.Name, entry.Status)); + } + + return ViewerResponse.Listing(items); + } + + ViewerResponse Act(string? key, CommandKind command) + { + if (key is null) + { + return ViewerResponse.Error($"{command} requires a key"); + } + + if (IndexOf(key) < 0) + { + return ViewerResponse.Error($"No pending snapshot for {key}"); + } + + var state = host.Mutate(_ => + { + var index = IndexOf(_, key); + if (index < 0) + { + return _; + } + + var selected = ViewerSession.Apply(_, Command.Select(index), actions); + return ViewerSession.Apply(selected, command, actions); + }); + + return ViewerResponse.Success(state.Message); + } + + ViewerResponse All(CommandKind command) + { + var state = host.Mutate(_ => ViewerSession.Apply(_, command, actions)); + return ViewerResponse.Success(state.Message); + } + + ViewerResponse Focus(string? key) + { + if (key is not null) + { + if (IndexOf(key) < 0) + { + return ViewerResponse.Error($"No pending snapshot for {key}"); + } + + host.Mutate(_ => + { + var index = IndexOf(_, key); + return index < 0 ? _ : ViewerSession.Apply(_, Command.Select(index), actions); + }); + } + + window(WindowCommand.Focus); + return ViewerResponse.Success(); + } + + int IndexOf(string key) => + IndexOf(host.State, key); + + static int IndexOf(SessionState state, string key) + { + for (var index = 0; index < state.Queue.Count; index++) + { + if (state.Queue[index].Key == key) + { + return index; + } + } + + return -1; + } +} diff --git a/src/DiffEngineViewer/Ipc/Payload.cs b/src/DiffEngineViewer/Ipc/Payload.cs new file mode 100644 index 00000000..83e5f745 --- /dev/null +++ b/src/DiffEngineViewer/Ipc/Payload.cs @@ -0,0 +1,84 @@ +/// +/// The wire format shared by requests and responses. Deliberately not JSON, for the same reason +/// is not: every value is base64, so snapshot text containing +/// quotes, braces or newlines needs no escaping and the `inline` body can carry an +/// payload verbatim rather than nested inside a JSON string. +/// +/// version: 1 +/// verb: inline +/// key: {base64} +/// body: {base64} +/// +/// +static class Payload +{ + public const int Version = 1; + + public static void Append(StringBuilder builder, string name, string? value) + { + if (value is null) + { + return; + } + + builder.Append(name); + builder.Append(": "); + builder.Append(Encode(value)); + builder.Append('\n'); + } + + public static string Encode(string value) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + + public static bool TryDecode(string value, [NotNullWhen(true)] out string? decoded) + { + decoded = null; + if (value.Length == 0) + { + decoded = ""; + return true; + } + + try + { + decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value)); + return true; + } + catch (FormatException) + { + return false; + } + } + + /// + /// Splits into name/value pairs, preserving order and duplicates so repeated `item` lines + /// survive. + /// + public static bool TryReadLines(string text, out List<(string Name, string Value)> lines) + { + lines = []; + foreach (var raw in text.Split('\n')) + { + var line = raw.TrimEnd('\r'); + if (line.Length == 0) + { + continue; + } + + var separator = line.IndexOf(':'); + if (separator < 1) + { + return false; + } + + lines.Add((line[..separator], line[(separator + 1)..].Trim())); + } + + return lines.Count > 0; + } + + public static bool HasVersion(IReadOnlyList<(string Name, string Value)> lines) => + lines.Count > 0 && + lines[0].Name == "version" && + lines[0].Value == Version.ToString(); +} diff --git a/src/DiffEngineViewer/Ipc/ViewerClient.cs b/src/DiffEngineViewer/Ipc/ViewerClient.cs new file mode 100644 index 00000000..673d3444 --- /dev/null +++ b/src/DiffEngineViewer/Ipc/ViewerClient.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Net.Sockets; + +/// +/// Talks to an already running viewer. A refused connection means no viewer owns the port, which +/// the caller treats as "nothing pending" (tray) or "spawn one" (DiffEngine). +/// +static class ViewerClient +{ + public static int Port { get; set; } = ViewerPort.Resolve(); + + /// + /// Short by design. Both callers are on an interactive path, so a wedged viewer must not + /// stall a tray menu or a test run. + /// + public static TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(3); + + /// + /// overrides for a single call. Tests pass their + /// own ephemeral port rather than mutating the static, so they can run in parallel. + /// + public static bool TrySend( + ViewerMessage message, + [NotNullWhen(true)] out ViewerResponse? response, + int? port = null) + { + response = null; + try + { + var text = Exchange(message.Build(), port ?? Port); + return ViewerResponse.TryParse(text, out response); + } + catch (SocketException) + { + return false; + } + catch (IOException) + { + return false; + } + } + + static string Exchange(string payload, int port) + { + using var client = new TcpClient(); + client.SendTimeout = (int) Timeout.TotalMilliseconds; + client.ReceiveTimeout = (int) Timeout.TotalMilliseconds; + Connect(client, port); + + using var stream = client.GetStream(); + var bytes = Encoding.UTF8.GetBytes(payload); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(); + + // Half close so the server sees end of request without losing the socket it must reply + // on. Reading to end is then unambiguous on both sides. + client.Client.Shutdown(SocketShutdown.Send); + + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } + + static void Connect(TcpClient client, int port) + { + try + { + if (!client.ConnectAsync(IPAddress.Loopback, port).Wait(Timeout)) + { + throw new SocketException((int) SocketError.TimedOut); + } + } + // Task.Wait wraps the refusal, and every caller matches on SocketException. + catch (AggregateException exception) + when (exception.InnerException is SocketException socket) + { + throw socket; + } + } +} diff --git a/src/DiffEngineViewer/Ipc/ViewerMessage.cs b/src/DiffEngineViewer/Ipc/ViewerMessage.cs new file mode 100644 index 00000000..62f990c2 --- /dev/null +++ b/src/DiffEngineViewer/Ipc/ViewerMessage.cs @@ -0,0 +1,72 @@ +/// +/// A request to the running viewer. identifies a queue entry for the +/// verbs that act on one; carries an +/// payload for . +/// +record ViewerMessage(ViewerVerb Verb, string? Key = null, string? Body = null) +{ + public string Build() + { + var builder = new StringBuilder($"version: {Payload.Version}\n"); + builder.Append($"verb: {Verb.ToString().ToLowerInvariant()}\n"); + Payload.Append(builder, "key", Key); + Payload.Append(builder, "body", Body); + return builder.ToString(); + } + + public static bool TryParse(string text, [NotNullWhen(true)] out ViewerMessage? message) + { + message = null; + if (!Payload.TryReadLines(text, out var lines) || + !Payload.HasVersion(lines)) + { + return false; + } + + ViewerVerb? verb = null; + string? key = null; + string? body = null; + foreach (var (name, value) in lines) + { + switch (name) + { + case "version": + continue; + case "verb": + if (!Enum.TryParse(value, true, out var parsed)) + { + return false; + } + + verb = parsed; + continue; + case "key": + if (!Payload.TryDecode(value, out key)) + { + return false; + } + + continue; + case "body": + if (!Payload.TryDecode(value, out body)) + { + return false; + } + + continue; + default: + // Unknown fields are ignored so a newer client can add one without breaking + // an older viewer, matching how PiperServer tolerates unknown payload types. + continue; + } + } + + if (verb is null) + { + return false; + } + + message = new(verb.Value, key, body); + return true; + } +} diff --git a/src/DiffEngineViewer/Ipc/ViewerPort.cs b/src/DiffEngineViewer/Ipc/ViewerPort.cs new file mode 100644 index 00000000..1d1b4b08 --- /dev/null +++ b/src/DiffEngineViewer/Ipc/ViewerPort.cs @@ -0,0 +1,22 @@ +static class ViewerPort +{ + public const int Default = 3493; + + /// + /// The tray's piper sits on 3492. Tests override this so a run never talks to a live viewer, + /// mirroring how PiperTest reassigns PiperClient.Port. + /// + public const string Variable = "DiffEngine_ViewerPort"; + + public static int Resolve() + { + var value = Environment.GetEnvironmentVariable(Variable); + if (int.TryParse(value, out var port) && + port is > 0 and < 65536) + { + return port; + } + + return Default; + } +} diff --git a/src/DiffEngineViewer/Ipc/ViewerResponse.cs b/src/DiffEngineViewer/Ipc/ViewerResponse.cs new file mode 100644 index 00000000..c25e4fef --- /dev/null +++ b/src/DiffEngineViewer/Ipc/ViewerResponse.cs @@ -0,0 +1,99 @@ +record ViewerResponseItem(string Key, string Name, string? Status); + +/// +/// The reply the viewer writes before closing the connection. Only +/// populates ; the rest report an outcome the tray can show in a balloon. +/// +record ViewerResponse(bool Ok, string? Message, IReadOnlyList Items) +{ + public static ViewerResponse Success(string? message = null) => + new(true, message, []); + + public static ViewerResponse Error(string message) => + new(false, message, []); + + public static ViewerResponse Listing(IReadOnlyList items) => + new(true, null, items); + + public string Build() + { + var builder = new StringBuilder($"version: {Payload.Version}\n"); + builder.Append($"status: {(Ok ? "ok" : "error")}\n"); + Payload.Append(builder, "message", Message); + foreach (var item in Items) + { + var status = item.Status is null ? "" : Payload.Encode(item.Status); + builder.Append($"item: {Payload.Encode(item.Key)}|{Payload.Encode(item.Name)}|{status}\n"); + } + + return builder.ToString(); + } + + public static bool TryParse(string text, [NotNullWhen(true)] out ViewerResponse? response) + { + response = null; + if (!Payload.TryReadLines(text, out var lines) || + !Payload.HasVersion(lines)) + { + return false; + } + + bool? ok = null; + string? message = null; + var items = new List(); + foreach (var (name, value) in lines) + { + switch (name) + { + case "status": + ok = value == "ok"; + continue; + case "message": + if (!Payload.TryDecode(value, out message)) + { + return false; + } + + continue; + case "item": + if (!TryParseItem(value, out var item)) + { + return false; + } + + items.Add(item); + continue; + default: + continue; + } + } + + if (ok is null) + { + return false; + } + + response = new(ok.Value, message, items); + return true; + } + + static bool TryParseItem(string value, [NotNullWhen(true)] out ViewerResponseItem? item) + { + item = null; + var parts = value.Split('|'); + if (parts.Length != 3) + { + return false; + } + + if (!Payload.TryDecode(parts[0], out var key) || + !Payload.TryDecode(parts[1], out var name) || + !Payload.TryDecode(parts[2], out var status)) + { + return false; + } + + item = new(key, name, status.Length == 0 ? null : status); + return true; + } +} diff --git a/src/DiffEngineViewer/Ipc/ViewerServer.cs b/src/DiffEngineViewer/Ipc/ViewerServer.cs new file mode 100644 index 00000000..3a97d866 --- /dev/null +++ b/src/DiffEngineViewer/Ipc/ViewerServer.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Net.Sockets; + +/// +/// The single instance gate and the queue's inbox. +/// +/// Ownership is decided by the bind, not a named mutex: whoever binds the port owns the window, +/// and a process that fails to bind forwards its patch to the owner and exits. That is race free +/// without any extra coordination, and it sidesteps the mac named mutex IOException already +/// documented in DiffEngine's TrayDetector. +/// +/// +sealed class ViewerServer : IDisposable +{ + readonly TcpListener listener; + + ViewerServer(TcpListener listener, int port) + { + this.listener = listener; + Port = port; + } + + public int Port { get; } + + public static bool TryBind(int port, [NotNullWhen(true)] out ViewerServer? server) + { + server = null; + // Without ExclusiveAddressUse a second bind can succeed on some platforms, and then two + // windows race for the same queue. + var listener = new TcpListener(IPAddress.Loopback, port) + { + ExclusiveAddressUse = true + }; + try + { + listener.Start(); + } + catch (SocketException) + { + // Already in use, so another viewer owns the queue. + return false; + } + + // Port 0 asks the OS to choose, which the tests use to avoid colliding with a live viewer. + server = new(listener, ((IPEndPoint) listener.LocalEndpoint).Port); + return true; + } + + public async Task Listen(Func handle, Cancel cancel = default) + { + await using var registration = cancel.Register(listener.Stop); + while (!cancel.IsCancellationRequested) + { + try + { + using var client = await listener.AcceptTcpClientAsync(cancel); + await Handle(client, handle, cancel); + } + catch (OperationCanceledException) + { + return; + } + catch (ObjectDisposedException) + { + // The listener was stopped by cancellation. + return; + } + catch (SocketException) + { + return; + } + catch (IOException) + { + // A client disconnected part way through. Keep serving. + } + } + } + + static async Task Handle(TcpClient client, Func handle, Cancel cancel) + { + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + var text = await reader.ReadToEndAsync(cancel); + + var response = ViewerMessage.TryParse(text, out var message) + ? handle(message) + : ViewerResponse.Error("Unreadable request"); + + var bytes = Encoding.UTF8.GetBytes(response.Build()); + await stream.WriteAsync(bytes, cancel); + await stream.FlushAsync(cancel); + } + + public void Dispose() => + listener.Stop(); +} diff --git a/src/DiffEngineViewer/Ipc/ViewerVerb.cs b/src/DiffEngineViewer/Ipc/ViewerVerb.cs new file mode 100644 index 00000000..83887eae --- /dev/null +++ b/src/DiffEngineViewer/Ipc/ViewerVerb.cs @@ -0,0 +1,31 @@ +enum ViewerVerb +{ + /// + /// Queue a patch, or replace the existing entry with the same key. From DiffEngine. + /// + Inline, + + /// + /// Drop the entry for a key, because a previously failing test now passes. From DiffEngine. + /// + Settle, + + /// + /// Return the pending entries. From the tray. + /// + List, + + Accept, + AcceptAll, + Discard, + DiscardAll, + + /// + /// Select an entry, unhide and raise the window. + /// + Focus, + + Show, + Hide, + Quit +} diff --git a/src/DiffEngineViewer/Ipc/WindowCommand.cs b/src/DiffEngineViewer/Ipc/WindowCommand.cs new file mode 100644 index 00000000..f6cbe72e --- /dev/null +++ b/src/DiffEngineViewer/Ipc/WindowCommand.cs @@ -0,0 +1,9 @@ +/// +/// Window side effects a remote message asks for, which only the render loop can perform. +/// +enum WindowCommand +{ + Show, + Hide, + Focus +} diff --git a/src/DiffEngineViewer/Model/Button.cs b/src/DiffEngineViewer/Model/Button.cs new file mode 100644 index 00000000..bf799254 --- /dev/null +++ b/src/DiffEngineViewer/Model/Button.cs @@ -0,0 +1,5 @@ +/// +/// travels with the button so the render loop looks up what a click +/// means rather than repeating the layout's ordering. +/// +record Button(string Label, bool Enabled, CommandKind Command); diff --git a/src/DiffEngineViewer/Model/Pane.cs b/src/DiffEngineViewer/Model/Pane.cs new file mode 100644 index 00000000..d4404046 --- /dev/null +++ b/src/DiffEngineViewer/Model/Pane.cs @@ -0,0 +1,6 @@ +/// +/// One side of the diff. holds only the visible slice; +/// and describe where that slice sits +/// so a scrollbar can be drawn. +/// +record Pane(string Header, IReadOnlyList Rows, int ScrollTop, int TotalRows); diff --git a/src/DiffEngineViewer/Model/QueueItem.cs b/src/DiffEngineViewer/Model/QueueItem.cs new file mode 100644 index 00000000..5daa8c5b --- /dev/null +++ b/src/DiffEngineViewer/Model/QueueItem.cs @@ -0,0 +1 @@ +record QueueItem(string Label, bool Selected, string? Status); diff --git a/src/DiffEngineViewer/Model/Row.cs b/src/DiffEngineViewer/Model/Row.cs new file mode 100644 index 00000000..3792d1cc --- /dev/null +++ b/src/DiffEngineViewer/Model/Row.cs @@ -0,0 +1,5 @@ +/// +/// One rendered line in a diff pane. is null for +/// rows. +/// +record Row(int? LineNumber, RowKind Kind, string Text); diff --git a/src/DiffEngineViewer/Model/RowKind.cs b/src/DiffEngineViewer/Model/RowKind.cs new file mode 100644 index 00000000..1c3d9377 --- /dev/null +++ b/src/DiffEngineViewer/Model/RowKind.cs @@ -0,0 +1,15 @@ +/// +/// How a relates to the other pane. +/// +enum RowKind +{ + Unchanged, + Added, + Removed, + Modified, + + /// + /// No line exists on this side. Rendered blank to keep the two panes vertically aligned. + /// + Filler +} diff --git a/src/DiffEngineViewer/Model/Screen.cs b/src/DiffEngineViewer/Model/Screen.cs new file mode 100644 index 00000000..a9544788 --- /dev/null +++ b/src/DiffEngineViewer/Model/Screen.cs @@ -0,0 +1,20 @@ +/// +/// Everything needed to draw one frame, and nothing else. Built by , +/// rendered either as text by or as pixels by the native shim. Both +/// renderers consume the identical structure, which is what makes the text snapshots meaningful. +/// +/// holds only the visible slice, so a renderer never decides what +/// scrolls into view. +/// +/// +record Screen( + string Title, + string Subtitle, + ViewerMode Mode, + IReadOnlyList Queue, + Pane Left, + Pane Right, + IReadOnlyList