jj-dojo / The graph webview protocol

The graph webview protocol

The contract between the extension host and the commit graph webview: two RPC interfaces, a handshake over postMessage, and the state types the host sends across.

On this page
  1. 1. The two interfaces
  2. ExtensionShape: implemented by the host, called by the webview
  3. WebviewShape: implemented by the webview, called by the host
  4. 2. The transport
  5. 3. The handshake
  6. 4. The host side
  7. Multi-selection, not wired yet
  8. 5. The webview side
  9. 6. The state types
  10. WebviewState
  11. WebviewOptions
  12. CommitGraphState
  13. Commit
  14. CommitGraphOptions
  15. RenderMode
  16. CommitMetadataTextStyle
  17. Callout
  18. CalloutType
  19. TopBarButton
  20. Chip
  21. SplitChip
  22. ChipColors
  23. IconButton
  24. VSCodeCommand
  25. File
  26. Types that never cross the wire

In short: the host and the webview each wrap postMessage in a small RPC layer adapted from VS Code's own. Each side gets a proxy for the other side's interface, so calling webviewApi.$setState(state) on the host becomes a JSON message, a call to the webview's implementation, and a response matched back by ID. Before any call goes out, both sides repeat a handshake until each knows the other is listening. The webview announces itself with $webviewReady, and the host answers with a WebviewState. At this pin that state is a single hard-coded commit.

1. The two interfaces

ExtensionShape: implemented by the host, called by the webview

MethodArgumentsCalled whenHost behaviour at this pin
$webviewReadynoneThe webview module boots (source).Sends the fake state with $setState.
$executeCommandrepoName, command, args?Any button, chip, double-click, drag-and-drop drop or focus-mode link that carries a VSCodeCommand.No-op.
$informNewOrderrepoName, OrderAfter every layout, from JjApp.setState.No-op.
$informContextMenuWillOpenrepoName, hash?Just before a row's context menu opens, so the host can enable or disable items. No hash means the working copy.No-op.
$onClickrepoName, hash, metaKey, shiftKeyA row is clicked.No-op.
$clearMultiSelectionrepoNameThe multi-selection is cleared from the top bar or the app.No-op.
$dismissCalloutdismissIdA dismissible callout is dismissed.No-op.

Order carries the layout's results back to the host: childrenOrder (a ChildrenOrder {parentHash, childrenHashes} per commit, in the order drawn), topToBottomOrder (all hashes as displayed), and wcCommitIndex (the working copy's position in that list, or -1). That lets keyboard navigation or "next/previous commit" commands in the host follow what the user sees.

WebviewShape: implemented by the webview, called by the host

MethodArgumentsWebview behaviour
$setStateWebviewStateJjApp.setState: focus mode, layout, $informNewOrder, re-render. It resolves on the next animation frame, so the host's call completes once the new state has rendered.
$openSearchBoxnoneOpens the search box. Nothing in the host calls it at this pin.

Every method name starts with $. That's how the RPC proxy recognises remote methods: a property starting with anything else reads as undefined.

2. The transport

The RPC layer lives in third_party/vscode/rpc_protocol/, adapted from VS Code (MIT licensed). Both sides speak to it through a two-method Channel: postMessage(message) and onMessage(callback) → Disposable.

MessageShapeSource
Request{rpcProtocolId: 'jj-dojo-rpc-protocol-request', data: {name, args, id}}rpc_protocol_utils.ts
Response{rpcProtocolId: 'jj-dojo-rpc-protocol-response', data: {id, response?, err?}}rpc_protocol_utils.ts
Handshake{rpcProtocolId: 'jj-dojo-rpc-protocol-handshake', received: boolean}rpc_protocol_utils.ts

Messages with any other rpcProtocolId are ignored, because "We may not be the only ones using the channel" (source). Both directions use the same code: the host calls getWebviewApi and the webview calls getExtensionApi. Both are thin wrappers over getApi, which:

  1. creates a sender: a Proxy whose $ methods post a request with an incrementing numeric id and return a promise stored in a pending map (source);
  2. calls the caller's callback with that proxy to get the local implementation;
  3. creates a receiver that answers each request by calling impl[name](...args), awaiting it, and posting a response with the same id (source);
  4. starts the handshake, and returns the proxy immediately. Every proxy call first awaits a "blocker" promise that only resolves once the handshake finishes (source).

Pairing. Each side numbers its own requests independently, and a response is matched only against the sender's own pending map, so the two directions never collide. Calls can overlap, and responses may arrive in any order.

