From 4efbbfcd1dc2de739c44b280bab8c8584625d085 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sun, 9 Aug 2026 09:42:36 +1000 Subject: [PATCH 1/3] . --- .gitattributes | 1 + .github/workflows/build-native.yml | 147 ++++ .github/workflows/publish-nuget.yml | 79 ++ .github/workflows/test.yml | 133 ++++ .gitignore | 1 + .gitmodules | 6 + appveyor.yml | 48 -- claude.md | 30 +- docs/diff-tool.md | 66 ++ docs/diff-tool.order.md | 3 +- docs/mdsource/doc-index.include.md | 1 + docs/mdsource/viewer.source.md | 84 +++ docs/readme.md | 1 + docs/viewer.md | 91 +++ native/CMakeLists.txt | 54 ++ native/include/deview.h | 170 +++++ native/src/deview.cpp | 699 ++++++++++++++++++ native/vendor/imgui | 1 + native/vendor/raylib | 1 + readme.md | 2 + src/DiffEngine.Tests/defaultOrder.include.md | 1 + src/DiffEngine.Tests/diffToolList.include.md | 1 + src/DiffEngine.Tests/diffTools.include.md | 66 ++ src/DiffEngine.slnx | 17 +- src/DiffEngine/Definitions.cs | 1 + src/DiffEngine/DiffEngine.csproj | 65 ++ src/DiffEngine/DiffRunner_Inline.cs | 104 +++ src/DiffEngine/DiffRunner_InlineMove.cs | 81 -- src/DiffEngine/DiffTool.cs | 4 + .../Implementation/DiffEngineViewer.cs | 59 ++ src/DiffEngine/InternalsVisibleTo.cs | 3 +- src/DiffEngine/Tray/PiperClient.cs | 45 +- .../Viewer/BundledViewerDirectory.cs | 79 ++ src/DiffEngine/Viewer/ViewerClient.cs | 105 +++ src/DiffEngine/Viewer/ViewerLauncher.cs | 87 +++ src/DiffEngine/Viewer/ViewerPayload.cs | 66 ++ .../buildTransitive/DiffEngine.targets | 17 + .../DiffRunnerCanKillTest.cs | 2 +- src/DiffEngineTray.Tests/FakeViewer.cs | 152 ++++ ...enuBuilderTest.FullWithInline.verified.png | Bin 11663 -> 12353 bytes .../MenuBuilderTest.OnlyInline.verified.png | Bin 7622 -> 8944 bytes src/DiffEngineTray.Tests/MenuBuilderTest.cs | 4 +- src/DiffEngineTray.Tests/ModuleInitializer.cs | 22 +- .../PiperTest.InlineMove.verified.txt | 6 - .../PiperTest.InlineMoveJson.verified.txt | 7 - ...nlineMoveJsonNoStagedVerified.verified.txt | 6 - src/DiffEngineTray.Tests/PiperTest.cs | 39 +- src/DiffEngineTray.Tests/RecordingTracker.cs | 6 +- .../TrackerInlineMoveTest.cs | 221 ------ .../TrackerSnapshotTest.cs | 140 ++++ .../TrayVersionFileTest.cs | 57 ++ src/DiffEngineTray/DiffToolLauncher.cs | 4 - src/DiffEngineTray/InlineViewerProxy.cs | 99 +++ src/DiffEngineTray/MenuBuilder.cs | 65 +- .../Payloads/InlineMovePayload.cs | 7 - src/DiffEngineTray/PendingSnapshot.cs | 45 ++ src/DiffEngineTray/PiperServer.cs | 18 +- src/DiffEngineTray/Program.cs | 7 +- src/DiffEngineTray/TrackedInlineMove.cs | 31 - src/DiffEngineTray/Tracker.cs | 187 ++--- .../AsciiRendererTests.cs | 86 +++ .../CommandLineTests.Files.verified.txt | 4 + .../CommandLineTests.Inline.verified.txt | 5 + ...ests.InlineArgumentsReordered.verified.txt | 5 + ...LineTests.Rejected_LineIsZero.verified.txt | 1 + ...Tests.Rejected_LineNotANumber.verified.txt | 1 + ...ineTests.Rejected_MissingLine.verified.txt | 1 + ...eTests.Rejected_MissingSource.verified.txt | 1 + ...neTests.Rejected_MissingValue.verified.txt | 1 + ...ineTests.Rejected_NoArguments.verified.txt | 1 + ...andLineTests.Rejected_OneFile.verified.txt | 1 + ...LineTests.Rejected_ThreeFiles.verified.txt | 1 + ...ests.Rejected_UnknownArgument.verified.txt | 1 + .../CommandLineTests.cs | 33 + .../DiffEngineViewer.Tests.csproj | 25 + .../FileScreenTests.AtEnd.verified.txt | 24 + .../FileScreenTests.Initial.verified.txt | 24 + .../FileScreenTests.LeftEmpty.verified.txt | 24 + .../FileScreenTests.LongLines.verified.txt | 24 + .../FileScreenTests.NextChange.verified.txt | 24 + ...FileScreenTests.NoDifferences.verified.txt | 24 + .../FileScreenTests.RightEmpty.verified.txt | 24 + .../FileScreenTests.Scrolled.verified.txt | 24 + src/DiffEngineViewer.Tests/FileScreenTests.cs | 56 ++ src/DiffEngineViewer.Tests/Fixtures.cs | 88 +++ ...InlineScreenTests.AfterAccept.verified.txt | 24 + ...ineScreenTests.AfterAcceptAll.verified.txt | 24 + ...nlineScreenTests.AfterDiscard.verified.txt | 24 + .../InlineScreenTests.EmptyQueue.verified.txt | 24 + ...InlineScreenTests.FailedApply.verified.txt | 24 + ...ests.FailedApplyThenAcceptAll.verified.txt | 24 + ...InlineScreenTests.NewSnapshot.verified.txt | 24 + .../InlineScreenTests.Queue.verified.txt | 24 + ...creenTests.SecondItemSelected.verified.txt | 24 + .../InlineScreenTests.Single.verified.txt | 24 + .../InlineScreenTests.StalePatch.verified.txt | 24 + ...creenTests.UnparsedExpression.verified.txt | 24 + .../InlineScreenTests.cs | 68 ++ .../IpcTests.AcceptAnUnknownKey.verified.txt | 4 + .../IpcTests.List.verified.txt | 13 + .../IpcTests.ListWhenEmpty.verified.txt | 3 + src/DiffEngineViewer.Tests/IpcTests.cs | 254 +++++++ .../ModuleInitializer.cs | 6 + .../PixelTestAttribute.cs | 12 + src/DiffEngineViewer.Tests/PixelTests.cs | 94 +++ src/DiffEngineViewer.Tests/ServerFixture.cs | 78 ++ .../ViewerProtocolTests.cs | 97 +++ .../ViewerSessionTests.cs | 202 +++++ src/DiffEngineViewer/AsciiRenderer.cs | 196 +++++ .../Assets/JetBrainsMono-OFL.txt | 93 +++ .../Assets/JetBrainsMono-Regular.ttf | Bin 0 -> 270224 bytes src/DiffEngineViewer/Command.cs | 12 + src/DiffEngineViewer/CommandKind.cs | 20 + src/DiffEngineViewer/CommandLine.cs | 76 ++ src/DiffEngineViewer/DiffEngineViewer.csproj | 63 ++ src/DiffEngineViewer/DiffRows.cs | 37 + src/DiffEngineViewer/GlobalUsings.cs | 3 + src/DiffEngineViewer/InternalsVisibleTo.cs | 1 + src/DiffEngineViewer/Ipc/MessageHandler.cs | 148 ++++ src/DiffEngineViewer/Ipc/Payload.cs | 84 +++ src/DiffEngineViewer/Ipc/ViewerClient.cs | 79 ++ src/DiffEngineViewer/Ipc/ViewerMessage.cs | 72 ++ src/DiffEngineViewer/Ipc/ViewerPort.cs | 22 + src/DiffEngineViewer/Ipc/ViewerResponse.cs | 99 +++ src/DiffEngineViewer/Ipc/ViewerServer.cs | 96 +++ src/DiffEngineViewer/Ipc/ViewerVerb.cs | 31 + src/DiffEngineViewer/Ipc/WindowCommand.cs | 9 + src/DiffEngineViewer/Model/Button.cs | 5 + src/DiffEngineViewer/Model/Pane.cs | 6 + src/DiffEngineViewer/Model/QueueItem.cs | 1 + src/DiffEngineViewer/Model/Row.cs | 5 + src/DiffEngineViewer/Model/RowKind.cs | 15 + src/DiffEngineViewer/Model/Screen.cs | 20 + src/DiffEngineViewer/Native/Deview.cs | 47 ++ src/DiffEngineViewer/Native/DeviewStructs.cs | 120 +++ src/DiffEngineViewer/Native/NativeResolver.cs | 108 +++ src/DiffEngineViewer/Native/ScreenPayload.cs | 165 +++++ src/DiffEngineViewer/Native/ViewerInput.cs | 11 + src/DiffEngineViewer/Native/ViewerWindow.cs | 156 ++++ src/DiffEngineViewer/Program.cs | 214 ++++++ src/DiffEngineViewer/QueueEntry.cs | 84 +++ src/DiffEngineViewer/ScreenBuilder.cs | 130 ++++ src/DiffEngineViewer/SessionHost.cs | 29 + src/DiffEngineViewer/SessionState.cs | 20 + src/DiffEngineViewer/ViewerActions.cs | 12 + src/DiffEngineViewer/ViewerMode.cs | 12 + src/DiffEngineViewer/ViewerRequest.cs | 11 + src/DiffEngineViewer/ViewerSession.cs | 290 ++++++++ .../win-x64/native/diffengine_viewer.dll | Bin 0 -> 992256 bytes src/Directory.Packages.props | 1 + 150 files changed, 7086 insertions(+), 683 deletions(-) create mode 100644 .github/workflows/build-native.yml create mode 100644 .github/workflows/publish-nuget.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitmodules delete mode 100644 appveyor.yml create mode 100644 docs/mdsource/viewer.source.md create mode 100644 docs/viewer.md create mode 100644 native/CMakeLists.txt create mode 100644 native/include/deview.h create mode 100644 native/src/deview.cpp create mode 160000 native/vendor/imgui create mode 160000 native/vendor/raylib create mode 100644 src/DiffEngine/DiffRunner_Inline.cs delete mode 100644 src/DiffEngine/DiffRunner_InlineMove.cs create mode 100644 src/DiffEngine/Implementation/DiffEngineViewer.cs create mode 100644 src/DiffEngine/Viewer/BundledViewerDirectory.cs create mode 100644 src/DiffEngine/Viewer/ViewerClient.cs create mode 100644 src/DiffEngine/Viewer/ViewerLauncher.cs create mode 100644 src/DiffEngine/Viewer/ViewerPayload.cs create mode 100644 src/DiffEngine/buildTransitive/DiffEngine.targets create mode 100644 src/DiffEngineTray.Tests/FakeViewer.cs delete mode 100644 src/DiffEngineTray.Tests/PiperTest.InlineMove.verified.txt delete mode 100644 src/DiffEngineTray.Tests/PiperTest.InlineMoveJson.verified.txt delete mode 100644 src/DiffEngineTray.Tests/PiperTest.InlineMoveJsonNoStagedVerified.verified.txt delete mode 100644 src/DiffEngineTray.Tests/TrackerInlineMoveTest.cs create mode 100644 src/DiffEngineTray.Tests/TrackerSnapshotTest.cs create mode 100644 src/DiffEngineTray.Tests/TrayVersionFileTest.cs create mode 100644 src/DiffEngineTray/InlineViewerProxy.cs delete mode 100644 src/DiffEngineTray/Payloads/InlineMovePayload.cs create mode 100644 src/DiffEngineTray/PendingSnapshot.cs delete mode 100644 src/DiffEngineTray/TrackedInlineMove.cs create mode 100644 src/DiffEngineViewer.Tests/AsciiRendererTests.cs create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Files.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Inline.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.InlineArgumentsReordered.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Rejected_LineIsZero.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Rejected_LineNotANumber.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingLine.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingSource.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Rejected_MissingValue.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Rejected_NoArguments.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Rejected_OneFile.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Rejected_ThreeFiles.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.Rejected_UnknownArgument.verified.txt create mode 100644 src/DiffEngineViewer.Tests/CommandLineTests.cs create mode 100644 src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj create mode 100644 src/DiffEngineViewer.Tests/FileScreenTests.AtEnd.verified.txt create mode 100644 src/DiffEngineViewer.Tests/FileScreenTests.Initial.verified.txt create mode 100644 src/DiffEngineViewer.Tests/FileScreenTests.LeftEmpty.verified.txt create mode 100644 src/DiffEngineViewer.Tests/FileScreenTests.LongLines.verified.txt create mode 100644 src/DiffEngineViewer.Tests/FileScreenTests.NextChange.verified.txt create mode 100644 src/DiffEngineViewer.Tests/FileScreenTests.NoDifferences.verified.txt create mode 100644 src/DiffEngineViewer.Tests/FileScreenTests.RightEmpty.verified.txt create mode 100644 src/DiffEngineViewer.Tests/FileScreenTests.Scrolled.verified.txt create mode 100644 src/DiffEngineViewer.Tests/FileScreenTests.cs create mode 100644 src/DiffEngineViewer.Tests/Fixtures.cs create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.AfterAccept.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.AfterAcceptAll.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.AfterDiscard.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.EmptyQueue.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.FailedApply.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.FailedApplyThenAcceptAll.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.NewSnapshot.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.Queue.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.SecondItemSelected.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.Single.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.StalePatch.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.UnparsedExpression.verified.txt create mode 100644 src/DiffEngineViewer.Tests/InlineScreenTests.cs create mode 100644 src/DiffEngineViewer.Tests/IpcTests.AcceptAnUnknownKey.verified.txt create mode 100644 src/DiffEngineViewer.Tests/IpcTests.List.verified.txt create mode 100644 src/DiffEngineViewer.Tests/IpcTests.ListWhenEmpty.verified.txt create mode 100644 src/DiffEngineViewer.Tests/IpcTests.cs create mode 100644 src/DiffEngineViewer.Tests/ModuleInitializer.cs create mode 100644 src/DiffEngineViewer.Tests/PixelTestAttribute.cs create mode 100644 src/DiffEngineViewer.Tests/PixelTests.cs create mode 100644 src/DiffEngineViewer.Tests/ServerFixture.cs create mode 100644 src/DiffEngineViewer.Tests/ViewerProtocolTests.cs create mode 100644 src/DiffEngineViewer.Tests/ViewerSessionTests.cs create mode 100644 src/DiffEngineViewer/AsciiRenderer.cs create mode 100644 src/DiffEngineViewer/Assets/JetBrainsMono-OFL.txt create mode 100644 src/DiffEngineViewer/Assets/JetBrainsMono-Regular.ttf create mode 100644 src/DiffEngineViewer/Command.cs create mode 100644 src/DiffEngineViewer/CommandKind.cs create mode 100644 src/DiffEngineViewer/CommandLine.cs create mode 100644 src/DiffEngineViewer/DiffEngineViewer.csproj create mode 100644 src/DiffEngineViewer/DiffRows.cs create mode 100644 src/DiffEngineViewer/GlobalUsings.cs create mode 100644 src/DiffEngineViewer/InternalsVisibleTo.cs create mode 100644 src/DiffEngineViewer/Ipc/MessageHandler.cs create mode 100644 src/DiffEngineViewer/Ipc/Payload.cs create mode 100644 src/DiffEngineViewer/Ipc/ViewerClient.cs create mode 100644 src/DiffEngineViewer/Ipc/ViewerMessage.cs create mode 100644 src/DiffEngineViewer/Ipc/ViewerPort.cs create mode 100644 src/DiffEngineViewer/Ipc/ViewerResponse.cs create mode 100644 src/DiffEngineViewer/Ipc/ViewerServer.cs create mode 100644 src/DiffEngineViewer/Ipc/ViewerVerb.cs create mode 100644 src/DiffEngineViewer/Ipc/WindowCommand.cs create mode 100644 src/DiffEngineViewer/Model/Button.cs create mode 100644 src/DiffEngineViewer/Model/Pane.cs create mode 100644 src/DiffEngineViewer/Model/QueueItem.cs create mode 100644 src/DiffEngineViewer/Model/Row.cs create mode 100644 src/DiffEngineViewer/Model/RowKind.cs create mode 100644 src/DiffEngineViewer/Model/Screen.cs create mode 100644 src/DiffEngineViewer/Native/Deview.cs create mode 100644 src/DiffEngineViewer/Native/DeviewStructs.cs create mode 100644 src/DiffEngineViewer/Native/NativeResolver.cs create mode 100644 src/DiffEngineViewer/Native/ScreenPayload.cs create mode 100644 src/DiffEngineViewer/Native/ViewerInput.cs create mode 100644 src/DiffEngineViewer/Native/ViewerWindow.cs create mode 100644 src/DiffEngineViewer/Program.cs create mode 100644 src/DiffEngineViewer/QueueEntry.cs create mode 100644 src/DiffEngineViewer/ScreenBuilder.cs create mode 100644 src/DiffEngineViewer/SessionHost.cs create mode 100644 src/DiffEngineViewer/SessionState.cs create mode 100644 src/DiffEngineViewer/ViewerActions.cs create mode 100644 src/DiffEngineViewer/ViewerMode.cs create mode 100644 src/DiffEngineViewer/ViewerRequest.cs create mode 100644 src/DiffEngineViewer/ViewerSession.cs create mode 100644 src/DiffEngineViewer/runtimes/win-x64/native/diffengine_viewer.dll 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..5e031f9b --- /dev/null +++ b/.github/workflows/build-native.yml @@ -0,0 +1,147 @@ +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 + with: + submodules: recursive + + - 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 + if: 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: + branch: native-binaries + 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..5f6b3fb5 --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,79 @@ +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 + with: + submodules: recursive + + - 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..0f538175 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,133 @@ +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 + with: + # native/vendor holds the raylib and imgui submodules. + submodules: recursive + + - 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 + with: + submodules: recursive + + - 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 + + # 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/.gitmodules b/.gitmodules new file mode 100644 index 00000000..198d161d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "native/vendor/raylib"] + path = native/vendor/raylib + url = https://github.com/raysan5/raylib.git +[submodule "native/vendor/imgui"] + path = native/vendor/imgui + url = https://github.com/ocornut/imgui.git 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..0ef23967 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,37 @@ 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 git submodules under `native/vendor`. Clone with + `git submodule update --init --recursive`. +- `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..4416fa71 --- /dev/null +++ b/native/CMakeLists.txt @@ -0,0 +1,54 @@ +cmake_minimum_required(VERSION 3.20) +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() + +# 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) +add_subdirectory(vendor/raylib EXCLUDE_FROM_ALL) + +set(IMGUI_DIR ${CMAKE_CURRENT_SOURCE_DIR}/vendor/imgui) +add_library(diffengine_viewer SHARED + src/deview.cpp + ${IMGUI_DIR}/imgui.cpp + ${IMGUI_DIR}/imgui_draw.cpp + ${IMGUI_DIR}/imgui_tables.cpp + ${IMGUI_DIR}/imgui_widgets.cpp) + +target_include_directories(diffengine_viewer PRIVATE include ${IMGUI_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-) + # No iterator debugging or RTTI cost in a renderer that never throws. + 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/native/vendor/imgui b/native/vendor/imgui new file mode 160000 index 00000000..46d39d56 --- /dev/null +++ b/native/vendor/imgui @@ -0,0 +1 @@ +Subproject commit 46d39d56febc2a00bdd2270dc88c8a13f2a0441a diff --git a/native/vendor/raylib b/native/vendor/raylib new file mode 160000 index 00000000..1893226c --- /dev/null +++ b/native/vendor/raylib @@ -0,0 +1 @@ +Subproject commit 1893226cb79506a7cad3658e76a11d9133dbeba0 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 b1b466f88dbb1d74fd5c5de209a1b9f72252a9ba..0c4aeda611f89233f00343e215cbcc2ea1e699b5 100644 GIT binary patch literal 12353 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 literal 11663 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 diff --git a/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png b/src/DiffEngineTray.Tests/MenuBuilderTest.OnlyInline.verified.png index 4552fb8bb687de372387826a25dc4fe6d5095b73..b8c1eef4247fd374cb391720f06a6121d6f563c9 100644 GIT binary patch literal 8944 zcmaiaXIK-_+HDX-Q9(M=r6W}!fb^Eo1py&+lp4D9-jxp0LX{SJ2`Ge4h?PhuR0&O_ z_m0wUJm;S8Ip;a|KHra&*)w})&z^U$cdhkK+%p|D(mM=y0001~hB`5m%JqckJPfwyg|6FBWSB*dqPbc8?CtJP`SFxc8C z%J;IoxP$4XS7ihz#QXwD``n6ph$PDYAbOXCT_3b0h$_{*BqTnn91+4u0C4ISaD;iAe#pflD?}}s$*~P8S7tG>+QzmY zc)m465UVYOC1a7T>^jWftwfl$G(b6r7rw+KnW*8%S7;8m2-dVhlO;_MiPlr;V{H^B$|~v&kC05>=CTVOOVu zGy~&s5t>h$sI#S8Ai{i&XF%)olkMt?3blNn2@|Kq?j)!At`K6iw=Ax2p|G9eHfDIl zAg2NF*9Q!X0f%bhvjjTfomdgm9QpC-(VD=ZVf=2MEGQ6heQ`MC1xX{~*zY=@-EC0Z zrf|*TJARQ!9IxVrm0rH(O!-z}lBoJh5VVLE*}RR@wSX^B2mqW)gs_$1pwYa!QWpQ8 z1jpYw-ah_4AEt0kw3j!3LHy4raf>zh87Z@LM#V)8DP#9L&)!E)IHcpoaIp$GDL}~r z-e%AIVUG%+Ku#KdTM-Cg2VeoQ01)BB8~@&*r~(f;uCX$#_y5N4e|&5RvG^teddL8f z5KmCm4Xri)pe%Q>_oY*3yM*s}c^~ zPuQ5<4Qe(Q5_m48|@D&`A?Yi7{hcr42895Y&NVK@@gVPa(U2-bGN1B1bY2{bDcLJXk z^-s_p`E|lWmN-}!roOOV?Zs@E&7WlkZko;a+K9%}C|mT%?6j(&P6ynLAm$!7uhm0- zek7nxxYD&27szcq*lcZ2UXfR&Mx;P>s+`FMPZ=Y^!*A6?sV$S+qgl^?VE$Y~ECb5U z7Fn+=?FVwpxSiqeRLb(FmBS02L6EexY-8=lfvoOl_g&MqDi{3tNrdt z>im)hItlwAu{{|mpC{%(c71KJ8<>S(2nGqDnr2FUiJ(BVsOn+z2T{;r5_SYF2HM;l z|4JE2NKTn#PvK}CRle`2dF@jl#H=u>z9Pw-;gDFNYFjpZ?4Xcq;( zmC+KbUb|V>F}Nl@m@kuc)BODl0(Xu&)QM^I)nydx#Y0$a0DRemn~F*nLgG}xjg@ep zs~wcy6!2NeaWEZDW4PnDH{&%Vu8Xwq_Hkh#CmqwlqgkH+pS_n&?(_1;9rKq$%Pr_j zMBqkMMKehM$bL#DLP^adikT^!FCqZU!aU?^jAf*skl#T`z!P ze0^c!z|w=Hq7RIS?(!5b(u9>n?XnWgiaKATkzP``OoPuD*Hcgxi}P0(^);L6zuHxLjF{CaN&#IS0fkGv23++Ce_j=6XdhaMUI#SP7@~B~TXN2yBVoK1I^KcG z7$}&lPsDcGlpXly=;RRpb2Zp?StmyOr(8xCL3^fcLN7P19O6&#P_LfEB4@fihw2DG zmx#c6tNhLdNcdn14(4iJK^$b^A(f)IXMmVYf=s1xFgY#rk?-D*u9Z)JfE)!v!P&rVQde5e8plP5<^Em0=5$L%jl zON07uyAKPd0G>)=nBZ-P5zqndEU0VT<^ysB!>Q(TVjDd>yJa8(y5i>0P~XOG+HHbt z4$5r$VdoX2`+n2|#xF%T*}xD`(B;iMUF$X4N8Za5RuHR|^VS^R#8p>PBASJMZe{l> zj$7sKcUrw2jur!!g1Ty&Y&A5=>ygv|?&%uG9Kob6K^cq7!2Ji4k4%l^r4BPvd&#p# zw1luY%1NvgJ^OCM6rA2K*hD042>C*)!CG^wCicjyVv!eOiB(>=&(9-tmlt1NZ=)cH z(#GOwCq;~V;$-1*nm;RnnGd6X*ah1S^H3!=5RZCf)X-vs|8I|gL+yjg;FV4LC?
mlwnXTG9V`;S^Zt1J`RD(QfefWMpmStd-wD%f0h_HyP6P{{Yk?)(ED;LzK^Ht7B; zo>AS+Ok}6X(&Q2kIrH{e&G%EKMP%9?1H5T1v6X%P_|!~hr}!xVO48q8*VlUY_dyo= z9S;~MluPkMbr(0qpNSa%v>qCe*}tSUblGN(*fMp=ZDA^^Bm#5rZtR_|7S>1KqyN0{ zv5I{dz1z0RVp*e#Ft@TQl=fUwPXIU4IiA2?d#i5U4{1XgVMl*NiLTscUSy0}Rv-Eu zP!4U%Af$$AV{bcsQ0K+DZ%!aKHfrka7j$Izza9x`qnoyWd?;Cmx0wmh6=c(O{fKJt zx!29xlUlHQD1de1yT!}b21-w78%xW7^_fHfa7pw&^fK@`;(l;=*vwp4NRR zIUkR*?W+e_P-`?23~bLgoS~6b3!EGBip}i6-^mykPB-*`s_vdah0d~3l0A3Tw0c9n z_lQQ7%K-JjhP3C+asKxeV6owmS4hrK&(hV)8IM|OK|d))y(%qWpv}EFRd+xcNxH4* z>rjWF`SUr9@K8l}f@gCY;Gm0r%xP6xH<+*}sL~7=*&bgXdR%k5V6&m)_Tzm7VI7`9;-k0- zn*u$G5`VfLwx+`ROM@aU(Vnd^_&@_MxKzKun|6B00qwW`71#eoAl4pWC0ubtijWaK zIKlA1|5HVzXaUnU@OR`ikSo3sc~|I}N(`A{)UEqQR6IJ*CFg@1aZS#2cZsopSLs?v z8h)d_JlpO>`Y#F6s{?rzb!{sQp`-V}U2yV+qu)ET9c&B$GEs2HcJs8q(&{%lt9~q^ zGB4Ykd3~Pxox}dGZ!@0<-bqox34VU$XGp^Abe+3py7Z)R2_(BGs(QN4b(%cQeyn8S zJ7xD*usx+nBHVwVq;uFMv!b8P&f7JNOTd`LC8cS zS6K@IGNeDv5q)FU01_>P_SoL`5xGUiR5?*+F%85fCQS2@cw3Q6- z_OB)DY2;S8VBoh`TqQ-~#kH$c{iak$(tZf$2Ds4&&|C0Zq?GobeJEGl zk(^!nZt!&o88zXwYI#aF6QI$+)$(=3J$BBI*_+!E3%wI|b#c1B3#G<*?98-XxH$*D z8U6g`?s$$vLDgSna5&$C-x*VAE!V7!bOKsWHRypggaY+Ns;%DBw^*^%+_HN*GQw{y z=xBKIJHGsu0gUD`e|>GsYAo=$a#OS>-#FFw-x?>n^fDxHx zXBc1?GJgM%CaK$o+9;W*l#W_I@zSuy$V9<-v>|*2#OM1o`nga8hh>PNCMJKRohn}V z$C|Vz7EjM~J>AMnBRU0+Jlpx~Oeo##I$@$5x|63pHt*nV2b8Y|5uRdcdew-|yLkBj z%gVKc+Pfg4~EFmdQ$=9+Sn!|Mr3%;bu$-Z<9aNck}U_ zf0*bwISi){$n3~zTWUV=W?G~i$jSQJCH3BR4mpFNzF7|qI@!|0n-B()v))t*oIEP# zaMzjE)G)si$X@evWG~&?T&7+?txX?IgAi79lyZx&%2KLTvO6=P^YzD*X|EeLRX_9G zZUk_za*U4KrQpY&_Px_fy)hD{p+dEo|8H+a~9{rvZ?LmMSl{qcsTzLu)Ot!I!!tQxD` zhg>#Owp71Ayh~70X^KoP_cmt9H#aD#1z!%nle!3+LOPEAS&llIcn}!WzcF4D&|7H9 zqAN&`nr{zN*nGf{j!cV;=k91F_>9W?YrPybeDc|x*caeTnqKo0&Hch#l#mnV& zd1)(TKiB4Sjo#@#!FT42nX%=9Eyhb3t(0@>{6RWFvFhh-@CvK(8b`Ce|V| zE)Hoc;FPp+Y)7S`60=ovtw3^}mLXCR?C~7PaUbH>8&pd^KpAs81A{{ozhs~o2-=p# z{5HUMstK~zpR|pw6)<{3##BQkD-pq0mw^b@X^70!IPmiB5XiBWKf4mYJsJz0JNS+~ zle|fez)7o5Z4}J4@a5$D?is5P9~!fioJPhnU^KTh9eoAuTC~~wOrstj$FDpIi~5;V z9Hw^<`#_EV=LKb-RcKRRrlCo{O|R{9pz}xJ-sf#1rG;D##+Y8uz7BN2;M^UHPw#Z< z4YLD5S|@%MGA0FAPa{BtIq$#gPxvmRAXy2q?f0DfaonG0XRiKt?ebq@=6@QfK|ste zP3JS6shoiBu^d6$dYzORo;7-wx6ZVBz!+W_d2j zOxkS9^`g`3U~{D7{JQ!%N86$ju`_6#>tPG0=!Aw;(oAY%en1Ba#C$4p&5?V$p)Tpd zZvr1+1)H|tSUn6-I)+Q~`8ot3Exw{ntzeB~7*e?Rc9>DsPj>5cAzi5z6wAlwOP?su z`HXp$-IdGk`KXENeCNomzt;uaI^0*|B#Wb!7TT2V5j}m>U<(of)ueQO{vI}&3Hw;< zKfyd7PsGJvEx&^u@%<|1O}0a-C*G-i`n@s*#+ZsWgst+8BI{uxuEFq|$$gzV$%};4 zhNAFiaFGW6GK57Wn$kAG9>16)UavGx~Jf2Z#|IvjG7zz21*LxUAw;J(Bn z(3+KRnf__y=6Aabjp31A4|@U9w?F9}{}9GS-#~H0v~(c}JBEG*^phMtJY#SZz3yH| z-0aCmao_>(-O&=1Sa`3wj&O>qr%=vuvDZ2a*7l7patBub#(Rll_w%DJ;8E~V2x@tf zze>vmp*)%AFmYUjZUl*aOq!XP3L!KfHU1J^#XetQEU83XyBu*c+}3N znpGEX0JLHdEwoiY$>Kchys=w{K-+qrA=ya|1EeOu@f4<$AkKLg6>X?7QVGFd=eqBJ zc=Xk*OT5ZzyuA;X`FCM6Bjb6x=R0*CWj;pVWucC(2Os!SEt?rkk>$(lPq<^COnDb~i~j!-lM~zUO9>lrKD)jMM{Q+nG&XDoNhJ;>bXo_Z5-NgxE8} zf3H&fcNAh`n;l2F0qtqYl%(Nq_=?Csi(mgP*CWi~sn28Xvl=;1)auchzlvax_O4`* z^?#rH!~@=bx{^0uXVGE&7_Wr#%;5-pr6WIRr_HH9Tf_;kPn_mD12%G<>-7Fwb=B?!OL2Gxc? zkr1thdB~flJaqqQ^vu5#j?H%I&3H7E#+7yo(&ARBOpto@Q?bc+8&@93&2LF5CjX%6 z5qA!Jz4#7qZaOnF-veqI@0*s`9n^12mUOwxqtP}8=E9&IDWAE3)hd%l`zCGR=fs2` z_M#xIl0)fc5%y;5m8+r@e{i0Bh_o(Vbp(J*gq9AL`vN>mVQ`pP+|r;saf^njY)9Us zMHQoFdJ8F(cokA;f>FEwM--|o1v({|!DSTPT9l2qDIQ&%91J20Ff}uDD2hQo*gpY0+F_mxb^No@8+tInuiB^g%k`?I^LereABq{D=u<64 z8raR(B@;U_;p})*(4K-$z&IuTuqm+qXm(`QvX4=$O{Nl%|#FwpgqZGeglkQ3dORS)>Gz@d7r)a1}g!7GW32JW7{3GF9{W)SWg@3Xo zTyxtOlV!%DNg2n!+*hbI!j}hE2J>tFnnZ$A69}{Y+7iOw9UooHE$oJwmngS?eu{FM zZTFGAtFb6?mY-O@=jc?(E$@apHfr)()ycdICwPIk3OXB;jo%W7Gybn+JR&nzb#n~A z@(pu^a6@5NUgAC*&;R-x2H=j}uJf^23XU-t#B$0U+u3)O@VbjuFvx9xa>X-P$UI6SsLMqQ8Hb)KU8rDeD%b)9#uY7K_hm0)p+KX4U5ldUnG;>=}l*Jc5 zY|dop2ZVa(vAKe|^hrOyc7A~nX*}j;^$Zr`M`KYLG@XI9<0GOg+7c}KvT=B0mdPYfOSK$$Ls;V9^U*)v&d|p$5d#zt)xm@SWac%Y8uto^ V%bsQ_{w)=N23QAFr~D%F{{SYL6runC literal 7622 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 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 0000000000000000000000000000000000000000..711830ede02a366f8b99f88e52f3148405e67eaf GIT binary patch literal 270224 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

{Qq;dYoLXG*oL7!{wrG%~aKb=FoR7{s=k| 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

AzPi!HK1CYdMasJ%868x!dF;`ny|>LRT>eY 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 +/// 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