Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 72 additions & 30 deletions .github/workflows/build-native.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
name: Build native

# Produces the diffengine_viewer binaries committed under
# src/DiffEngineViewer/runtimes/{rid}/native.
# src/DiffEngineViewer.{Linux,Mac}/runtimes/{rid}/native, in the head that loads them. There is no
# Windows equivalent: that head renders with WinForms.
#
# 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
Expand Down Expand Up @@ -34,12 +35,7 @@ jobs:
fail-fast: false
matrix:
include:
- rid: win-x64
os: windows-latest
generator: -A x64
- rid: win-arm64
os: windows-latest
generator: -A ARM64
# No Windows entries. That head renders with WinForms and loads no native library.
- rid: linux-x64
os: ubuntu-24.04
- rid: linux-arm64
Expand All @@ -61,40 +57,83 @@ jobs:
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
if: matrix.rid != 'osx'
run: cmake -S native -B build -G Ninja -DCMAKE_BUILD_TYPE=Release

- name: Build
if: matrix.rid != 'osx'
run: cmake --build build --config Release

# macOS draws with AppKit and Core Text rather than raylib and ImGui, so it is a Swift
# package rather than a CMake project. Both --arch flags in one invocation produce a
# universal binary, so there is no separate lipo step.
#
# Nothing of the Swift runtime is shipped: it has been part of macOS since 10.14.4, which is
# why this dylib is a fraction of the size of the one it replaced.
- name: Build
if: matrix.rid == 'osx'
run: swift build -c release --arch arm64 --arch x86_64 --package-path native/swift

- name: Collect
shell: bash
run: |
# Laid out as src/{head}/runtimes/{rid}/native, so the propose job below can merge every
# artifact straight into src and the binaries land in the head that loads them.
collect() {
mkdir -p "artifacts/$1/native"
cp "$2" "artifacts/$1/native/"
mkdir -p "artifacts/$1/runtimes/$2/native"
cp "$3" "artifacts/$1/runtimes/$2/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
collect DiffEngineViewer.Linux "${{ matrix.rid }}" build/libdiffengine_viewer.so
;;
osx)
strip -x build/libdiffengine_viewer.dylib
# swift build leaves a per architecture dylib in more than one place, so taking the
# first one found gives a single slice binary that loads on half the Macs in the
# world. Every candidate is checked, and if none is already universal they are
# merged, so the only thing that can be collected is a fat binary.
# The dSYM contains a DWARF file of the same name which is also universal, so the
# arch check below would happily accept it and ship debug symbols as the library.
candidates=$(find native/swift/.build -name libdiffengine_viewer.dylib -not -path '*.dSYM/*')
if [ -z "$candidates" ]; then
echo "::error::swift build produced no libdiffengine_viewer.dylib"
exit 1
fi

echo "$candidates" | while read -r candidate; do
echo "$candidate: $(lipo -archs "$candidate" 2>/dev/null)"
done

dylib=""
for candidate in $candidates; do
archs=$(lipo -archs "$candidate" 2>/dev/null || echo "")
if [[ "$archs" == *x86_64* && "$archs" == *arm64* ]]; then
dylib="$candidate"
break
fi
done

if [ -z "$dylib" ]; then
dylib=universal/libdiffengine_viewer.dylib
mkdir -p universal
# shellcheck disable=SC2086
lipo -create $candidates -output "$dylib"
fi

archs=$(lipo -archs "$dylib")
echo "collecting $dylib: $archs"
for arch in x86_64 arm64; do
case " $archs " in
*" $arch "*) ;;
*) echo "::error::$dylib is missing the $arch slice"; exit 1 ;;
esac
done

strip -x "$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
collect DiffEngineViewer.Mac osx-x64 "$dylib"
collect DiffEngineViewer.Mac osx-arm64 "$dylib"
;;
esac
ls -lhR artifacts
Expand Down Expand Up @@ -133,11 +172,11 @@ jobs:
with:
pattern: native-*
merge-multiple: true
path: src/DiffEngineViewer/runtimes
path: src

- name: Show what changed
run: |
ls -lhR src/DiffEngineViewer/runtimes
ls -lhR src/DiffEngineViewer.Linux/runtimes src/DiffEngineViewer.Mac/runtimes
git status --short

# A PR rather than a direct push: these are binaries, so the diff is not reviewable and the
Expand All @@ -151,7 +190,10 @@ jobs:
title: 'Rebuild native renderer binaries'
commit-message: 'Rebuild native renderer binaries'
body: |
Rebuilt `diffengine_viewer` from `native/` for all six RIDs.
Rebuilt `diffengine_viewer` from `native/` for the four RIDs that load one.
Windows is not among them: that head renders with WinForms.