Errors. If the implementation throws, the receiver copies message, name and stack into a plain object, because methods don't survive the boundary, and sends it as err. Non-Error throws become {name: 'UNKNOWN_ERROR'} with a stringified message. An unknown method name produces No such function: <name>. On the calling side the code builds a fresh Error from err but then rejects with the original plain object (source), so callers receive an object that has message and name but isn't an Error instance.

Disposal. Disposing the returned disposable removes the listeners and rejects every pending call with RPC channel disposed.

3. The handshake

Webviews take time to load, and a message posted before the other side is listening is lost. shakeHands solves this symmetrically. Each side posts {received: weHaveReceived} every 100 ms. The first handshake message it gets sets weHaveReceived. Once a message arrives saying the other side has received ours (received: true), it resolves, posts one final message so the other side can finish too, and stops. Then the blocker opens and queued calls go out.

Timeout. After 300 tries, which is 30 seconds, it rejects with Handshake timed out after 30s. Nothing catches that rejection, since getApi only attaches a .then, and the blocker never opens, so every later call on that side hangs forever. The webview logs JJ Dojo commit graph started on boot, "temporarily to debug handshake time out errors" (source).

Startup sequence between the extension host and the commit graph webview The host resolves the webview view and starts posting handshake messages saying it has not yet received anything. Once the webview module runs, it starts its own handshake and queues a call to $webviewReady. Each side marks that it has received a handshake and replies with received true; when each sees the other's received true, its blocker opens. The webview's queued $webviewReady request goes out with id 1. The host answers by calling $setState with id 1 from its own counter. While rendering, the webview calls $informNewOrder with id 2, which the host acknowledges. The webview then responds to $setState after the next animation frame, and finally the host responds to $webviewReady. extension host commit graph webview resolveWebviewView: set HTML, getWebviewApi(channel, …)handshake {received: false} (every 100 ms, lost until the webview listens)webview_module main(): getExtensionApi(channel, …)callback calls $webviewReady() → queued behind the blockerhandshake {received: false}weHaveReceived = truehandshake {received: true}theyHaveReceived → resolve; blocker openshandshake {received: true} (final message)theyHaveReceived → resolve; blocker opensrequest {name: '$webviewReady', id: 1}ExtensionShapeImpl.$webviewReady()request {name: '$setState', args: [WebviewState], id: 1}JjApp.setState: focus mode, layout, renderrequest {name: '$informNewOrder', id: 2}response {id: 2} (no-op)response {id: 1} (after next animation frame)response {id: 1} ($webviewReady done)

Request IDs are per direction: the webview's $webviewReady and the host's $setState are both id: 1 without colliding, because each side only matches responses against its own pending map.

4. The host side

JjUi registers CommitGraphViewProvider for the view type jj-dojo.commit-graph with retainContextWhenHidden: true, so the webview, and its RPC session, survive the view being hidden (source). When VS Code shows the view, resolveWebviewView:

  1. sets enableScripts: true and limits localResourceRoots to the extension's own directory;
  2. writes the HTML (getHtmlForWebview): the codicon stylesheet, _jj_graph_base_styles.css, the bundled webview_module.bundle.js, and a body containing <jj-context-menu-provider> and <jj-app id="jj-app">;
  3. builds a Channel from webview.postMessage and webview.onDidReceiveMessage;
  4. calls getWebviewApi with a callback that returns a new ExtensionShapeImpl wrapping the webview proxy;
  5. disposes the RPC session when the view is disposed (source).

ExtensionShapeImpl is the only host implementation. A TODO in its header says its contents "should be replaced by the Google internal code". Only $webviewReady does anything: it sends createFakeWebviewState(), marked // TODO - Replace with a real implementation. That state is one graph named jj-dojo with a single empty working-copy commit, change IDs shown, one-line rendering, and search disabled. Every other method is an empty async function.

Multi-selection, not wired yet

MultiSelectManager sits next to the provider and holds the set of selected commits in a HashSet. Upstream says it came from Google's internal code with minimal changes. Nothing outside its test constructs it at this pin, so the host still ignores $onClick.

  • setOrder(ids) stores the graph's top-to-bottom row order.
  • onClick(id, metaKey, shiftKey): a plain click selects just that commit; a meta-click toggles it. Both remember it as the last clicked. A shift-click adds every commit between the last clicked and this one, inclusive and in either direction, and keeps the anchor; when there is no order or either commit isn't in it, the shift key is ignored (source).
  • evictMissing(ids) drops selected commits that are no longer in the repository, and forgets the anchor if it went too (source).
  • getOnlySelected() returns the selection when it is exactly one commit.

Its eight tests cover each of those paths, including matching distinct ID objects by their string (source).

