feat(desktop,host-service): add a commit graph to the workspace sidebar

AC
1mo ago
+5606-27

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). getCommitFiles is 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. refScope picks how wide it goes, and all five values are served:

    ScopeTipsReads as
    headHEADthis workspace's own history
    open-workspaces+ base ref, worktree branches, detached worktree headsbranches open in other Superset workspaces
    local (default)+ every local branch, the current branch's upstreameverything on this machine
    remoteHEAD + --remoteswhat the last fetch saw on the server
    all--exclude=refs/stash --allevery ref, tags included

    remote and all pass 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. Under open-workspaces, a worktree git marks prunable is 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 from git branch --merged <baseRef>, not from graph topology, so a branch merged far outside the window still reports merged. prunable comes from git's own porcelain flag (gitdir file points to non-existent location) rather than a per-branch existsSync, 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 from for-each-ref and resolved through ResolvedRef — never from %D, never from a shortname prefix.

  • Overlapping traversals coalesce, so a git:changed storm during a rebase doesn't fan out into duplicate walks.

  • The pagination cursor is tagged with the scope it was issued for. --skip counts rows of one traversal, so replaying a local offset against head would 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..8 tokens 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 from shared/themes/graph-lanes through applyGraphLaneColors, and the hex in globals.css is 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 diff pane 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

GateResult
bun run lint0 (5832 files)
bun run typecheckpasses 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 test14/15 packages green
apps/desktop tests2617 pass, 1 fail — getProcessEnvWithShellPath, environment-dependent and untouched by this branch
packages/host-service tests998 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.

Commit graph with four lanes, merge and fork elbows, a tag badge, HEAD and main, and a struck-through prunable branch badge

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.

Graph tab with the scope menu open, showing This workspace / Open workspaces / All local branches / Remote / Everything

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.

Same graph with ref badges on their own line above each commit, showing full branch and tag names

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.

Same graph dimmed except the rows carrying unreferenced branches, which stay at full contrast

Checklist

  • PR title follows conventional commits (type(scope): subject)
  • bun run lint passes with 0 output (CI fails on warnings too)
  • bun run typecheck — green for every package this PR touches; @superset/ui fails on a pre-existing duplicate @types/hast resolution, 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 --skip offset, 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.
  • Everything is now largely All local branches ∪ Remote ∪ tags, so it may not earn its slot. Left in for one release to see whether anyone picks it.
  • remote reflects the last fetch. Nothing in this PR fetches, so a stale remote reads as a stale graph.
  • All local branches is 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.