feat(desktop,host-service): add a commit graph to the workspace sidebar
What & why
Adds a fourth Graph tab to the v2 workspace sidebar: a lane-based commit graph whose primary value is ref classification, not the lanes. The lanes are the frame; what the tab is actually for is surfacing branches and worktrees that exist in git but have no open workspace behind them.
Commit history was only reachable as a flat base..HEAD dropdown — no topology, and no view at all of the branches and worktrees that accumulate outside the currently-open workspaces. A branch whose worktree was deleted, or a worktree with no workspace row, was invisible until it caused a problem.
Read-only. No write actions (checkout, cherry-pick, revert, prune) in this PR.
Relates to superset-sh/superset#4935 and superset-sh/superset#5594 (superset-sh/superset#3849 folded in).
Host service — git.listGraph
-
New procedure alongside
listCommits, which is untouched (desktop and mobile both consume its output type).getCommitFilesis the one existing procedure that changed: it now also returns the commit's own metadata (subject, body, author, date, parents), so a diff pane opened from the graph renders its header without a second spawn. -
Tip set is seeded from git's own refs, never from workspace records.
refScopepicks how wide it goes, and all five values are served:Scope Tips Reads as headHEADthis workspace's own history open-workspaces+ base ref, worktree branches, detached worktree heads branches open in other Superset workspaces local(default)+ every local branch, the current branch's upstream everything on this machine remoteHEAD+--remoteswhat the last fetch saw on the server all--exclude=refs/stash --allevery ref, tags included remoteandallpass one flag rather than enumerating refnames, so a tag-heavy repo can't overflow the command line. Detached worktree heads are still named explicitly — they aren't refs. Underopen-workspaces, a worktree git marksprunableis excluded: its directory is gone, so it is not an open workspace. -
Ref state is classified per local branch:
open·detached-worktree·prunable(+ reason) ·merged·orphan-branch. Containment comes fromgit branch --merged <baseRef>, not from graph topology, so a branch merged far outside the window still reportsmerged.prunablecomes from git's own porcelain flag (gitdir file points to non-existent location) rather than a per-branchexistsSync, so classification costs no synchronous fs calls on the host event loop. -
Every git spawn happens in the worker (
gitGraphLogTask), including base resolution, so both stdout draining and parsing stay off the host event loop. Decorations are built fromfor-each-refand resolved throughResolvedRef— never from%D, never from a shortname prefix. -
Overlapping traversals coalesce, so a
git:changedstorm during a rebase doesn't fan out into duplicate walks. -
The pagination cursor is tagged with the scope it was issued for.
--skipcounts rows of one traversal, so replaying alocaloffset againstheadwould window a list that never had those rows; a cursor from another scope restarts at page one instead of serving a silently wrong page. Scope is also part of the coalescing dedupe key, so two scopes never share one walk.
Renderer
assignLanes— pure, tested, no React or tRPC. Active-lane sweep over topo-ordered input; lane identity is pinned to the originating branch so a refetch doesn't reshuffle the graph.- Virtualized list, per-row inline SVG (not one tall canvas), so virtualization stays trivial. Row height is derived from the commit's own refs — no measurement pass — and the size cache is keyed by commit hash so it survives refs moving underneath an open tab.
- Lane colours are new additive
--graph-lane-1..8tokens in both themes, equal chroma/lightness so the lanes read as one family and survive a theme switch. No component hardcodes a colour; the live values come fromshared/themes/graph-lanesthroughapplyGraphLaneColors, and the hex inglobals.cssis only the pre-hydration fallback set. - Ref badges encode state in texture as well as colour — fill, dashes, strike — so they still read at 260px and for a colourblind viewer, leaving hue free for lane identity.
- Row tint in the lane's colour makes lane membership readable without tracing lines, and survives the lane compression narrow widths force.
- Commit clicks open the existing
diffpane kind; the graph owns its own persisted selection, separate from the Changes tab's filter, so the two surfaces no longer fight over one piece of state. Shift-click ranges, cmd/ctrl-click forces a new pane, double-click pins — matching the file tree's convention exactly. - Read-only row context menu (copy hash / subject / open in new tab).
- The tab's own control strip carries a scope chooser and two toggles. The chooser sits left of them and reads This workspace · Open workspaces · All local branches · Remote · Everything — plain wording rather than git's, since the audience is people who don't think in refs. The toggles are ref badges on their own untruncated line, and dimming of commits that carry no unreferenced ref. The strip renders even when the window is empty, so a scope that returns nothing can't take away the control that would let you leave it.
Notable decisions
The "unreferenced" filter dims, it does not remove. Lanes and edges are assigned across the whole commit list; dropping rows would leave the surviving edges pointing at rows that aren't there, and filtering before lane assignment yields a graph of disconnected nodes.
Selected rows and range-interior rows share a tint alpha. Selection also carries --accent; the range interior doesn't. Same alpha therefore keeps an endpoint strictly brighter than the band it anchors, in both themes, with no per-theme numbers.
The row size cache is keyed by commit hash, not by list index. Refs move while the tab is open, so a refetch that prepends a commit shifts every index — under the virtualizer's default index key a row inherits the cached height of whatever previously sat in its slot, which in two-line mode misfits by a whole badge line and shows up as overlapping rows or a blank band. Keying by hash lets a cached size travel with its commit; rows is also in the remeasure deps for the case the key can't catch, a ref landing on a commit whose hash doesn't change. Only reproduces on a repo with live churn.
"Open workspaces" is read off git worktree list, not the workspaces table. The worker has no DB access, and every Superset workspace is a worktree, so the worktree list already answers the question. The known ceiling: a worktree created outside Superset counts as an open workspace. Joining against DB rows in the coordinator is the upgrade path if that ever matters.
The header toggles read through a live query, not .get(). They write to v2SidebarProjects, and .get() is a non-reactive snapshot — the row changed in localStorage while the header kept rendering the stale flag until some unrelated re-render happened to run, which reads as a multi-second lag on a write that already completed. No spinner: the write is synchronous and local, so a pending indicator would animate over an already-finished state change. The toggles also ensureProjectInSidebar first, matching useV2ProjectDefaultApp — a project with no sidebar row yet would otherwise swallow the write entirely.
The strip's controls are not in SidebarHeader's actions slot. That slot is a shrink-0 island in a fixed 40px row whose four tab buttons are each flex-1 with no min-w-0; anything rendered there clips the last tab label instead of shrinking it. The chooser and toggles live on the graph's own control strip instead.
How I tested it
| Gate | Result |
|---|---|
bun run lint | 0 (5832 files) |
bun run typecheck | passes except @superset/ui, which fails on a duplicate @types/hast resolution in node_modules. No packages/ui file, package.json, or bun.lock is in this branch's diff, so it is not from here |
bun run test | 14/15 packages green |
apps/desktop tests | 2617 pass, 1 fail — getProcessEnvWithShellPath, environment-dependent and untouched by this branch |
packages/host-service tests | 998 pass, 0 fail, 14 skip |
refScope has its own integration test: a branch with no worktree is absent under head and open-workspaces but present under local and all; a worktree checked out on that branch whose directory is then deleted stays excluded from open-workspaces, which fails without the prunable filter; and remote excludes the local-only branch until refs/remotes/origin/side exists, then includes it. Cursor handling is covered both ways — a local cursor pages forward, and the same cursor handed to head restarts at page one instead of windowing rows that traversal never produced. buildTipSet is unit-tested per scope, including that all and remote emit a flag rather than a refname list.
Integration test covers all five ref states against a fixture repo (open workspace, worktree with no workspace row, orphan branch, prunable worktree with its path deleted) plus a merge commit, a tag, and a branch merged outside the commit window. Lane assignment is unit-tested for linear / merge / octopus / criss-cross / root / lane reuse / parent outside the window, including determinism when commits are appended.
Rendering was checked against the running app at 260px and 400px sidebar widths, in both toggle states, with the measurements alongside the screenshots (total virtual size, per-row height, badge clipping). The toggle-reactivity fix was verified end-to-end on the running app.
The row-height regression is pinned by a test that prepends a commit to a mixed-height list and asserts every commit present in both lists keeps the same (key, size) pair, looked up by hash rather than by slot. It was confirmed red against the pre-fix behaviour: restoring the index key fails it. The size and key derivation was extracted into a pure graphRowSizer to make that testable — this repo has no jsdom or testing-library, and adding one for two callbacks wasn't worth the dependency.
It was also verified live: with the Graph tab open on a repo of ~1600 commits, a commit landing on HEAD prepended a two-line row and shifted every index by one. Row offsets stayed consistent with rendered heights across the shift and across the subsequent removal — 0 mismatches in both directions, where the pre-fix build produced an overlap at the slot that grew and a blank band at the slot that shrank.
Screenshots
Captured from the dev app over CDP (RENDERER_REMOTE_DEBUG_PORT=9222 bun dev, renderer matched to this workspace's DESKTOP_VITE_PORT), driving the real controls with dispatched mouse input. The clip is the sidebar column only; the rest of the window is unchanged by this PR. The branches below main are a throwaway topology built for these shots and deleted afterwards — the repo's own history is a straight line, which shows none of the cases the tab exists for.
Inline refs (default) — four concurrent lanes, two merges and an octopus. The fork elbow leaves the node horizontally then bends down; the merge descends then bends in, so the two read apart at a 3.5px node. Row tint carries lane membership without tracing the line. The dashed, struck-through red badge is a branch whose worktree directory is gone (prunable); badges truncate to fit 260px.

Scope chooser — the five scopes as the user reads them, All local branches selected. Plain wording rather than git's, because the audience is people who don't think in refs.

Refs on their own line (toggle on) — same commits, badges move above the row they decorate and stop truncating: origin/main and the long branch names read in full. That is the trade the toggle exists for, and the reason row height has to come from the commit's own refs.

Highlight unreferenced (toggle on) — the point of the tab. Rows carrying a branch or worktree with no open workspace behind it stay bright; everything already accounted for dims. Nothing is removed, so the lanes and edges still connect.

Checklist
- PR title follows conventional commits (
type(scope): subject) -
bun run lintpasses with 0 output (CI fails on warnings too) -
bun run typecheck— green for every package this PR touches;@superset/uifails on a pre-existing duplicate@types/hastresolution, see the table above - "Allow edits from maintainers" is checked on fork PRs
Known gaps
- Pagination is best-effort within a scope: the cursor is a
--skipoffset, so pages re-anchor if refs move between fetches. Crossing scopes is handled (the cursor is dropped), moving refs are not. The truncation banner surfaces when a repo has more history than one page. Everythingis now largelyAll local branches ∪ Remote ∪ tags, so it may not earn its slot. Left in for one release to see whether anyone picks it.remotereflects the last fetch. Nothing in this PR fetches, so a stale remote reads as a stale graph.All local branchesis the longest label and squeezes the "Showing first N of M" text beside it at the narrowest sidebar widths.- Light theme is covered by unit tests on the pure colour helpers; the store's delivery path was verified in dark only.
Summary by CodeRabbit
- New Features
- Added a Git Graph tab to the workspace sidebar with a visual, lane-based commit history.
- Added reference-scope filtering, pagination, two-line badges, and unreferenced-reference highlighting.
- Added commit and range selection, diff previews, pane pinning, context-menu actions, and commit metadata.
- Added responsive layouts and loading, empty, error, and truncated-history states.
- Added theme-aware lane colors and indicators for branches, tags, HEAD, worktrees, merged, and prunable references.