jj-dojo / The graph webview UI

The graph webview UI

The Lit components that draw the commit graph inside its webview: the app shell, one graph per repository, rows with their chips and glyphs, the top bar, search, drag and drop and context menus.

On this page
  1. 1. The page and the app shell
  2. 2. Graphs and rows
  3. The display ID
  4. Chips
  5. 3. Top bar, callouts and hidden commits
  6. 4. Search
  7. 5. Drag and drop
  8. Targets
  9. State and subscribers
  10. What a drop does
  11. 6. Context menus
  12. 7. The resize controller
  13. 8. Untrusted text
  14. 9. Styles and theme colours
  15. 10. Helpers and tests

In short: the webview page holds two custom elements, a hidden jj-context-menu-provider and jj-app. Each time the extension sends a state, jj-app lays out the commits (see graph layout) and renders a search box plus one jj-commit-graph per repository. A graph is a repository name, a top bar of buttons, callouts, one jj-commit-row per commit and a line about hidden commits. A row is a display ID, the tiles of the graph on its left, and on its right the chips, title and action buttons. Every user action becomes a call on the extension API (see graph protocol). The webview never runs jj itself.

Component tree of the commit graph webview The webview HTML creates jj-context-menu-provider and jj-app. jj-app renders jj-search-box and one jj-commit-graph per repository state. jj-commit-graph renders jj-top-bar, jj-callout, one jj-commit-row per commit and jj-focus-mode-text. While a drag is in progress the top bar shows jj-garbage-section instead of its buttons. jj-commit-row renders jj-commit-row-display-id, jj-commit-row-left-side with its tile groups, and a drag-and-drop publisher around jj-commit-row-right-side, which holds jj-commit-row-title with the chip group. webview HTML getHtmlForWebview jj-context-menu-provider hidden button, light DOM jj-app setState, render mode jj-search-box Cmd/Ctrl+F jj-commit-graph one per repository jj-top-bar buttons, sticky jj-callout error, warning, info jj-commit-row one per commit jj-focus-mode-text "N other commits" jj-garbage-section only while dragging …-display-id change ID prefix …-left-side tiles, glyphs, lines …-right-side in a d&d publisher …-title chip group, text

1. The page and the app shell

getHtmlForWebview writes a small page. It loads codicon.css, _jj_graph_base_styles.css and the module webview_module.bundle.js, and its body holds just <jj-context-menu-provider> and <jj-app id="jj-app">. The page sets no Content-Security-Policy meta tag of its own. The bundle's entry point, which connects jj-app to the extension, is covered under graph protocol.

jj-app extends the resize controller and imports the @vscode-elements/elements widgets used across the tree.

  • setState runs once per message. For each repository state it keeps only the focused commits when focus mode is on (the options carry a disableFocusMode command exactly then, per its documentation), lays them out with createCommitNodes, sorts the nodes top to bottom and tells the extension the resulting order with $informNewOrder (children of each parent, top-to-bottom hashes, the working-copy index). It sets multiSelectionMode when more than one node is multi-selected and unfocusedCommits to the number hidden. It resolves on the next animation frame, so the caller knows the DOM has caught up.
  • render draws the search box (only when the webview options enable search) and one jj-commit-graph per state, sized to the controller's renderedWidth. It swallows contextmenu events, because otherwise VS Code shows its default Cut/Copy/Paste menu, which does nothing here.
  • calculateRenderMode turns the AUTO render mode into one line per commit when the view is wider than 400 px and two lines otherwise. A click anywhere on the app clears the multi-selection (source).

2. Graphs and rows

jj-commit-graph renders the repository name, a top bar (only if the state has topBarButtons), the callouts, the rows and jj-focus-mode-text. The rows sit in a relatively positioned wrapper that clips horizontally, so a very wide graph does not add a horizontal scroll bar, but lets content overflow vertically, so the rebase hint above the top row is no longer cut off by the top bar (source).

jj-commit-row is one commit. In one-line mode it renders the display ID, the left side and the right side on a single 24 px line. In two-line mode it renders two lines: the first has the left side, the display ID and the chips, the second the left side again and the title (source). Selection shows as attributes: the working copy (active) uses the inactive-selection background, multi-selected rows the active-selection colours, and a drop destination --vscode-list-dropBackground.

  • A click calls $onClick(repo, hash, metaOrCtrl, shift), unless the event was already defaultPrevented by a chip link (see chips). A double click without Cmd/Ctrl runs the node's dblClick command, if it has one (source).
  • The left side is drawn by tiles, glyphs and lines, which belong to the layout and are described in graph layout: the rendering components and where a line actually appears.
  • jj-commit-row-right-side holds the title and the action buttons: one icon per node.buttons entry and, if showContextMenuIcon is set, a "More" kebab that opens the row's context menu. The buttons hide until the row is hovered unless alwaysShowActions is set. The whole right side is wrapped in a drag-and-drop publisher for the commit.
  • jj-commit-row-title shows the chip group, then "conflict" and "divergent" in the error colour, then metadata text, then the short description. The metadata follows commitMetadataTextStyle: either "(empty)" and "(no description set)", or "(empty)" and "(has changes)" without the redundant combination (source). Immutable commits show a jj-time-ago-text instead of a description: timeAgoTitle (or "Submitted") followed by the commit's age, refreshed every minute by a timer.

