This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
src/DiffEngine.slnx
# Build (from repo root). Also packs: ProjectDefaults sets GeneratePackageOnBuild in Release.
dotnet build src --configuration Release
# Run all tests, which is what CI runs
dotnet test --solution src/DiffEngine.slnx --configuration Release --no-build --no-restore
# Run one test project
dotnet test --project src/DiffEngine.Tests/DiffEngine.Tests.csproj --configuration Release --no-build --no-restore
# Run one class, then one test
dotnet test --project src/DiffEngine.Tests/DiffEngine.Tests.csproj --configuration Release --no-build --no-restore -- --treenode-filter "/*/*/ClassName/*"
dotnet test --project src/DiffEngine.Tests/DiffEngine.Tests.csproj --configuration Release --no-build --no-restore -- --treenode-filter "/*/*/ClassName/MethodName"
# Or run the test project directly, which is the fastest loop and takes the same filter
src/DiffEngine.Tests/bin/Debug/net10.0/DiffEngine.Tests.exe --treenode-filter "/*/*/ClassName/*"Test runner: TUnit runs on Microsoft.Testing.Platform rather than VSTest, which changes two things about the commands above. Filters are treenode paths given after --, as /Assembly/Namespace/Class/Test with * for any segment; VSTest's --filter "FullyQualifiedName~ClassName" matches nothing and exits 5, so a filtered run that reports no failures may have run no tests. And --nologo makes any run report "Zero tests ran" and exit 5, whatever else is on the command line, so leave it off.
SDK Requirements: .NET 10 SDK (see global.json, at the repository root rather than under src). It also carries "test": { "runner": "Microsoft.Testing.Platform" }, which is what puts dotnet test on the runner described above. The project uses preview/prerelease SDK features.
Target Frameworks:
- DiffEngine library: net462, net472, net48, net6.0, net7.0, net8.0, net9.0, net10.0 (Windows also includes .NET Framework targets)
- DiffEngineTray: net10.0 Windows Forms application
- Tests: net10.0 (net48 on Windows)
DiffEngine is a library that manages launching and cleanup of diff tools for snapshot/approval testing. It's used by ApprovalTests, Shouldly, and Verify.
Five parties, three transports. The library runs inside the test process (embedded in Verify and
the others); the tray and viewer are separate processes; the ReSharper/Rider plugin
(jetbrains-plugin-verify) embeds the
library inside the IDE. docs/inline.md is the durable, consumer-facing version of this map.
flowchart LR
subgraph Test["test process"]
Verify["Verify"] --> Engine["DiffEngine library"]
end
Tray["DiffEngineTray"]
Window["DiffEngineViewer window"]
Plugin["ReSharper / Rider plugin"]
Owner{{"inline queue owner: whoever bound 3493<br/>first — the tray at login, else a viewer"}}
Files[("source files and<br/>staged patch files")]
Engine -->|"3492 moves, deletes (one way),<br/>when a tray is running"| Tray
Engine -->|"3493 inline, settle, and diff,<br/>moves and deletes with no tray"| Owner
Engine -.->|"launch with patch on stdin, or with<br/>a delete or a pair, when nothing owns 3493"| Window
Tray <-->|"3493 list, accept, focus"| Owner
Window <-->|"3493 listfull, accept, discard"| Owner
Plugin -->|"3493 settle, after accepting"| Owner
Owner -->|"InlineApplier"| Files
Plugin -->|"InlineApplier"| Files
The failing-inline-snapshot flow: Verify builds an InlinePatch and calls
DiffRunner.AddInlineAsync. If something owns 3493 the patch goes over the socket and the owner
shows or focuses a window; if nothing does, the bundled viewer is launched with the patch on
stdin and binds the port itself; if no viewer resolves (or DiffEngine_InlineViewer=false),
Verify stages received/expected/.inlinepatch files and the IDE plugin or a text diff tool
becomes the review surface. Accepting anywhere runs InlineApplier against the source file
(per-file cross-process mutex — safe concurrently from any process). A passing re-run calls
SettleInline, and any surface that applies a patch itself must call SettleAppliedInline — not
SettleInline, whose framework label is the running process's own and so never matches an entry
some other process queued, missing silently — or the queue owner keeps offering a snapshot that is
already in the source.
The source may be C# or F#, decided by the file's extension (SourceLanguage.ForFile) rather than
stated on the patch. InlinePatcher walks the same structure either way — a name, an argument
list, a chain hung off it — and everything per language sits on SourceLanguage: the lexing that
fills a SourceScan, what tells a declaration from a call, and how a literal is written and read
back. F# is the awkward one, because it has no raw string: its compiler hands over a triple-quoted
literal verbatim, line break after the opening delimiter and every line's indentation included. So
the same shape C# writes is written for F# too and the trimming is a convention -
SourceLanguage.SnapshotValue is the reader's half, and a test library that skips it fails every
F# snapshot against itself. Writing content at the left margin instead was tried and abandoned:
F#'s offside rule then rejects anything ending in a newline. The agreement is asserted by compiling
patched source with dotnet fsi and applying the trim there in F# (FsCompilerRoundTripTests),
because a belief about F#'s lexis is exactly the kind of thing a second copy of the same belief
cannot check. With both languages on the same shapes, the rendering and most of the parsing is one
implementation in StringLiteral; what is left per language is delimiter widening, which F# lacks
(FS1232), and the escapes a regular literal carries.
A patch is anchored to the call by OriginalExpression, the argument's source text from
CallerArgumentExpression, so a file that moved since the run still patches the right call. F#
does not implement that attribute (FS0202), so a producer sends OriginalValue — the argument's
value — and the patcher matches on what a literal parses to instead of on what it says. Same
anchor, one parse apart. With neither, the hint is all there is and a differing literal is taken
as the snapshot that changed, or a snapshot could be accepted once and never updated.
MemberName (CallerMemberName, which F# does implement) narrows on top of either: a call above
that member's declaration is not in it, so an identical snapshot in the test next door is not a
candidate at all, while the recorded line is still tried first so two snapshots in one member stay
apart.
DiffEngine Library (src/DiffEngine/):
DiffRunner- Main entry point. Launches diff tools viaLaunch/LaunchAsyncmethods and kills them viaKill. Handles process lifecycle.DiffTools- Registry of available diff tools. Maintains lookups by extension and path. Initialized fromDefinitionsand ordered byOrderReader.Definitions- Static collection of all supported diff tool definitions. Each tool is defined inImplementation/folder.Definition- Record type describing a diff tool: executable paths, command arguments, supported extensions, OS support, MDI behavior, auto-refresh capability.DiffTool- Enum of all supported diff tools (BeyondCompare, P4Merge, VS Code, etc.)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/ 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 thinExeheads, one package each, all namedDiffEngineViewerso 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.DiffEngine.csprojmaps each RID to the head that renders on it. ViewerSessionis a pure state machine over an immutableSessionState.ScreenBuilderprojects that into aScreen(already sliced to the visible rows), whichAsciiRendererdraws as text and eachIViewerWindowdraws 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 aMainthat 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.DoEventsrather thanApplication.Run, so the shared loop stays shared. Only the grid is owner drawn: the footer, the context menu, the pane scrollbar and the tooltips are real controls, so they get the OS's keyboard handling, theming and screen reader support. The menu is still projected from the sameScreen.Menuthe other heads draw. - 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. - macOS took the same treatment as Windows: a real menu bar, an
NSMenucontext menu,NSViewtooltips and anNSScroller, withNSApp.appearanceset todarkAquaso they match the drawn grid. The cost is that none of them exists indeview_capture, which makes no window — hencePixelTests.ContextMenubeing skipped there, and the scroller taking its strip out of the renderer only when a window exists. - Linux draws its own menu, so it keeps that baseline. Its tooltip and pane scrollbar are ImGui's,
the scrollbar being
ScrollbarExdriven in rows rather than pixels so its travel is exactlyViewerSession's clamp. - Group headers fold.
SessionState.CollapsedholdsQueueItem.GroupKeys andQueueProjectionskips their members, so the marker rides in the label and no head or ABI field knows about it. Whether an entry is hidden is always read back out ofVisibleEntries, never recomputed — the rules about when a header exists at all live in one place and must stay there. A fold is a view:AcceptAllstill sweeps what it hides, whichCollapseTestspins. - Images (
Images/, extensions inDiffEngine/Viewer/ImageExtensions.cs, linked into the viewer so the tool registration and the renderer cannot disagree) are a side, not a mode.FileSide.Readdecides text or picture by extension, because the expected side of a new snapshot has no bytes to sniff, andImageRowsproduces the same alignedRowlistsDiffRowsdoes — one per property, coloured against the other side. So every head compares images today with no ABI change. Whether the two are the same file belongs to the pair rather than to a side, so it is the status line.Pane.Imageis an enrichment: all three heads paint the picture under those rows, each with its toolkit's own decoder (GDI+, ImageIO, raylib), so which formats draw is per platform while what the comparison says is not. Nothing about a comparison may become expressible only through the picture, or the text snapshots stop describing what a head without that decoder shows. All three fit fromImagePane.Width/Height— the file header's numbers, not the decoder's — one blank line under the pane's rows, so the placement rule lives once. Headers are sniffed by hand (ImageHeader) rather than by System.Drawing, which does not exist on macOS or Linux. - Queue tooltips are composed once in
QueueProjection, not per head, and are null when they would only repeat the row. Labels are already the shortest distinguishing form, so the tip is what the label left off — path, test, frameworks, failure text.QueueTooltipTestssnapshots the rule; the heads only display the string. - Does not reference DiffEngine. It links
Inline/*.cs,Protocol/*.csandTray/TrayDetector.csas source, because DiffEngine publishes and embeds the heads and a reference back would be a cycle. - Holds pending moves and deletes itself when it owns the queue, which is what happens with no tray installed, and every failing pair DiffEngine resolved it for, whether a tray is running or not.
TrackedWatchis what keeps those rows honest, and only runs for a queue this process owns. An owned queue is otherwise push only — a socket message or a launch argument puts an entry in it and nothing ever revisits it — so rows described the moment they arrived and nothing after.OwnerLink.ReadChangeshas always done the equivalent for a displayed queue, on the same 200ms cadence and the sameFileStamptest, which is why the two are worth reading together. A pass that finds nothing must return the identicalSessionState, or the open context menu closes five times a second. It stops short of the tray's third rule, dropping a pair whose two files became byte equal: that check exists because an external diff tool might have converged them, and here the viewer is the diff tool. They are ordinaryQueueEntryKind.Move/Deleteentries — the same ones an attached viewer draws for the tray's — so nothing about how they look or what their menu offers is per arrangement. Only who applies them differs:ViewerActions.MoveFile/DeleteFilehere, a forwarded key there.ViewerMode.File— two paths on the command line, one window, no port — is reached by nothing in DiffEngine any more, and is kept deliberately rather than left behind. It is the blocking one-pair-per-invocation shape agit difftoolstyle caller needs, where queue mode's second invocation forwards and exits and the caller races ahead; it is the only place accepting means copy rather than move, which is what two arbitrary files a person named deserve; andFixtures.File()is the "one entry, no queue chrome" state around thirty test call sites are built on, so collapsing it would re-approve every renderer, scroll and pixel snapshot with a pending column those tests are not about. It costs a handful ofifs inScreenBuilder,QueueProjectionandSettle. Do not delete it because it looks unreachable.- Single instance by socket bind on 3493 (
DiffEngine_ViewerPort): whoever binds owns the queue, and a process that fails to bind talks to the owner instead. A viewer that does not own one runs with--attach: it pollslistfull, derives every pane from the patches that come back, and forwards accept and discard rather than applying them.
The viewer protocol (src/DiffEngine/Protocol/):
ViewerVerb,ViewerMessage,ViewerResponse,ViewerPayload,ViewerClient,ViewerServer.- Lives in DiffEngine because all three processes speak it, and any of them can be the owner. It was previously written twice, once per side, with tests holding the halves together; one implementation removes the failure mode instead of detecting it.
- Plain text, every value base64, for the same reason
InlinePatchFileis: snapshot text contains quotes, braces and newlines, and theinlinebody carries anInlinePatchFilepayload verbatim. - Compiles for every DiffEngine target, so the socket calls carry
#ifbranches for the frameworks with no cancellation overloads.ViewerProtocolTestsruns on all of them.
Native shim (native/), used by the Mac and Linux heads only:
raylibandimguiare fetched by CMake (FetchContent), pinned by tag innative/CMakeLists.txt. Deliberately not submodules: nothing in a normaldotnet buildtouches 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.cppis a renderer for theScreenmodel, not an ImGui binding: eight exports taking one flat blittable frame description. The ABI isnative/include/deview.h; bumpDEVIEW_VERSIONwhenever the structs change or a field changes meaning. The managed side refuses a library whose version is not an exact match, so a bump and a binaries rebuild land together: changenative/, runbuild-native, merge the PR it opens. Between the two, thenativeCI job — the one that loads the committed binaries — reports the mismatch, which is the check working.- Built binaries are committed to
src/DiffEngineViewer.{Linux,Mac}/runtimes/{rid}/native/, so a plaindotnet buildproduces a shippable package and contributors never need CMake. Regenerate them with thebuild-nativeGitHub workflow, which opens a PR.
DiffEngineTray (src/DiffEngineTray/):
- Windows Forms tray application that handles pending file diffs
PiperServer- TCP server (localhost, 3492) receiving move/delete payloads from DiffEngine. Deliberately a second listener beside the viewer protocol, not debt: its format is frozen (every stable DiffEngine embeds PiperClient, pinned in test projects while the tray updates independently), and the ports answer different questions — 3492 "a tray is here", 3493 "the queue owner is here", which is sometimes a viewer. Merging them breaks the late-starting-tray case. Full rationale on the PiperServer class doc.Tracker- Manages pending file moves and deletes with concurrent dictionariesOwnedInlineHost/RemoteInlineHost- The tray binds 3493 at startup and holds the inline queue when it wins, which it usually does because it starts at login. A viewer that got there first keeps the queue for as long as it runs, and the tray drives it remotely instead. Decided once, never transferred, so handover is not something that has to work.- Either host runs the same
InlineQueuefrom DiffEngine, so the two cannot differ on what accepting or settling means. Owning it means accepting runs on a listener thread rather than on a render loop, which is whereInlineApplier's ten second mutex wait used to sit. - Which arrangement is live decides which process applies an accept, so both are pinned by
TrayViewerSyncTest— a real tray and a realSessionStateover a real socket, asserting that an accept, discard, sweep or settle from either surface leaves the other showing the same thing. It sits in DiffEngineTray.Tests because that is the only project that can reference both halves (the viewer aliased, since it links DiffEngine's sources and so declares the same type names). The wire carriesokand a message, not an apply status, soRemoteInlineHostdecides applied versus failed by re-reading the listing: an owner keeps a failed entry pending, and takingokat face value used to report it as accepted while the viewer was still showing it. - A viewer that owns the queue answers about the tray's snapshots and about its own pending
files. Moves and deletes go to the tray when one is running and to the queue owner when one is
not (
PendingFiles), because the alternative was that with no tray they went nowhere at all — the send was skipped and the file was pending in nothing. A tray that owns the queue answers those verbs too, routing them into the same tracked files the piper port fills, which is load bearing rather than defensive:DiffEngineTray.IsRunningis cached at type init, so a test process that started before the tray addresses the queue owner for the rest of its life. - A delete starts a viewer when nothing owns the queue; a move does not. A move already has a
window — the diff tool DiffRunner just launched for that pair — and a delete has no second file
to compare against, so no tool ever opens for it.
--delete <file>is the launch, on the command line rather than stdin because a path fits where snapshot content does not. - Unless that diff tool is the viewer, which is the
Diffverb and--diff <received> <target>. Then the premise above is false — there is no window for the pair yet — so it is tracked exactly as a move and a window is raised over the entry, andDiffRunnerskips the whole process per pair path: nothing to find already showing it, no window to replace, and no process for the tray to kill on accept.MaxInstancestill applies, but charged byViewerLaunchGaterather than byDiffRunner, and only on a viewer that has to be started: handing a pair to one already on screen opens no window and spends nothing, so the caller cannot be the one to ask.DiffRunner.KillsendsSettlefor the move key rather than killing anything, since the row is drawn in a window shared with every other pending pair. That is what makes ten failing image snapshots one window instead of ten, and it is only available to the viewer because no other tool can be told to drop one pair. - The catch that shape creates: every inline transition rebuilds its half of the queue from
InlineQueue, soViewerSession.Rebuildcarries the tracked entries across it. Without that, accepting one snapshot silently drops the files pending beside it.Syncis the one caller that must not, since it is replacing them with what the owner just reported. DebugReport/DebugForm- the menu's "Debug view": every field of every tracked move, delete and snapshot as text, plus the queued patches when this tray owns the queue. The report is a string so it can be copied into an issue and snapshot tested without rendering a window.- Allows accepting/discarding diffs from system tray
Packaging.Tests (src/Packaging.Tests/):
- Opens each
.nupkga Release build drops innugetsand 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:
PackAsToolpackages the publish directory wholesale, and MSBuild never removes a file that stopped being produced, so anything a discarded experiment left inbinkeeps shipping. - Windows only, and skipped entirely when no packages were produced, which is every Debug build.
- Add enum value to
DiffTool.cs - Create implementation in
src/DiffEngine/Implementation/following existing patterns (seeBeyondCompare.cs) - Register in
Definitions.cscollection - The
Definitionrecord specifies:- Executable name and search paths per OS (
OsSupport) - Argument builders for temp/target file positioning
- Binary file extensions supported
- Whether tool supports auto-refresh, is MDI, requires target file to exist
- Executable name and search paths per OS (
- Tool discovery uses wildcard path matching (
WildcardFileFinder) to find executables in common install locations - Tool order can be customized via
DiffEngine_ToolOrderenvironment variable DisabledCheckerrespectsDiffEngine_Disabledenv varTrayDisabledCheckerrespectsDiffEngine_TrayDisabledenv var, behindDiffRunner.TrayDisabled. Separate fromDisabledbecause tracking a pending move is separate from launching a tool: every exit ofInnerLaunch,Disabledincluded, still callsAddMove.PendingFiles.TrayAvailableis the single gate- Tests use TUnit and Verify for snapshot testing