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 247–251

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

WebviewOptions

types.ts, lines 253–256

FieldTypeMeaningSource
enableSearchbooleanWhether the search box is enabled.L278

CommitGraphState

types.ts, lines 261–291

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

Commit

types.ts, lines 20–71

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
chipsSplitChip[]Bookmark-style chips on the row.L51
iconButtonsIconButton[] (optional)Buttons shown when the row is hovered.L54
dblClickVSCodeCommand (optional)Command run when the row is double-clicked.L57
displayIdstringThe ID text to show. The comment points at "Commit.displayId in api.ts", a file not in this repository.L60
highlightedDisplayIdLennumberHow many leading characters of displayId to highlight, like jj's shortest unique prefix.L63
updateTimenumberLast-updated time in milliseconds since the epoch, shown as "time ago".L66
isMultiSelectedbooleanWhether the row is part of a multi-selection.L69
vscodeContext{[key: string]: unknown} (optional)Merged into the row's data-vscode-context attribute when its context menu opens.L75

CommitGraphOptions

types.ts, lines 123–174

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

RenderMode

types.ts, lines 180–188

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

CommitMetadataTextStyle

types.ts, lines 115–118

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.L123
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.L124

Callout

types.ts, lines 203–215

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

CalloutType

types.ts, lines 193–198

MemberValueMeaningSource
UNSTYLED'UNSTYLED'No styling.L213
INFO'INFO'Informational.L214
WARNING'WARNING'Warning.L215
ERROR'ERROR'Error.L216

TopBarButton

types.ts, lines 220–242

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

Chip

types.ts, lines 296–316

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

SplitChip

types.ts, lines 321–345

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

ChipColors

types.ts, lines 94–98

FieldTypeMeaningSource
backgroundTBackground colour.L102
foregroundTText colour.L103
borderTBorder colour.L104

IconButton

types.ts, lines 76–89

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

VSCodeCommand

types.ts, lines 103–110

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

File

types.ts, lines 350–352

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

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.