The display ID

jj-commit-row-display-id renders the change ID in a monospace column of fixed width: maxChars × 7.5 × uiScaling + 4 px, the longest ID in the graph, or 0 when showChangeId is off (source). The first highlightedDisplayIdLen characters are bold in --commit-row-highlighted-text, the rest plain, and anything after a / bold again; divergent IDs use the error colour (source). Because the ID is split across several text nodes, the element supplies its own search matches (source).

Chips

jj-commit-row-chip-group renders one draggable split chip per node.chips entry. While a chip from another row is dragged over this row, it also shows a preview copy of that chip (source). The chip being dragged fades to opacity 0.3 in its own row (source).

  • A split chip has a left half and an optional right half, each with its own colours, codicons and command or link. Right-clicking a chip with a vscodeContext opens the chip's own context menu and stops the row's. The split chip tracks hover over the whole element and passes it down to both halves, so hovering either half counts (source).
  • A link chip is a real <a>. Middle click is turned into Cmd/Ctrl+click. A plain click calls preventDefault rather than stopPropagation: VS Code has to see the click to open the link safely, and if it can't, the webview navigates and is destroyed by its frame policy. The row then ignores the prevented click (source).
  • While the search box is open a chip shows its searchText, if it has one, so that hidden text can be found; otherwise, while hovered, it shows textOnHover if it has one (source). A chip with no text to show and no codicons renders nothing (source).

3. Top bar, callouts and hidden commits

jj-top-bar is a sticky 35 px bar that gains a shadow once the page scrolls. Clicking its background clears the multi-selection. It renders one vscode-button per topBarButtons entry; an entry with a right half becomes a split button whose chevron opens a context menu with origin right.origin and the primary command (source). While anything is being dragged the buttons are replaced by the garbage section.

jj-garbage-section is a dashed box with a trash icon and a hint, "Drop here to abandon" or, while hovered, "Abandon", each with the number of commits when more than one is dragged. Its colours come from the --jj-garbageSection-* variables (styles).

jj-callout shows a message styled as error, warning or info with VS Code's input-validation colours. A callout with a dismissId offers "Don't show this again" or "Dismiss", which call $dismissCallout. The message is the only HTML the webview renders from a string (see untrusted text).

jj-focus-mode-text renders only while focus mode hides commits: a "Disable focus mode" link that runs the state's disableFocusMode command, then "to see the other N commits in this workspace."

Search is entirely in the webview: it highlights matches in the rendered DOM and never asks the extension.

PieceWhat it does
jj-search-box A fixed box at the top right with a "Find..." field, a result count ("No results" or "i of n") and up, down and close buttons. A document key listener (source) opens it on Cmd+F (Mac) or Ctrl+F and stops the browser's own find. If it is already open the text is selected. Enter and Shift+Enter step forwards and backwards, wrapping at the ends. Escape closes it and stops the event so the app does not also clear the multi-selection. isVisible is a reflected property, and while it is false the host element itself is display: none (source).
SearchBoxStateManager A global singleton holding isOpen, with subscribe and unsubscribe that throw on a duplicate or unknown callback. Chips subscribe so they can switch to their search text.
highlight Clears CSS.highlights for an empty query. Otherwise walks the tree under #jj-app and registers every match as the CSS custom highlight search-matches.
findMatchesRecursively The walk. An element that implements getMatches answers for itself (the display ID does; the search box answers with none). Otherwise it descends into shadow roots, into the nodes assigned to a <slot>, and into children, skipping comments.
calculateRanges Case-insensitive, non-overlapping matches over a run of text nodes, so a match can span more than one node.
scrollRangeIntoView Marks the current match as search-current-match and scrolls it into view instantly, leaving room for the sticky top bar (35 + 8 px) above and 16 px below.

5. Drag and drop

Dragging is how the graph rebases, moves bookmarks, inserts and abandons. Three pieces cooperate through one global DragAndDropStateManager.

Drag and drop flow A jj-drag-and-drop-publisher wraps each drag source and drop target and reports dragstart, dragenter, dragleave and dragend to the DragAndDropStateManager. The manager keeps the dragged target and a stack of hovered targets and informs its subscribers: commit rows, chip groups, glyph tiles and the top bar re-render as sources, destinations or hints. On dragend the publisher's handleDragEnd turns the dragged and hovered targets into one $executeCommand call to the extension. drag-and-drop-publisher dragstart, enter, leave, end DragAndDropStateManager dragged, hovered stack subscribers rows, chips, tiles, top bar handleDragEnd target pair → command $executeCommand extension runs jj

Targets