Produced by the `build-native` workflow from ${{ github.sha }}.
add-paths: src/DiffEngineViewer/runtimes
add-paths: |
src/DiffEngineViewer.Linux/runtimes
src/DiffEngineViewer.Mac/runtimes
13 changes: 9 additions & 4 deletions .github/workflows/publish-nuget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,18 @@ jobs:
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"
check() {
directory="src/$1/runtimes/$2/native"
if [ -z "$(ls -A "$directory" 2>/dev/null)" ]; then
echo "::error::No native renderer for $rid. Run the build-native workflow."
echo "::error::No native renderer for $2. Run the build-native workflow."
missing=1
fi
done
}
# No Windows RIDs: that head renders with WinForms and loads no native library.
check DiffEngineViewer.Linux linux-x64
check DiffEngineViewer.Linux linux-arm64
check DiffEngineViewer.Mac osx-x64
check DiffEngineViewer.Mac osx-arm64
exit $missing

# Enumerated rather than passed as a glob. This job runs on Windows, where the shell is pwsh
Expand Down
33 changes: 23 additions & 10 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
# macOS is pinned rather than latest, because it carries committed pixel baselines and
# Core Text rasterisation moves between OS versions. Same image build-native uses.
os: [ubuntu-latest, macos-14]
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down Expand Up @@ -104,8 +106,8 @@ jobs:
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/
mkdir -p src/DiffEngineViewer.Linux/runtimes/linux-x64/native
cp native/build/linux-x64/libdiffengine_viewer.so src/DiffEngineViewer.Linux/runtimes/linux-x64/native/

# Release-NotWindows drops the WinForms tray and its tests from the solution.
- name: Build
Expand All @@ -130,6 +132,17 @@ jobs:
dotnet test src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj
--configuration Release --no-build --no-restore

# No xvfb and no GL flags: deview_capture on this platform draws into a bitmap context of its
# own making, so it needs neither a window nor a window server. Determinism is pinned inside
# that call rather than by the environment.
- name: Pixel snapshots
if: runner.os == 'macOS'
env:
DIFFENGINE_VIEWER_PIXEL_TESTS: 'true'
run: >
dotnet test src/DiffEngineViewer.Tests/DiffEngineViewer.Tests.csproj
--configuration Release --no-build --no-restore

- name: Upload received on failure
if: failure()
uses: actions/upload-artifact@v4
Expand All @@ -139,10 +152,10 @@ jobs:
if-no-files-found: ignore
retention-days: 14

# The jobs above only ever load the x64 natives: the Linux one rebuilds its own from source, and
# Windows never P/Invokes because the pixel tests are Linux gated. This job exists so the other
# four committed binaries are actually loaded somewhere, which is what catches a wrong
# architecture, a file corrupted by a text mode checkout, or an unsatisfied runtime dependency.
# The jobs above only ever load the x64 natives, and the Linux one rebuilds its own from source.
# This job exists so the arm64 binaries are actually loaded somewhere, which is what catches a
# wrong architecture, a file corrupted by a text mode checkout, or an unsatisfied runtime
# dependency.
#
# It runs DiffEngineViewer.Tests rather than the whole suite: that is where the native smoke test
# lives, it takes seconds, and it gives the screen and IPC tests some cross architecture coverage
Expand All @@ -159,12 +172,12 @@ jobs:
# targeting one now sits queued indefinitely. Both macOS RIDs ship the same universal
# dylib, so loading it here covers the arm64 slice and the step below checks that the
# x86_64 slice is present.
#
# No Windows entry either, since that head renders with WinForms and loads nothing.
- rid: osx-arm64
os: macos-14
- rid: linux-arm64
os: ubuntu-24.04-arm
- rid: win-arm64
os: windows-11-arm
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -189,7 +202,7 @@ jobs:
if: matrix.rid == 'osx-arm64'
run: |
for rid in osx-arm64 osx-x64; do
dylib="src/DiffEngineViewer/runtimes/$rid/native/libdiffengine_viewer.dylib"
dylib="src/DiffEngineViewer.Mac/runtimes/$rid/native/libdiffengine_viewer.dylib"
archs=$(lipo -archs "$dylib")
echo "$rid: $archs"
for arch in x86_64 arm64; do
Expand Down
42 changes: 31 additions & 11 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,30 +46,40 @@ DiffEngine is a library that manages launching and cleanup of diff tools for sna
- `ResolvedTool` - A diff tool that was found on the system with its resolved executable path.
- `BuildServerDetector` - Detects CI/build server environments to disable diff tool launching.