5. The webview side

webview_module.ts is the webview's entry point. It imports the JjApp component, gets VS Code's webview API with acquireVsCodeApi(), and wraps vscode.postMessage and the window's message events in a Channel. Its getExtensionApi callback hands the host proxy to <jj-app>, fires $webviewReady, and returns the WebviewShape implementation, which forwards $setState and $openSearchBox to the app. The returned disposable is deliberately not kept: the comment says resources are released when the webview itself is disposed.

From there the components call the host through extensionApi, mostly with $executeCommand. See the graph webview UI.

6. The state types

Everything the host sends lives in api/types.ts. All of it must survive structured cloning through postMessage, so it is plain data: strings, numbers, booleans, arrays and objects, with commands named by ID rather than passed as functions. The tables start at the root, WebviewState.

WebviewState

types.ts, lines 276–280

FieldTypeMeaningSource
statesCommitGraphState[]One graph per repository.L277
snapshotIdnumber (optional)Declared but not read anywhere in the extension at this pin.L278
optionsWebviewOptionsWebview-wide options.L279

WebviewOptions

types.ts, lines 282–285

FieldTypeMeaningSource
enableSearchbooleanWhether the search box is enabled.L284

CommitGraphState

types.ts, lines 290–330

FieldTypeMeaningSource
repoNamestring (optional)Repository name. If absent, no name is shown. Passed back on most ExtensionShape calls.L295
calloutsCallout[]Messages shown above the graph.L298
commitsCommit[]The commits to lay out.L300
topBarButtonsTopBarButton[]Empty: no top bar. One: a full-width button. Several: fixed-width split buttons.L309
optionsCommitGraphOptionsPer-graph options (above).L311
multiSelectionModeboolean (optional)Internal, set by the webview. "The extension should ignore this field."L317
unfocusedCommitsnumber (optional)Internal, set by the webview: how many commits focus mode hid.L323
timeAgoTitlestring (optional)Replaces the default "Submitted" text in the base commit row.L329

Commit

types.ts, lines 21–82

FieldTypeMeaningSource
hashstringUnique identifier. Every map in the layout is keyed by it.L23
childrenHashesreadonly string[]Children in preferred display order. Every child must be in the same commits array, or layout throws.L33
shortDescriptionstringFirst line, shown as the row title. Empty means "no description set".L35
fullDescriptionstringWhole message, shown as the row's tooltip.L37
isImmutableboolean (optional)Drawn with a diamond glyph, and bounds focus mode.L40
activeboolean (optional)Marks the working-copy commit (@ glyph).L42
isEmptybooleanDrives the (empty) tag.L45
hasConflictboolean (optional)Drawn with a cross glyph in the error colour, plus a conflict tag.L47
hasDivergedboolean (optional)Adds a divergent tag.L49
isElidedRevisionsboolean (optional)Marks a row that stands for hidden revisions. It is drawn with a ~ glyph instead of a circle, its title shows the description in italics with no metadata, and it can't be dragged, dropped on, or inserted next to.L55
chipsSplitChip[]Bookmark-style chips on the row.L57
iconButtonsIconButton[] (optional)Buttons shown when the row is hovered.L60
dblClickVSCodeCommand (optional)Command run when the row is double-clicked.L63
displayIdstringThe ID text to show. The comment points at "Commit.displayId in api.ts", a file not in this repository.L66
highlightedDisplayIdLennumberHow many leading characters of displayId to highlight, like jj's shortest unique prefix.L69
updateTimenumberLast-updated time in milliseconds since the epoch, shown as "time ago".L72
isMultiSelectedbooleanWhether the row is part of a multi-selection.L75
vscodeContext{[key: string]: unknown} (optional)Merged into the row's data-vscode-context attribute when its context menu opens.L81

CommitGraphOptions

types.ts, lines 136–199

FieldTypeMeaningSource
showChangeIdbooleanWhether to show each commit's display ID.L138
dragAndDropCommands{…} (optional)If set, commits can be dragged and dropped.L141
dragAndDropCommands.rebaseVSCodeCommandRun on dropping onto a commit, with [sourceIds…] and the destination ID appended.L149
dragAndDropCommands.abandonVSCodeCommandRun on dropping onto the garbage section, with every selected commit's ID appended.L155
dragAndDropCommands.insertVSCodeCommandRun on dropping onto a line between commits, with {target, insertBefore, insertAfter} appended.L169
alwaysShowActionsbooleanShow row buttons all the time instead of on hover (default false).L176
uiScalingnumber (optional)UI scale factor, meant to be cssOverrides.fontSizeScale. Default 1.L183
disableFocusModeVSCodeCommand (optional)Turns focus mode on, and is the command behind its "Disable focus mode" link (see focus mode).L190
showContextMenuIconboolean (optional)Show a context-menu icon on each row.L193
commitMetadataTextStyleCommitMetadataTextStyle (optional)Which metadata tags to show (above).L196
renderModeRenderModeOne-line, two-line or automatic rows.L198