A Target is one of four things: a commit, a chip on a commit, an insert action (an edge of the graph, from the layout's tiles) or the garbageSection. isDraggable allows a drag only when the state supplies dragAndDropCommands: never from an insert action or the garbage section, from a chip only if that chip has its own commands, never from an immutable commit, and in multi-selection mode only from a selected commit.

State and subscribers

  • The manager keeps the dragged target and a stack of hovered targets with enter and leave counts, because nested elements fire several dragenter/dragleave pairs. It ignores entering the dragged target itself, and entering one multi-selected commit while dragging another. dragover always calls preventDefault so drops are allowed. On macOS dragstart sets effectAllowed = 'move' to hide the green plus cursor; this is skipped on Linux, where it flickers (source).
  • isNoopInsert suppresses hints for drops that would change nothing: a chip over its own commit, an insert before an immutable commit, or an insert whose either end is the dragged commit.
  • JjDragAndDropSubscriber gives an element isDragSource and isDragDestination for its own target (a hovered chip counts as its commit). JjDragAndDropAllTargetsSubscriber exposes the raw dragged and hovered targets instead.
  • The publisher sets the drag image: a 0.7-opacity clone of a dragged chip, or a blank image for a commit (source). It reports dragleave 10 ms late because Linux delivers enter and leave out of order.

What a drop does

DraggedDropped onCommand call
chipanother commit (or a chip on it)the chip's move command with the destination hash
chipgarbage sectionthe chip's abandon command
commitcommit or chiprebase: the dragged commits (all selected ones in multi-selection mode) onto the destination
commitinsert actioninsert with {target, insertBefore, insertAfter}
commitgarbage sectionabandon, fired without waiting

The mapping is handleDragEnd; the commands themselves come from the chip's or the state's dragAndDropCommands, so the webview only knows the shape of the call. While a drag is on, svgRebaseHint draws a curved arrow and an 85×20 "Rebase here" bubble, and svgInsertHint with svgInsertNode shows a ghost node and "Insert between", "Insert as common parent" or "Insert as common child". These used to be custom elements with their own SVGs; they are now plain functions returning SVG groups that the glyph tile paints into its one SVG, part of an upstream change to cut the number of SVGs in the graph (see graph layout: the rendering components). The bubbles share HINT_BUBBLE_STYLES, now scoped to a .hint-bubble class: no pointer events, a drop shadow, and the button colours. The old 10 px cap on the hint's box, which stopped a hint on the bottom row from growing the graph, went away with the separate SVGs; the hint now lives inside a tile-sized SVG with visible overflow.

6. Context menus

A webview cannot open a VS Code menu directly. Instead VS Code opens one on a contextmenu event and passes the menu items the element's data-vscode-context JSON. openContextMenu uses that: it writes the JSON onto the hidden #jj-context-menu-provider button and dispatches a synthetic bubbling contextmenu at the mouse position. The provider renders into the light DOM (source) so VS Code can find the button.

  • A row first awaits $informContextMenuWillOpen; an upstream comment says the menu sometimes fails to appear without it. The context then has origin commitRowMultiSelected for a selected row in multi-selection mode, or the node's own vscodeContext with origin commitRow (source). The kebab button calls the same method.
  • Chips and top-bar split buttons build their own contexts (see chips and top bar). Every context sets preventDefaultContextMenuItems.

7. The resize controller

JjResizeController is the base class of jj-app. VS Code delivers webview resize events out of order while a panel is dragged, so rendering every event makes the graph jitter. On each resize (source) the controller skips widths it has already seen, tracks whether the width is growing or shrinking, and only updates renderedWidth when the new width moves in that direction. A 100 ms timer always renders the latest width in the end and resets the history. Widths come from the host's computed style; parseWidthString throws on anything but pixels.

8. Untrusted text

Commit descriptions, bookmark names and other repository text reach the page as strings in the state. Every component inserts them with Lit bindings, which set text content and escape it, so they are never parsed as HTML. The one exception is the callout message: safeHTML passes it through sanitizeHtml from safevalues before Lit's unsafeHTML, and callout.ts is its only caller.

Two contexts are built as JSON strings: the top bar's split-button context interpolates right.origin and the command ID into a template string without escaping (source), whereas rows and chips use JSON.stringify. Both values are set by the extension, not by repository content.

9. Styles and theme colours

Each component carries its own Lit static styles, and colours come from VS Code's theme variables (--vscode-*) wherever one fits, so the graph follows the user's theme. The page stylesheet _jj_graph_base_styles.css adds the rest:

VariableLightDarkUsed for
--commit-row-highlighted-text#994f00#fcad70display ID prefix, "(empty)" text
--inactiveLineColorlightgray#616161lines and glyphs off the working-copy path (foreground in other themes)
--jj-garbageSection-*#939393; on hover the diff editor's removed-line background and border #f59c8dthe abandon drop zone
--jj-activeCommitRow-*--vscode-list-activeSelection*multi-selected rows
::highlight(search-matches)black on yellowevery search match
::highlight(search-current-match)black on #ff9632the current match

Icons are VS Code codicons through jj-codicon, which parses names like codicon-sync~spin with parseCodicon. One name, codicon-checkout, is swapped for the custom SVG jj-edit-codicon. Sizes are in constants.ts: 14 px tiles, 24 px rows and a 35 px top bar.

10. Helpers and tests