BPMN Diff internals
Overview
The BPMN diff view renders two side-by-side readonly BPMN canvases in place of VS Code's default text diff, with colour-coded element highlights driven by bpmn-js-differ. The interesting architecture lives in the extension host: routing each resolving CustomTextEditor into the viewer path when its URI belongs to an open diff, coordinating two independent webviews, and keeping their viewports and highlight cursors in sync.
See the user-facing BPMN Diff page for the UX description.
System overview
A diff session is two CustomTextEditor webviews that together render one diff tab. Sessions come from two origins:
scm— The host (VS Code or Theia) opened the diff via Source Control,git diff, a PR review, etc. One URI is typically the Git-provided ref (git:in VS Code,gitfs:in Theia's@theia/git), the otherfile:(working tree). The session is created lazily viaDiffSession.forScm(…): the first pane to resolve is stashed in a "pending SCM" slot keyed byuri.path; when the second pane resolves with the same path, both are promoted into aDiffSession("scm", …). The factory encapsulates the side-assignment rule (file:URI →after, otherwise resolution order).compare-files— the extension opened the diff via its own Explorer commands (bpmn-modeler.compareSelectedor the two-stepselectForCompare/compareWithSelected). Both URIs are known up front, so the session is pre-registered byBpmnDiffController.openCompareFilesDiff(which delegates toDiffPaneStore.registerCompareFiles(…), in turn callingDiffSession.forCompareFiles(…)) beforevscode.difffires, and a 30 s TTL sweeper in the store evicts it if no pane ever attaches.
ModelerEditorController.resolveCustomTextEditor runs its delegateResolve hook for each resolving pane; for the BPMN editor that hook (wired in composition/editorFeature.ts) asks BpmnDiffController.shouldResolveAsDiff(uri). That method runs an ordered decision tree: already-has-a-pane → no (the caller is a second resolve for a file already open elsewhere); pre-registered session → yes; Git-provided scheme (git: or gitfs:) → yes; label heuristic matches a diff tab → yes; otherwise → no.
Entry points
BpmnCompareController(extension host) — registers the three Explorer commands (selectForCompare,compareWithSelected,compareSelected) and dispatches each toBpmnDiffController.openCompareFilesDiff, which owns the session-register +vscode.diff+ tab-title construction.CompareSelectionStore(extension host) — ephemeral single-URI store that bridges the two-step flow. Toggles thebpmn-modeler.compareSelectionActivecontext key so "Compare with Selected" only appears in the menu when a pick is pending.BpmnDiffController(extension host) — VS Code-facing surface:shouldResolveAsDiff,resolveDiffPane,openCompareFilesDiff, SCM pairing, webview message routing, the compare-files-only swap-sides operation, and the language-setting subscription.BpmnDiffService(extension host, vscode-free) — runs the differ, broadcasts highlights, and forwards viewport/cursor/language sync messages.DiffPaneStore(extension host) — registry of every session, the per-URI lookup index, pending SCM panes, and the compare-files TTL timers.DiffSession(extension host) — domain object for one diff:origin, fixedbefore/afterURIs, attached panes, armed flag. Exposes two origin-specific factories (forCompareFiles,forScm) that each encapsulate their own side-assignment rule.ModelerEditorController(extension host) — the generic custom-editor host. ItsdelegateResolvehook (wired for BPMN incomposition/editorFeature.ts) branches a resolving pane between editable modeler and readonly viewer viaBpmnDiffController.shouldResolveAsDiff(uri).DiffMode(webview) — webview entry point for viewer mode, wires the viewer + legend + message handlers.
Key files
| File | Purpose |
|---|---|
apps/vscode-plugin/src/diff/controller/BpmnDiffController.ts | VS Code-facing surface: shouldResolveAsDiff, resolveDiffPane, openCompareFilesDiff (vscode.diff), SCM pairing, webview message routing, swap-sides, language-setting subscription. |
apps/vscode-plugin/src/diff/service/BpmnDiffService.ts | vscode-free diff content: differ runner, highlight broadcaster, viewport/cursor forwarder, language broadcast. |
apps/vscode-plugin/src/diff/infrastructure/DiffPaneStore.ts | Session registry: sessions, sessionByUri, pending SCM panes, compare-files TTL timers. |
apps/vscode-plugin/src/diff/infrastructure/WebviewPaneHandle.ts | Infra adapter wrapping a WebviewPanel + TextDocument into the abstract DiffPaneHandle. |
apps/vscode-plugin/src/diff/domain/DiffSession.ts | Domain object for one diff: origin, fixed before/after URIs, pane slots, armed flag. Also exports basenameOfUriString. |
apps/vscode-plugin/src/modeler/editor-session/ModelerEditorController.ts | Generic custom-editor host; its delegateResolve hook (wired for BPMN in composition/editorFeature.ts) branches between editable modeler and readonly viewer via BpmnDiffController.shouldResolveAsDiff. |
apps/vscode-plugin/src/diff/controller/BpmnCompareController.ts | Explorer commands (selectForCompare, compareWithSelected, compareSelected) and the shared openBpmnDiff dispatch. |
apps/vscode-plugin/src/diff/infrastructure/CompareSelectionStore.ts | In-memory store for the pending "Select for Compare" URI; toggles the bpmn-modeler.compareSelectionActive context key. |
apps/vscode-plugin/src/types/bpmn-js-differ.d.ts | Ambient shim for the untyped bpmn-js-differ package. |
apps/vscode-plugin/src/types/bpmn-moddle.d.ts | Ambient shim for bpmn-moddle (factory function, not a class). |
apps/bpmn-webview/src/app/diff/DiffMode.ts | Webview entry point for viewer mode — wires viewer + legend + message handlers. |
apps/bpmn-webview/src/app/diff/DiffViewer.ts | Thin wrapper over NavigatedViewer adding marker helpers and viewport sync guard. |
apps/bpmn-webview/src/app/diff/DiffLegend.ts | Floating chip with per-category counts and prev/next nav. |
apps/bpmn-webview/src/styles/diff.css | Marker colours, dashed stroke, legend chip layout (light + dark theme). |
apps/bpmn-webview/src/app/__fixtures__/mock-diff.ts | Dev-only fixture XMLs that feed the browser preview. |
libs/shared/src/lib/modeler.ts | Message types (BpmnViewerMode, DiffSide, DiffCounts, Viewport, Query/Command classes). |
Message protocol
All types are defined in libs/shared/src/lib/modeler.ts.
| Message | Direction | Payload |
|---|---|---|
BpmnFileQuery | host → webview | { content, engine, viewerMode: "modeler" | "viewer" } |
DiffReadyCommand | webview → host | {} — signals the pane has imported its XML |
ApplyDiffHighlightsQuery | host → webview | { side, added, removed, changed, layoutChanged, counts, navigationOrder, origin, paneFilename } |
ViewportChangedCommand | webview → host | { viewport: { x, y, width, height } } |
SyncViewportQuery | host → webview | { viewport } — applied to the partner pane |
CursorChangedCommand | webview → host | { index } — current position in the shared navigationOrder |
SyncCursorQuery | host → webview | { index } — applied to the partner pane via applyCursor(index, false) |
SwapCompareSidesCommand | webview → host | {} — user clicked the swap button on a compare-files legend |
Each pane receives only the ids that exist on its canvas: the before side sees removed / changed / layoutChanged, the after side sees added / changed / layoutChanged. The counts and navigationOrder fields are symmetric — they drive the dual legend and the cursor-sync stepper, so applyHighlights does not need a per-pane filter pass.
The origin and paneFilename fields are always sent, but the webview only renders the filename label and swap button when origin === "compare-files". SCM panes receive the same fields so the message shape stays uniform; they are simply ignored by the legend.
Interaction flow
Webview branching
apps/bpmn-webview/src/main.ts inspects the first BpmnFileQuery and branches on viewerMode:
viewerMode === "modeler"— the existingBpmnModelerbootstrapping runs unchanged.viewerMode === "viewer"— skips the modeler entirely and starts aDiffModeinstance. The body gets a.viewer-modeclass that hides the properties panel and panel resizer, so a bare canvas fills the viewport.
DiffMode owns a single DiffViewer (readonly NavigatedViewer wrapper) and a DiffLegend, and translates between webview DOM events and the message protocol.
Viewport sync
Each pane emits a ViewportChangedCommand (debounced 80 ms) on pan or zoom. The extension host forwards it to the partner pane as a SyncViewportQuery, which calls canvas.viewbox() on the partner. A suppression guard on the receiving side prevents the resulting canvas.viewbox.changed event from echoing back and creating a feedback loop.
Swap sides (compare-files only)
Compare-files diff panes render a swap button on the legend. When clicked, the webview posts SwapCompareSidesCommand to the host, which looks up the sending pane's session, confirms the origin is compare-files, disposes both webview panels, and re-invokes BpmnDiffController.openCompareFilesDiff with the two URIs reversed. Disposing the panels cascades through disposePane and cleans up the old session's index entries before the fresh session registers, so the two sessions never collide.
SCM panes never render the button — VS Code owns the tab title and the ref metadata there, and swapping git refs isn't an operation the extension owns.
Cursor sync
Each pane emits a CursorChangedCommand { index } after the user clicks Next/Prev. The host forwards it as a SyncCursorQuery { index }, which calls the receiving pane's internal applyCursor(index, false). The false flag suppresses re-emission — without it the two panes ping-pong indefinitely. No DOM-event guard is needed because the partner applies the cursor passively (focusElement / centerOnElement only); it never originates a CursorChangedCommand of its own from a sync.
Developer preview
Either pane of the diff UI can run in a plain browser — no Extension Development Host required. See Development → Preview the BPMN webview in a plain browser for the URL table. Highlights come from running the real bpmn-js-differ in the browser against two fixture XMLs, so the preview stays honest even when the differ is upgraded.
Gotchas
- Editor id is the full URI string, not just the path. Git-provided URIs (
git:in VS Code,gitfs:in Theia) andfile:URIs for the same file produce different editor ids, so theEditorSessionStorecan hold both panes side by side without collision. - Dual-host Git scheme handling.
shouldResolveAsDiffrecognises both VS Code'sgit:and Theia'sgitfs:schemes via theGIT_PROVIDED_SCHEMESset inBpmnDiffController.ts. Add new schemes there if porting to another host; do not branch on a single hardcoded scheme. - SCM pairing is keyed by
uri.path, not by the full URI. That is the only thinggit:foo.bpmn/gitfs:foo.bpmnandfile:foo.bpmnshare. Pairing flows throughscmPairingKey(uri)so future hosts that bake ref metadata into the path (e.g./foo.bpmn@HEAD) can normalize in one place. The two paths of acompare-filessession deliberately differ, which is why that origin must be pre-registered — lazy pairing would never match them. shouldResolveAsDiffshort-circuits when a pane already exists for the URI. Without this, opening a working-tree file in a normal editor tab while the SCM diff is still open would trigger a second resolve, and the label heuristic would route it to the diff path too.compare-filessessions carry a TTL (COMPARE_FILES_TTL_MS, 30 s). If the caller invokesvscode.diffand the tab never opens (VS Code swallowed the error, the user cancelled a dialog, etc.) the sweeper evicts the session so the same pair can be registered again cleanly.- Sessions are armed only when both panes signal ready. The differ runs exactly once per session; subsequent document edits from Git (e.g. checkout of another ref) retire and re-register the session.
bpmn-moddleis loaded via dynamicimport(). This keeps it in its own webpack chunk so the extension host doesn't pay the parse cost until a diff actually opens. The package's default export is a factory function (not a class) — it must be called withoutnew.- Sequence flows receive the same stroke colour on their path and arrowhead via paired CSS selectors. If you add a new category, update both selectors in
apps/bpmn-webview/src/styles/diff.css.
Related
- bpmn-js-differ — upstream differ library
- demo.bpmn.io/diff — reference UI
- Development → browser preview
- Architecture overview — webview branching and message contracts