**DiffEngineViewer (`src/DiffEngineViewer/`):**
- Cross platform GUI diff tool: Dear ImGui rendered through raylib. Reviews inline snapshots and
plain two-file diffs.
**DiffEngineViewer (`src/DiffEngineViewer/` plus three heads):**
- Cross platform GUI diff tool. Reviews inline snapshots and plain two-file diffs.
- `src/DiffEngineViewer/` is a **library** (`DiffEngineViewer.Core.dll`) holding everything that is
not a renderer. `src/DiffEngineViewer.{Windows,Mac,Linux}/` are thin `Exe` heads, one package
each, all named `DiffEngineViewer` so the launcher can resolve the executable by name.
- One package per OS rather than one portable one, because WinForms must be named as a framework
dependency and such a package cannot start on macOS or Linux.
- Bundled inside DiffEngine.nupkg under `tools/viewer/{rid}/`, so inline snapshots work with no
extra install. Also shipped standalone as the `DiffEngineViewer` dotnet tool.
extra install. `DiffEngine.csproj` maps each RID to the head that renders on it.
- `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.
as text and each `IViewerWindow` draws as pixels. Every renderer consumes the identical
structure, which is what makes the text snapshots meaningful and keeps three renderers honest.
- `ViewerProgram.Run(args, OpenWindow)` owns the loop for all heads. A head is a `Main` that
chooses a renderer; nothing else about the app is per platform.
- Windows renders with **WinForms** and loads no native library. It is pumped through
`Application.DoEvents` rather than `Application.Run`, so the shared loop stays shared.
- macOS renders with **AppKit and Core Text** (`native/swift/`), Linux with **raylib and Dear
ImGui** (`native/`). Both implement the same C ABI, so the managed interop layer is identical.
- 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.
because DiffEngine publishes and embeds the heads 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/`):**
**Native shim (`native/`), used by the Mac and Linux heads only:**
- `raylib` and `imgui` are fetched by CMake (`FetchContent`), pinned by tag in
`native/CMakeLists.txt`. Deliberately not submodules: nothing in a normal `dotnet build` touches
this folder, so a recursive clone on every checkout would serve a path almost nobody takes.
- Building it needs CMake 3.24+, a C++17 compiler and network access. Contributors do not need
any of that, because the binaries are committed.
- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: ~12 exports
- `native/src/deview.cpp` is a renderer for the `Screen` model, not an ImGui binding: eight 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
`DEVIEW_VERSION` whenever the structs change **or a field changes meaning**.
- Built binaries are **committed** to `src/DiffEngineViewer.{Linux,Mac}/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.

Expand All @@ -82,6 +92,16 @@ DiffEngine is a library that manages launching and cleanup of diff tools for sna
every platform rather than a Windows-only copy that can drift.
- Allows accepting/discarding diffs from system tray

**Packaging.Tests (`src/Packaging.Tests/`):**
- Opens each `.nupkg` a Release build drops in `nugets` and snapshots its entry list, plus a few
invariants a snapshot states poorly: an apphost with no assembly beside it, a viewer file in the
tray package, an incomplete bundled head.
- Exists because package content is assembled by several unrelated MSBuild mechanisms and nothing
else asserts the result. The failure mode it was written for is stale build output: `PackAsTool`
packages the publish directory wholesale, and MSBuild never removes a file that stopped being
produced, so anything a discarded experiment left in `bin` keeps shipping.
- Windows only, and skipped entirely when no packages were produced, which is every Debug build.

### Adding a New Diff Tool

1. Add enum value to `DiffTool.cs`
Expand Down
4 changes: 2 additions & 2 deletions docs/diff-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,8 @@ 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
* Also available standalone as `DiffEngineViewer.Windows`, `.Mac` or `.Linux`
* Cross platform: WinForms on Windows, Dear ImGui through raylib elsewhere

#### Windows settings:

Expand Down
26 changes: 21 additions & 5 deletions docs/mdsource/viewer.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,34 @@ 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).
The renderer is native to each platform:

| Platform | Renderer |
| --- | --- |
| Windows | WinForms |
| macOS | AppKit and Core Text |
| Linux | [Dear ImGui](https://github.com/ocornut/imgui) through [raylib](https://github.com/raysan5/raylib) |

All three draw the same screen model, and the layout, scrolling and keyboard handling are shared,
so the only difference is how the pixels get there.


## NuGet

* https://www.nuget.org/packages/DiffEngineViewer
* https://www.nuget.org/packages/DiffEngineViewer.Windows
* https://www.nuget.org/packages/DiffEngineViewer.Mac
* https://www.nuget.org/packages/DiffEngineViewer.Linux

Only needed to use the viewer outside a project that references DiffEngine, since DiffEngine
already bundles it.

`dotnet tool install -g DiffEngineViewer`
```
dotnet tool install -g DiffEngineViewer.Windows
```

One package per operating system rather than one for all of them, because WinForms has to be named
as a framework dependency and a package that names it cannot start anywhere else. The copy bundled
in DiffEngine is unaffected: it is published per RID and resolved by directory.


## Usage
Expand Down Expand Up @@ -80,5 +96,5 @@ 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
platform with no matching build, resolution falls through to a globally installed
DiffEngineViewer tool, and then to whatever other diff tool is available.
Loading
Loading