RenderMode

types.ts, lines 205–213

MemberValueMeaningSource
ONE_LINEordinalEvery row is one line.L206
TWO_LINEordinalEvery row is two lines.L207
AUTOordinalOne or two lines "depending on the width of the panel".L212

CommitMetadataTextStyle

types.ts, lines 128–131

MemberValueMeaningSource
SHOW_EMPTY_AND_NO_DESCRIPTION_SET'show empty/no description set'Tags (empty) and (no description set) independently. Also the behaviour when the option is unset.L129
SHOW_EMPTY_AND_HAS_CHANGES'show empty/has changes'Tags (empty), or (has changes) for a non-empty commit without a description, and nothing when both apply.L130

Callout

types.ts, lines 228–242

FieldTypeMeaningSource
typeCalloutTypeStyle.L229
messagestringText, which "can contain HTML, e.g. links".L231
dismissIdstring (optional)If set, the callout gets a dismiss control, and this ID is sent back with $dismissCallout.L236
dismissStyle'button' | 'link' (optional)button (default) or link.L241

CalloutType

types.ts, lines 218–223

MemberValueMeaningSource
UNSTYLED'UNSTYLED'No styling.L219
INFO'INFO'Informational.L220
WARNING'WARNING'Warning.L221
ERROR'ERROR'Error.L222

TopBarButton

types.ts, lines 247–271

FieldTypeMeaningSource
left{…}The main button.L248
left.commandVSCodeCommandCommand to run on click.L250
left.disabledbooleanDisabled state.L252
right{…} (optional)Optional split half. If absent, a plain button is drawn.L255
right.originstringOpens a context menu with origin set to this value, so the extension's package.json menus can match on it.L267
right.disabledbooleanDisabled state.L269

Chip

types.ts, lines 335–357

FieldTypeMeaningSource
textstring (optional)Label.L337
textOnHoverstring (optional)Label while hovered (defaults to text).L339
searchTextstring (optional)Label while the search box is open (defaults to text).L341
tooltipstring (optional)Hover text.L342
linkstring (optional)URL to open on click. Ignored if command is set.L343
underlineOnHoverboolean (optional)Underline the label on hover.L344
commandVSCodeCommand (optional)Command to run on click.L349
colorChipColors<string>Colours.L350
codiconBeforestring (optional)Codicon before the label, e.g. codicon-comment.L352
codiconAfterstring (optional)Codicon after the label.L354
minWidthnumber (optional)Minimum width.L356

SplitChip

types.ts, lines 362–390

FieldTypeMeaningSource
leftChipThe main chip.L363
dragAndDropCommands{…} (optional)If set, the chip can be dragged.L365
dragAndDropCommands.abandonVSCodeCommandRun when dropped on the garbage section.L367
dragAndDropCommands.moveVSCodeCommandRun when dropped on another commit, with that commit's hash appended.L373
right{…} (optional)Optional second half.L376
right.chipChipThe right-hand chip.L377
right.borderColorstringBorder colour for the whole split chip, overriding both halves' borders.L382
vscodeContext{[key: string]: unknown} (optional)Merged into data-vscode-context for the chip's context menu. If absent, no menu opens.L389

ChipColors

types.ts, lines 107–111

FieldTypeMeaningSource
backgroundTBackground colour.L108
foregroundTText colour.L109
borderTBorder colour.L110

IconButton

types.ts, lines 87–102

FieldTypeMeaningSource
commandVSCodeCommandCommand to run. Its title is ignored.L98
iconstringCodicon name, e.g. split-vertical.L101

VSCodeCommand

types.ts, lines 116–123

FieldTypeMeaningSource
titlestring (optional)Display title, e.g. New commit.L118
commandstringCommand ID, e.g. jj.new.L120
tooltipstring (optional)Hover text.L121
argumentsunknown[] (optional)Arguments passed to the command. Several call sites append extra arguments.L122

File

types.ts, lines 395–397

FieldTypeMeaningSource
pathstringPath. The type is exported but not used anywhere at this pin.L396

Types that never cross the wire

The same file also defines the layout's own types. The webview builds them from the Commits it receives, and the host never sees them. They're covered on commit graph layout.