SLOPSHOPPER

repo-diffs

Pane showing branch diffs for the repos/worktrees this session touches, outside the session cwd

newpaneguardcommandtoastprocess
v0.4.0no licenseupdated 2026-10-19jquezada19/claude-mods/repo-diffs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-diffs
│ ┃ Repo diffs ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ No project repos yet ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ A repo shows up here once this session ⏺ Update(src/auth.ts) │ ┃ claims its worktree or edits a file in it. ⎿ Added 2 lines, removed 1 line │ ┃ Changes in this session's own folder are ⏺ Bash(bun test) │ ┃ in the built-in Diff pane. ⎿ 3 pass, 1 fail │ ┃ │ ┃ ↻ refresh ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ │ › /diffs │ ⎿ repo-diffs: Repo diffs pane opened: 0 repo(s), 0 changed file(s) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Repo diffs
No project repos yet A repo shows up here once this session claims its worktree or edits a file in it. Changes in this session's own folder are in the built-in Diff pane. ↻ refresh
README

repo-diffs

A /diffs command and a Repo diffs pane that show branch diffs for the project repos a session works in, outside the session's own working directory. Built 2026-10-09 because sessions rooted in one folder see only that folder's changes in the desktop app's built-in Diff pane, which is bound to the session's cwd repo.

Repo diffs pane: file tree on the left, the selected file's card with its hunks on the right, PR status and counts in the header

What it shows

  • Repos: any repo containing a file this session changed with Edit, Write or NotebookEdit, plus, if you use a worktree-lease file at ~/.claude/leases/worktree/*/lease.json, any worktree this session holds a lease on (read directly, matched by session id; optional). The session's own cwd repo is left out; the built-in pane covers it.
  • Baseline: the merge-base of HEAD with the first of these that resolves: the ref picked in the header's base picker, the PR's baseRefName (as origin/<name>), origin/develop, origin/main, origin/master, then whatever origin/HEAD names (a trunk default branch included); else the lease's base_oid; else HEAD. A picked or PR base that is not fetched is said under the header (Base: origin/qa not found locally (git fetch?)) and the next candidate is used. Untracked files show the status letter U (new in the tree, new file on the card), never ?.
  • Before there is anything to show: Looking for project repos… while the first listing runs, then No project repos yet with a line on how a repo gets here and a ↻ refresh button.
  • Layout: a header, a toolbar, the file list and a stack of file cards.
  • Header, folded by default to one line: ▸ repo branch → base (PR base) ▾ PR #n open ✓ n N files +a −r keys ● time ↻ (the base's tag reads PR base, picked or claim, and nothing for an automatic default; the compact PR reads e.g. PR #42 open ✗ 1, the failing count when any check fails, else pending, else passed). Repo tabs sit above it when there are several (keys 1–9 with the pane focused; a failed switch toasts why). Unfolding it (its chevron, or opening the base picker from → base ▾) shows the full PR row, the failing checks and the picker; the compact PR text shows only while folded.
  • Wide panes (90+ columns): the file list is a pinned left column (26% of the pane, 24–40 columns) that stays in view and scrolls on its own under the wheel; the header, toolbar and cards stack to its right and scroll with the pane. Under 90 columns everything stacks, with one pinned ▸ Files N · n/N viewed ‹ n/N › row (see v24).
  • File list: titled ▾ Files N · n/N viewed; folders joined where they hold a single folder and no files, collapsible; each file has a status letter (A M D R T, U for untracked, ! for a conflict) and its counts.
  • Toolbar: the scope pills and whitespace toggle on the left; ‹ n/m › file stepping, ↑ / ↓ change stepping, ▸ collapse all and options on the right.
  • Cards: ▾ / ▸, the status letter, the file name then its dim dir/, counts (new file, conflict), viewed / ✓ viewed and copy path; then one diff block per hunk with an ⋯ N unmodified lines row between them (click it, or show full file, for full context; show changes only goes back). No per-card diffstat bar.
  • Colours follow the light/dark theme: the selected row uses the theme's selectionBg, bands and hover use userMessageBackground(Hover).
  • Large or odd diffs: the hunk text a drawing carries is budgeted at 60,000 characters (DIFF_CAP): the selected file first, its last hunk that fits clipped to whole lines, then each other open file whole while it fits. A whole drawing is further bounded at 90,000 characters of text (the ROOM_* text bound). Paths are read with -z; CRLF is cleaned; a git failure keeps the last good listing and says git busy, showing last good listing; a diff that cannot load says why, with Retry.
  • Scope (v3, 2026-10-09, after a GitHub-style diff viewer; last turn added in 0.3.0): four pills, lower case, all · committed · uncommitted · last turn. all (merge-base → working tree, plus untracked: the default), committed (merge-base → HEAD: what the PR shows), uncommitted (HEAD → working tree, plus untracked) or last turn (see 0.3.0).
  • Review comments (v3, after a GitHub-style diff viewer): + comment on each hunk's band opens a field labelled Comment on line N (Comment on old line N for a deleted line) with tags must-fix · suggestion · nit · question; the comment pins to the hunk's first added line (else its first deleted one). Send to prompt appends every comment to the prompt box as one markdown "Code review comments" block and clears them once the box took them; a refused fill keeps them. The tree and the card show a ✎ count.
  • Styling (v3, after GitHub-style diff viewers; since revised): −removed +added with zeros dropped; the header's totals are chips (+288 on the added tint, −34 on the removed tint), with no five-block diffstat; a card leads with the file name and its dir/ follows, dim. The refresh control reads ● 2:14 PM ↻ (the last refresh), ● updating ↻ or ● stale ↻.
  • From delta (v4, 2026-10-09): each hunk's row shows its line and git's function context (L42 · in async function refreshOnce(), a ⧉ that copies the absolute path:line, and + comment; renames list once as old → new (-M, both paths passed to the patch); a rebase, merge, cherry-pick or revert stopped part-way shows ⚠ rebase in progress · N conflicts, with unmerged files marked ! (never the untracked U) and listed first under Conflicts; ↑ ↓ (keys p n) scroll hunk to hunk, ‹ › (keys k j) step files, and → cut / ↩ wrap (key w) toggles long-line wrapping. Letter keys pause while a comment is being typed. (now: the hunk band reads ▾ @@ -40,7 +40,9 @@ in async function refreshOnce( with copy line and + comment at its end, no ⧉; the banner reads ⚠ rebase in progress · N conflicts to resolve, or ⚠ unresolved conflicts · N conflicts to resolve with no operation stopped; wrapping is a wrap lines pill on the options row, key w.) Reviewed 2026-10-10 by Codex (model_reasoning_effort=high) and Kimi K3 (Grok and Gemini timed out at 900 s). Fixed from it: a rename's patch keeps only its own file section, the conflict scan is validated like the listings, the watcher sees operation markers come and go (rebase --quit), copy on a deletion copies its current-file line, a stopped git am is not called a rebase, and n/p move the cursor before any await. Kimi's "conflicted files show no diff" was refuted against a real rebase conflict (git prints marker hunks).
  • From gh (v5, 2026-10-10): the header shows the branch's pull request, read with gh pr view --json in the repo's folder off the listing path (10 s timeout; re-read when branch@HEAD moves or the answer is 60 s old): PR #42 (a link) · Open/Draft/Merged/Closed · ✗ failed ● pending ✓ passed/total · merge conflicts or mergeable · review state, where an empty decision reads "no review yet", never approval. Checks are grouped and de-duplicated as gh does (a re-run replaces the run it repeats); up to five failing checks are listed as links to their details. "No pull request for this branch" and "PR status unavailable: <gh's reason>" are said plainly. hide files… takes gh pr diff --exclude-style globs (* never crosses /; a pattern matches the whole path or the file's name), hides those files from the tree and totals (never a conflict), shows N hidden · show all, and is kept in the plugin store across sessions. Letter keys pause while the field is open. (now: the button is hide…; with saved patterns the list reads Hiding <globs> with edit, and N hidden with show / Showing N hidden with hide again.) Reviewed 2026-10-10 by Codex (model_reasoning_effort=high) and Kimi K3 (Grok and Gemini skipped after timing out on both earlier rounds). Fixed from it: the commit comes from its own rev-parse --verify HEAD (a second HEAD after --symbolic-full-name prints the ref again, so the cache key never moved), a PR answer shows only for the branch@commit it was read for and a switch mid-call re-asks, gh is given the branch by name, cut-off gh output says so, exclude globs follow Go's path.Match (^ negates, ! is literal, \ escapes, ? is one whole character, a malformed pattern matches nothing), patterns with spaces are quoted, tab counts leave hidden files out, and an all-hidden tree says why it is empty.
  • From lazygit (v6, 2026-10-10; hooks/commits.ts and hooks/options.ts were built by sub-agents, wired here):
  • Base from the PR (L1). The pane diffs against the PR's baseRefName (origin/qa for a qa hotfix), then develop/main/master. The header's → <base> ▾ opens a picker: auto, the PR base, origin/develop, origin/qa, origin/main, or any ref typed. A wanted base that is not fetched is reported ("origin/qa not found locally (git fetch?)"), and costs one extra listing per session, not one per PR re-read.
  • Commits (L2). ▸ Commits (N) lists base..HEAD (200 max). Picking one shows only what it introduced (git diff <first parent> <sha>, or the empty tree for a root commit), with a Viewing commit … banner and back to branch. Comments written there stay with that commit.
  • Context (L3). ±N lines − + (keys l m, 0 to 30) on the patch call only, always as an explicit -U<n>. (now: − ±3 lines + on the options row.)
  • Whitespace (L4). ␣ (key i) adds -w to the patch and the listing, so whitespace-only files leave the tree. (now: an ignore whitespace pill, key i, that reads no whitespace while on.)
  • Keys (L5). ? (key h) shows the key legend, built from the same constants as the buttons. (now: a keys button, key h.)
  • Reviewed 2026-10-10 by Codex (model_reasoning_effort=high) and Kimi K3. Fixed from it:
  • A cached PR base counts only for the live branch and commit, so after a switch the old branch's target is never used.
  • A commit pick survives an older refresh that was already running.
  • The PR-base re-list remembers only the latest attempt per repo, so a retarget qa → main → qa still re-lists. It compares against the base in use, not a commit view's label.
  • The watched base ref comes from git rev-parse --symbolic-full-name (no --end-of-options, which rev-parse echoes back), and stays watched while a commit is shown.
  • The whitespace toggle moved to the always-visible scope row.
  • A typed base starting with - never reaches git.
  • From the UI review (v7, 2026-10-10; a five-state screen check, ui-ux-pro-max and impeccable's audit/critique checklists, run by hand):
  • clear… asks first (Clear N comments? This can't be undone. with Clear all / keep); adding a comment drops a question left open.
  • N hidden · show reveals hidden files for now and keeps the saved patterns (Showing N hidden · hide again); it used to erase them.
  • A diff that failed to load has a Retry beside the error.
  • Controls name what they do (⧉ copy path, − less / + more, ↑ prev change, ‹ prev file, ↻ refresh, ? keys, ✕ remove): a desktop Button's label is its accessible name. (now: copy path, − / +, ↑ / ↓, ‹ / ›, ● <time> ↻, keys, ✕ remove.)
  • The comment mark is ✎, a text glyph the theme colour reaches, not the 💬 emoji; muted text is inactive alone, no longer also dimmed.
  • A comment shows tag, line and remove on top and its words below. Each change has a test that fails with it removed (mutation-checked).
  • Performance and narrow panes (v8, 2026-10-10):
  • The tree draws its first 150 rows, then N more rows · show 150 more · show all; the selected file is always drawn (after ⋯ N rows when it sits further down). A 1,000-file branch went from 52 ms and ~8,900 elements a redraw to 13 ms and ~1,500 in the harness.
  • Under 40 columns the tree indents one cell a level and leaves per-file counts to the card, commit rows drop their age, the key legend puts a label under its key, and the card's counts wrap under the path.
  • Rows that wrap use columnGap, so wrapped lines carry no blank row.
  • Contrast (v9, 2026-10-10; measured against Claude Code's theme colours and a desktop screenshot, WCAG 4.5:1):
  • Selected rows (tree file, commit, conflict) draw their letter, counts, ✎ and age in the text colour: red/green/grey on the selection colour measured 2.98–3.52 in the terminal themes; text on it is 8.5–13.9.
  • Secondary controls dim only off the terminal (canDim): a terminal's faint text measured ~3.9 on white and ~4.3 on the dark band; the desktop's dim measured 9.7. Each on/off control also changes its label (● / ○, [tag], ↩ wrap / → cut), so state survives undimmed. (now: a picked pill is the secondary variant and keeps its label; only the whitespace toggle, the comment tags ([tag]), the base picker's ● auto / ● <ref> and ✓ viewed change their label.)
  • Left to the theme itself: inactive on the dark band (4.18) and the light colour-blind theme's warning (2.14 on white).
  • Labels (v10, 2026-10-10): the comment field is named Comment on line N (it had only a placeholder; the tags row reads Tag:), and the compare-against picker has a cancel, as the hide field does.
  • Wording and the phone (v11, 2026-10-10): plain (chromeless) controls read lower case (cancel, keep, clear…, hide files…, show full file); boxed ones keep sentence case (Send to prompt, Retry, Clear all). (now: hide files… is hide….) + comment shows only where a text field is drawn: on the phone it led to a composer that could not take text.
  • GitHub's Files changed (v12–v15, 2026-10-10; reviewed by several outside models and a Codex review at --effort high):
  • Rounded subtle panels on header, file list and card (flat under 40 columns); the list is titled Files N · n / N viewed; the card header carries the five-block diffstat; counts read +1,001 −75. (now: the list reads ▾ Files N · n/N viewed; no card carries a diffstat bar.)
  • Viewed (v, [ ] viewed; now viewed / ✓ viewed): marks the file, moves to the next unviewed one, folds a viewed file to ✓ Viewed · show diff, shows ✓ in the tree. A mark holds while the file's listing entry (status, rename, counts) is unchanged, and is kept in the plugin store.
  • Views (g): by default each hunk is drawn as GitHub's tinted rows: runs of unchanged / added / removed lines, each run on GitHub's row tint with its numbers (new-file; old-file for removed) in GitHub's stronger gutter tint, the marker padded + , the code in GitHub's token colours (hooks/highlight.ts, a line-by-line highlighter: keywords, functions and decorators, types, module paths after import / package / from / using, constants, strings and comments each their own colour, the scopes delta's syntect themes colour apart). Colours are GitHub's own (GH_DARK read off github.com 2026-10-10; GH_LIGHT from Primer), picked by the theme setting; the terminal keeps the theme's diffAdded* / diffRemoved* tints, having no alpha. Text, since a Code paints its own background and cannot carry the tint. g switches to syntax colours: each run a source Code with startLine, a one-cell tinted bar beside it. One band per hunk carries its @@ range, function, copy and comment in both. (2026-10-10: replaced the engine's own format: 'diff' view, which on the desktop drew the text green with neither tint nor syntax colours.)
  • Drawing size: the desktop drops a whole pane drawing past its own bound and shows "nothing to show yet" (measured 2026-10-10: 1,639 elements / 210k characters of JSON drew, 1,738 / 223k did not). Each render weighs what it draws and adds tree rows and file containers only while they fit ROOM_REMOTE (1,300 / 170k; the terminal holds 19,000 elements), the selected file always; what is left out is said, not paged. (now: ROOM_REMOTE is 1,600 nodes / 170k characters / 90k characters of text; the terminal 19,000 nodes / 90k text.)
  • Notes instead of "No text changes.": renamed without changes, now / no longer executable (mode), symlink replaced by a file.
  • Fixed from the reviews: keys no longer stay paused for a comment draft on another file (Kimi), a selected file's collapsed folders open (Codex C1), tree indent is capped at a third of the list (C2), an editor opened on another surface holds no keys on the phone (C3).
  • Refuted on the way: conflicted files show no diff (they show marker hunks, again), deep edits past six folders are lost (git finds the root), brackets break pickSection (it uses includes), CRLF reaches Code.
  • File containers (v16, 2026-10-10): GitHub's stacked Files changed. Every changed file is its own framed container (the selected one ringed in the theme's suggestion accent), each with ▾ fold / ▸ open, its path (press to select), counts, diffstat, [ ] viewed and ⧉ copy path. (now: bare ▾ / ▸, the file name then dim dir/, counts, viewed / ✓ viewed, copy path; no diffstat.) The shared diff options (context, wrap, numbers, prev/next change and file, ▸ collapse all / ▾ expand all) sit in one bar above them. A tree row or j/k selects the file and scrolls its container to the top. The first 25 files are drawn (show 25 more / show all), plus the selected one wherever it sits. A non-selected diff over 1,000 changed lines waits for load diff, as GitHub's "Large diffs are not rendered by default". The ~90k-character drawing budget goes to the selected file first, then to each other open file whole while it fits (show it past that). (now: that diff budget is 60,000 characters, DIFF_CAP; 90k is the per-drawing text bound.) Other files' diffs load four git calls at a time into their own cache.
  • Sideways pan (v17, 2026-10-12): in → cut mode a hunk with a line over 20 columns shows ‹ scroll › on its band; each press pans that block alone 20 columns (col N shows where it starts), stopping where the widest line's last columns are still in view. No surface has a horizontal scroll box (Box overflow is only visible | hidden), so the mod drops leading columns itself; the +/− marker and line numbers stay put. ↩ wrap hides the arrows. Keys a / d (toolbar ‹ scroll / scroll ›) pan the change n/p last landed on, else the selected file's first; h/l were already the legend and less context. (now: "cut mode" is wrap lines off; the toolbar's ‹ scroll / scroll › sit on the options row.)
  • Simpler pane (v18, 2026-10-14, after a 300-file change set): the header is one line (▾ repo branch → base ▾ · N files +a −r ■■■■■ ? ● ↻ time; the live dot and last refresh sit on the refresh button, "stale" or "updating" replace the time, and the listing source and scope descriptions moved into the ? legend). The scope row keeps All · Committed · Uncommitted · ␣ whitespace with no blurb under it. (now: the header line is ▸ repo branch → base (PR base) ▾ PR #n open ✓ n N files +a −r keys ● time ↻, folded by default; the scope pills are all · committed · uncommitted · last turn plus ignore whitespace, on the toolbar.) The file list is titled ▾ Files N, shows · n viewed only once a file is, and hide… opens the globs. Past 40 files a repo's top folders start collapsed (once per repo, the selected file's folder left open) so a big change set reads as folders to click into. The bar above the files holds only ‹ n/m ›, ↑ k changes ↓, collapse all and an options toggle; context, wrap, tinted/syntax and sideways pan live on the options row (their keys arm only while it shows). File cards lose the per-file five-block bar; the header keeps the totals'. Every block folds on a chevron: the header's detail (hdr-fold), the file list (files-fold: the diff then takes the whole width, wide panes included), Conflicts, each file (as before) and each hunk (hunkfold:i: the band with its range stays). Folds live in shut (sec:header, sec:files, sec:conflicts, hunk:<file>#<i>), the options row in showOptions, the seeded repos in seeded. Harness: five scenarios added (1 file, 3 files, a 300-file change set with seeded folders, everything collapsed, options open) and the audit now treats a cut Text root as clipping its token spans and skips the digit-only line-number gutter (both v12 artifacts: 945 flags over 77 renders of the previous build were all of those two kinds, 4 one-pixel wrap flags otherwise). v18 sweep: 1,080 renders (18 scenarios × 10 widths from 200 px × 2 themes × 3 font sizes), 20 flags, all the same 1–4 px wrap-mode token overflows the v17 build shows; the hook took at most 2.2 ms a drawing. Two found and fixed on the way: at 17–21 columns the hunk band's ‹ scroll › ⧉ copy + comment cluster overflowed (it wraps now), and a folded hunk kept that row (hidden while shut). harness/bundle-old.js + index-old.html (ignored) hold a bundle of any earlier register for A/B sweeps; sweeps log to ~/.claude/metrics/repo-diffs-harness-sweeps.jsonl.
  • Look (v19, 2026-10-14, "still outdated" after v18): picked states are the surface's own filled pill (variant="primary": the scope, ignoring whitespace (now no whitespace), options, keys, wrap lines, syntax colours); everything else is a plain dim word, lower case, no ●/○/␣/⧉/☐ glyphs. Secondary actions sit in a Box the desktop draws display: none and reveals under the pointer (hover: { display: 'flex' } inside the keyed card or hunk band): copy path on a card, copy line · + comment on a band; the terminal and the phone, which have no pointer, show them always. The header has no frame (spacing, not a box); the file list's and the other cards' frames are dim, the selected card's stays the accent. The refresh control is ● time ↻. Harness: the painter draws dim borders and hover-reveal (?reveal=1 shows every hidden control at rest), and index.html?s=<scenario>&w=<px>&theme=&font= loads one state, so headless Chrome can capture it (harness/shots/, ignored). Claude cannot be granted control of its own window, so the live pane is checked through the plugin test engine (the real pane, mounted) and these captures, never by clicking the app from a session.
  • After the seats (v20, 2026-10-14): Codex (model_reasoning_effort=high, with the captures as images), an external model review and an impeccable critique (two isolated assessments: a design review scoring 28/40 on Nielsen's heuristics, and the detector, which found nothing real on the pane: its 82 padding flags are unresolved calc(var(--cell)) in a static dump and its one contrast flag is the harness's own button style) converged, and these were adopted: context rows and every line number go regular and dim, changed rows stay bold (the v12 note that regular code reads thin on the tints still holds for them); a hunk's band and the ⋯ N unmodified lines row lose their background, so only the card header is a band and bands never stack; the toolb
Source 9 files
hooks/register.tsx 3034 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { CommentTag, Composing, DiffState, FileChange, PrEntry, PrInfo, RepoSnap, ReviewComment, Scope, TurnEdits, View } from '../types'
5import { PAN_STEP, cleanPatch, clipHunk, exportComments, firstChange, grouped, hunkContext, hunkHolds, hunkRuns, type HunkRun, nextHunkIndex, panLine, panMax, patchNotes, pickSection, splitHunks } from './hunks'
6import { parseNameStatus, parseNumstat, parseUnmerged } from './git-parse'
7import { PR_FIELDS, formatPatterns, isExcluded, parsePatterns, parsePrView, prFailure } from './pr'
8import { LOG_ARGS, commitRange, parseLog, relTime } from './commits'
9import type { Commit } from './commits'
10import {
11  DEFAULT_OPTIONS,
12  KEY_IGNORE_WHITESPACE,
13  KEY_LEGEND,
14  KEY_VIEWED,
15  KEY_NUMBERS,
16  KEY_PAN_LEFT,
17  KEY_PAN_RIGHT,
18  KEY_LESS_CONTEXT,
19  KEY_MORE_CONTEXT,
20  diffFlags,
21  legendRows,
22  optionsLabel,
23  stepContext,
24} from './options'
25import type { DiffOptions } from './options'
26import { buildTree, fileOrder, visibleRows } from './tree'
27import { highlightLine, panTokens, type TokenKind } from './highlight'
28
29// Repos this session works in that are NOT the session's own cwd repo: the
30// built-in Diff pane already covers that one. Sources: files touched by Edit/Write/NotebookEdit, plus any
31// worktree this session holds a session-ledger lease on.
32
33const PANE = 'repo-diffs'
34const TITLE = 'Repo diffs'
35// the hunk text a drawing carries; the engine draws 100,000 characters of text
36// a drawing, and each drawn line adds its number and padded marker (a tenth
37// to a quarter more), so the rest is that overhead and the pane's own words
38const DIFF_CAP = 60_000
39// How much one drawing may weigh. The desktop drops a pane's whole tree past a
40// bound of its own and shows "nothing to show yet" (measured 2026-10-10: 1,639
41// elements / 210k characters of JSON drew, 1,738 / 223k did not; with their
42// string children, about 1.2x those counts), so remote surfaces get a margin
43// under that; the terminal holds the engine's own 20,000 elements. Rows and
44// file containers are drawn while they fit. Strings count as nodes: a
45// highlighted row is mostly short strings between its coloured spans.
46// text: every surface draws a tree's first 100,000 characters of text (strings,
47// Code sources) and past that cut a Box loses its key, so a later container
48// could be neither read whole nor scrolled to; 90,000 leaves the margin.
49const ROOM_REMOTE = { nodes: 1_600, chars: 170_000, text: 90_000 }
50const ROOM_TERMINAL = { nodes: 19_000, chars: Number.POSITIVE_INFINITY, text: 90_000 }
51type Weight = { nodes: number; chars: number; text: number }
52const weightOf = (el: unknown): Weight => {
53  let nodes = 0
54  let text = 0
55  const walk = (n: unknown): void => {
56    if (Array.isArray(n)) return n.forEach(walk)
57    if (typeof n === 'string' && n !== '') {
58      nodes++
59      text += n.length
60    }
61    if (!n || typeof n !== 'object') return
62    nodes++
63    const o = n as { props?: { children?: unknown; source?: unknown; label?: unknown }; children?: unknown }
64    if (typeof o.props?.source === 'string') text += o.props.source.length
65    if (typeof o.props?.label === 'string') text += o.props.label.length
66    walk(o.children)
67    walk(o.props?.children)
68  }
69  walk(el)
70  const json = JSON.stringify(el, (_k, x: unknown) => (typeof x === 'function' ? undefined : x))
71  return { nodes, chars: json?.length ?? 0, text }
72}
73// GitHub's diff palette. Dark: read off github.com 2026-10-10 (--diffBlob-*,
74// --fgColor-*, the prettylights token colours). Light: Primer's light theme
75// values, not read off a page. The row tints are alpha hex, which only a
76// remote surface paints; the terminal keeps the theme's own diff keys.
77type Palette = { addLine: string; addNum: string; delLine: string; delNum: string; fg?: string; muted: string } & Record<TokenKind, string>
78const GH_DARK: Palette = {
79  addLine: '#2ea04326', addNum: '#3fb9504d', delLine: '#f851491a', delNum: '#f851494d', fg: '#f0f6fc', muted: '#9198a1',
80  keyword: '#ff7b72', entity: '#d2a8ff', type: '#ffa657', namespace: '#7ee787', constant: '#79c0ff', string: '#a5d6ff', comment: '#9198a1',
81}
82const GH_LIGHT: Palette = {
83  addLine: '#dafbe1', addNum: '#aceebb', delLine: '#ffebe9', delNum: '#ffcecb', fg: '#1f2328', muted: '#59636e',
84  keyword: '#cf222e', entity: '#8250df', type: '#953800', namespace: '#116329', constant: '#0550ae', string: '#0a3069', comment: '#59636e',
85}
86const BASH_DEBOUNCE_MS = 400
87const WIDE_COLS = 90
88// below this the tree indents one cell a level and leaves out per-file counts
89const NARROW_COLS = 40
90// change detection: git-dir mtimes every 3 s, `git status` every 5th tick
91const WATCH_MS = 3_000
92const STATUS_EVERY = 5
93// files whose mtimes the status check reads; past this only status counts
94const STATUS_STAT_CAP = 400
95// a PR answer is re-read when the branch moves, or after this long (checks
96// finish on GitHub without anything changing here)
97const PR_TTL_MS = 60_000
98const PR_TIMEOUT_MS = 10_000
99const SCOPES: ReadonlyArray<{ value: Scope; label: string; description: string }> = [
100  { value: 'all', label: 'all', description: 'Everything since the base branch, committed or not, plus untracked files' },
101  { value: 'committed', label: 'committed', description: 'Commits since the base branch: what the PR shows' },
102  { value: 'touched', label: 'uncommitted', description: 'Work not yet committed, plus untracked files' },
103  {
104    value: 'turn',
105    label: 'last turn',
106    description:
107      "Only the files an Edit, Write or NotebookEdit changed in the latest turn (subagents included; shell commands are not counted), each with its whole diff against the base",
108  },
109]
110const TAGS: ReadonlyArray<Exclude<CommentTag, ''>> = ['must-fix', 'suggestion', 'nit', 'question']
111const TAG_COLOR: Record<string, string> = { 'must-fix': 'error', suggestion: 'success', nit: 'inactive', question: 'warning' }
112const BASE_CANDIDATES = ['origin/develop', 'origin/main', 'origin/master']
113// where the session ledger keeps one folder per claimed worktree, each with a lease.json
114const LEASES_DIR = '.claude/leases/worktree'
115// plain, parseable patches whatever the person's git config says
116// -M: a moved file is one rename (old → new), not a delete and an add
117const PATCH_FLAGS = ['--no-color', '--no-ext-diff', '-M']
118// what the git dir holds while an operation is stopped part-way
119const IN_PROGRESS: ReadonlyArray<{ file: string; op: NonNullable<RepoSnap['op']> }> = [
120  { file: 'rebase-merge', op: 'rebase' },
121  { file: 'rebase-apply', op: 'rebase' },
122  { file: 'MERGE_HEAD', op: 'merge' },
123  { file: 'CHERRY_PICK_HEAD', op: 'cherry-pick' },
124  { file: 'REVERT_HEAD', op: 'revert' },
125]
126
127// Theme keys that follow the person's light/dark theme. The background ones
128// are not in the typed ThemeKey list but resolve ("another key the theme holds
129// resolves too"): the transcript's own user-message band and selection blue.
130const BAND = 'userMessageBackground'
131const HOVER = 'userMessageBackgroundHover'
132const SELECTED = 'selectionBg'
133// section frames: the theme's quietest grey; the selected file's frame, the
134// theme's accent, as GitHub rings the file a link or the tree jumped to
135const BORDER = 'subtle'
136const FOCUS = 'suggestion'
137// the reference dashboard (2026-10-14): a card is a slightly lighter fill inside
138// one hairline, with no bands inside it; one warm accent carries data, never a control
139const CARD = 'userMessageBackground'
140const ACCENT = '#ff5a2c'
141
142const GLYPH: Record<string, { ch: string; color: string }> = {
143  A: { ch: 'A', color: 'success' },
144  M: { ch: 'M', color: 'warning' },
145  D: { ch: 'D', color: 'error' },
146  R: { ch: 'R', color: 'merged' },
147  T: { ch: 'T', color: 'warning' },
148  '?': { ch: 'U', color: 'success' },
149  // an unmerged path: never the untracked U
150  '!': { ch: '!', color: 'error' },
151}
152const glyphOf = (s: string) => GLYPH[s[0] ?? 'M'] ?? { ch: s[0] ?? '·', color: 'inactive' }
153
154const EMPTY_VIEW: View = { repo: null, selected: null, full: [], collapsed: [], scope: 'all' }
155
156const touched = atom({ plugin: 'repo-diffs', key: 'touched' } as const, [] as string[])
157// The files an Edit, Write or NotebookEdit changed since the latest main turn started
158// (subagents' included), recorded after the call ran, as git names them (repo root and
159// path inside it, keyOf) so a symlinked or aliased path matches the listing.
160// the files edited in the latest main turn: the turn's generation, the paths (git keys) and
161// whether more were edited than the list holds, changed together in one update so two edits
162// landing at once cannot pass the cap and a straggler of an older turn cannot join
163// 0.3.0 kept the list and its overflow flag apart; a reload carries them into turnEdits once
164const legacyTurn = atom({ plugin: 'repo-diffs', key: 'lastTurn' } as const, [] as string[])
165const legacyTurnFull = atom({ plugin: 'repo-diffs', key: 'lastTurnFull' } as const, false)
166const turnEdits = atom({ plugin: 'repo-diffs', key: 'turnEdits' } as const, { gen: 0, paths: [], isFull: false } as TurnEdits)
167// The list stops at this many files; past it the scope says so instead of growing.
168const LAST_TURN_CAP = 500
169// how many paths are stat-ed at once when looking for symlinks
170const STAT_BATCH = 16
171const snapshot = atom({ plugin: 'repo-diffs', key: 'snapshot' } as const, [] as RepoSnap[])
172const refreshedAt = atom({ plugin: 'repo-diffs', key: 'refreshedAt' } as const, 0)
173// set on the first open, asked or not: the pane opens by itself at most once
174// per session, so closing it is never undone
175const autoOpened = atom({ plugin: 'repo-diffs', key: 'autoOpened' } as const, false)
176const view = atom({ plugin: 'repo-diffs', key: 'view' } as const, EMPTY_VIEW)
177const diff = atom({ plugin: 'repo-diffs', key: 'diff' } as const, null as DiffState | null)
178// a refresh is running: the header's live dot shows it
179const busy = atom({ plugin: 'repo-diffs', key: 'busy' } as const, false)
180// review comments waiting to be sent to the prompt, and the one being written
181const comments = atom({ plugin: 'repo-diffs', key: 'comments' } as const, [] as ReviewComment[])
182const composing = atom({ plugin: 'repo-diffs', key: 'composing' } as const, null as Composing | null)
183// each repo's pull request, by root
184const prs = atom({ plugin: 'repo-diffs', key: 'prs' } as const, {} as Record<string, PrEntry>)
185// globs whose files the tree hides (gh pr diff --exclude); kept in the store
186// across sessions, so lockfile and pbxproj noise stays hidden
187const exclude = atom({ plugin: 'repo-diffs', key: 'exclude' } as const, [] as string[])
188// the hide field is open: letter hotkeys pause, as while writing a comment
189const editingHide = atom({ plugin: 'repo-diffs', key: 'editingHide' } as const, false)
190// a base the person picked per repo (root → ref), and the base picker open
191const baseOverride = atom({ plugin: 'repo-diffs', key: 'baseOverride' } as const, {} as Record<string, string>)
192const editingBase = atom({ plugin: 'repo-diffs', key: 'editingBase' } as const, false)
193// the key legend and the branch commits list, open or folded
194const showKeys = atom({ plugin: 'repo-diffs', key: 'showKeys' } as const, false)
195const showCommits = atom({ plugin: 'repo-diffs', key: 'showCommits' } as const, false)
196// Clear drops every comment: it asks first
197const confirmClear = atom({ plugin: 'repo-diffs', key: 'confirmClear' } as const, false)
198// "show" reveals hidden files for now without discarding the saved patterns
199const revealHidden = atom({ plugin: 'repo-diffs', key: 'revealHidden' } as const, false)
200// the tree draws this many rows, then offers more (a 1,000-file branch
201// took 52 ms and ~9k elements a redraw drawn whole, 2026-10-10)
202const TREE_PAGE = 150
203const treeLimit = atom({ plugin: 'repo-diffs', key: 'treeLimit' } as const, TREE_PAGE)
204// GitHub's Viewed: a file stays viewed while its listing entry is the one it was
205// viewed at (status, rename, counts); a new commit or edit that moves it resets
206// it. Kept in the plugin store across sessions, as GitHub keeps it per PR.
207const viewed = atom({ plugin: 'repo-diffs', key: 'viewed' } as const, {} as Record<string, string>)
208const openViewed = atom({ plugin: 'repo-diffs', key: 'openViewed' } as const, [] as string[])
209// GitHub's file containers: every changed file in its own frame, folded or
210// open. This many are drawn before "show more files"; a larger diff than
211// LARGE_LINES waits to be asked for unless it is the selected file; the other
212// containers' diffs load LOAD_PARALLEL git calls at a time
213const FILE_PAGE = 25
214const LARGE_LINES = 1_000
215const LOAD_PARALLEL = 4
216const fileLimit = atom({ plugin: 'repo-diffs', key: 'fileLimit' } as const, FILE_PAGE)
217const treeScroll = atom({ plugin: 'repo-diffs', key: 'treeScroll' } as const, 0)
218const diffs = atom({ plugin: 'repo-diffs', key: 'diffs' } as const, {} as Record<string, DiffState>)
219// each cut hunk's sideways pan, in columns: `fileKey#hunk index` -> offset
220const pan = atom({ plugin: 'repo-diffs', key: 'pan' } as const, {} as Record<string, number>)
221// the diff options row: folded until asked for; the keys it holds work while it shows
222const showOptions = atom({ plugin: 'repo-diffs', key: 'showOptions' } as const, false)
223// every block folds on its chevron: the header's detail, the file list, the
224// conflicts, and each hunk (`hunk:<fileKey>#<i>`)
225// the header's detail starts folded: the first thing on screen is the list and
226// a hunk, the PR reads as one line (impeccable, 2026-10-14); `open:checks`
227// is the one entry that means open, not shut (the failing checks list)
228const shut = atom({ plugin: 'repo-diffs', key: 'shut' } as const, ['sec:header'] as string[])
229// a change set past this many files starts with its top folders collapsed, so
230// the list reads as folders to open rather than a wall of paths; once per repo
231const FOLD_TREE_AT = 40
232const seeded = atom({ plugin: 'repo-diffs', key: 'seeded' } as const, [] as string[])
233const SEC_HEADER = 'sec:header'
234const SEC_FILES = 'sec:files'
235const SEC_CONFLICTS = 'sec:conflicts'
236// A viewed mark holds while both sides of the file's diff are what they were: the
237// old side by the commit the diff starts from (side), the new side by the file's
238// content id (blob, read for marked and selected files). Same counts with different
239// text, or the same text against a moved base, is a different change.
240const stampOf = (f: FileChange) =>
241  `${f.status}|${f.oldPath ?? ''}|${f.added}|${f.removed}|${f.isBinary ? 'b' : ''}|${f.side ?? ''}|${f.blob ?? ''}`
242
243// The content id of each path on the diff's new side: the working tree when the range
244// runs to it (one rev), else the range's last revision. One git call for the batch; if
245// that fails (a submodule path, a vanished file), each path alone, so one bad path costs
246// only itself. In the working tree a symlink is fingerprinted by its link text, as git
247// stores it, not by the file it reaches. A path with no id is left out.
248async function contentIds($: EngineInterface, root: string, range: readonly string[], paths: readonly string[]) {
249  const ids = new Map<string, string>()
250  if (paths.length === 0) return ids
251  const isWorktree = range.length === 1
252  const rev = range[range.length - 1] ?? 'HEAD'
253  const links: string[] = []
254  const plain: string[] = []
255  for (let i = 0; i < paths.length; i += STAT_BATCH) {
256    const chunk = paths.slice(i, i + STAT_BATCH)
257    const isLink = await Promise.all(
258      chunk.map(p => (isWorktree ? $.fs.stat(`${root}/${p}`).then(st => st.isLink === true, () => false) : Promise.resolve(false))),
259    )
260    chunk.forEach((p, k) => (isLink[k] ? links : plain).push(p))
261  }
262  const ask = (batch: readonly string[]) =>
263    git($, root, isWorktree ? ['hash-object', '--', ...batch] : ['rev-parse', ...batch.map(p => `${rev}:${p}`)]).catch(() => null)
264  const lines = (r: Awaited<ReturnType<typeof ask>>) =>
265    r && r.exitCode === 0 ? r.stdout.split('\n').filter(l => l.trim() !== '').map(l => l.trim()) : null
266  // a failed batch is halved until the bad paths stand alone: one bad path among n costs
267  // about 2·log2(n) calls, not n
268  const hashAll = async (batch: readonly string[]): Promise<void> => {
269    if (batch.length === 0) return
270    const got = lines(await ask(batch))
271    if (got !== null && got.length === batch.length) {
272      batch.forEach((p, i) => ids.set(p, got[i]!))
273      return
274    }
275    if (batch.length === 1) return
276    const half = Math.ceil(batch.length / 2)
277    await hashAll(batch.slice(0, half))
278    await hashAll(batch.slice(half))
279  }
280  await hashAll(plain)
281  for (const p of links) {
282    const link = await $.process.run(['readlink', '--', `${root}/${p}`], { timeoutMs: 5_000 }).catch(() => null)
283    if (!link || link.exitCode !== 0) continue
284    const text = link.stdout.replace(/\n$/, '')
285    const r = await $.process.run(['git', ...GIT_PREFIX, '-C', root, 'hash-object', '--stdin'], { stdin: text, timeoutMs: 5_000 }).catch(() => null)
286    const id = r && r.exitCode === 0 ? r.stdout.trim() : ''
287    if (id) ids.set(p, id)
288  }
289  return ids
290}
291// the hide patterns in force: none while hidden files are being shown
292const activeExclude = async ($: EngineInterface) => ((await read($, revealHidden)) ? [] : await read($, exclude))
293
294let cwdRoot: string | null = null
295let running: Promise<void> | null = null
296let queued = false
297let diffGen = 0
298let restGen = 0
299// the last wide drawing's file list: its width, and how far it can scroll,
300// so the wheel over it scrolls the list alone (drawing state only)
301let pinnedList: { cols: number; rows: number; maxScroll: number } | null = null
302// the load past its cache check, still running: a cache hit on the same file
303// gives the generation back to it rather than cancelling it
304let pendingLoad: { key: string; gen: number } | null = null
305let bashTimer: Timer | null = null
306let watchTimer: Timer | null = null
307let watchTicks = 0
308let isTicking = false
309let isSending = false
310const prInFlight = new Set<string>()
311// root\0ref of PR bases a refresh was already run for: a base that is not
312// fetched locally must not refresh forever
313const prBaseTried = new Map<string, string>()
314let commentSeq = 0
315// which hunk n/p last scrolled to, per selected file: drawing state only
316let hunkCursor: { key: string; at: number } = { key: '', at: -1 }
317// what each repo looked like after the last refresh: git-dir mtimes, status
318const statSigs = new Map<string, string>()
319const statusSigs = new Map<string, string>()
320
321const lines = (s: string) => s.split('\n').filter(l => l.length > 0)
322const dirname = (p: string) => p.replace(/\/[^/]*$/, '') || '/'
323const basename = (p: string) => p.replace(/\/+$/, '').replace(/^.*\//, '')
324const keyOf = (root: string, path: string) => `${root}\u0000${path}`
325const splitKey = (key: string) => key.split('\u0000') as [string, string]
326const records = (s: string) => s.split('\u0000').filter(r => r.length > 0)
327
328// --no-optional-locks: polling never takes index.lock from another session;
329// --literal-pathspecs: a file named `*.ts` is that file, not a glob;
330// suppressBlankEmpty off: blank context lines keep their leading space, so
331// hunk line counting (comment anchors) holds whatever the person's config
332const GIT_PREFIX = ['--no-optional-locks', '--literal-pathspecs', '-c', 'diff.suppressBlankEmpty=false']
333
334async function git($: EngineInterface, root: string, args: string[]) {
335  return $.process.run(['git', ...GIT_PREFIX, '-C', root, ...args], { timeoutMs: 15_000 })
336}
337
338async function toplevel($: EngineInterface, path: string): Promise<string | null> {
339  let dir = path
340  for (let i = 0; i < 6 && dir !== '/'; i++) {
341    const r = await git($, dir, ['rev-parse', '--show-toplevel']).catch(() => null)
342    if (r && r.exitCode === 0) return r.stdout.trim()
343    dir = dirname(dir)
344  }
345  return null
346}
347
348// This session's leased worktrees, or null when the ledger could not be read
349// (the caller then keeps the worktrees it already knew).
350async function ledgerTrees($: EngineInterface): Promise<Map<string, string> | null> {
351  // Read straight from the ledger's folder (no python3 to spawn). A folder that cannot be
352  // listed is an unreadable ledger (null: the caller keeps what it had); one unreadable
353  // lease (mid-write, another session's) is skipped, never the rest.
354  let dir: string
355  let entries: readonly { name: string; kind: string; isLink?: boolean }[]
356  try {
357    const home = await $.env.get('HOME')
358    if (!home) return null
359    dir = `${home}/${LEASES_DIR}`
360    entries = await $.fs.list(dir)
361  } catch {
362    return null
363  }
364  const sid = await $.session.id().catch(() => '')
365  const out = new Map<string, string>()
366  for (const entry of entries) {
367    // a lease folder may be a symlink (listed as other, isLink): its lease.json is read through it
368    if (entry.kind !== 'dir' && entry.isLink !== true) continue
369    try {
370      const l = JSON.parse(await $.fs.read(`${dir}/${entry.name}/lease.json`)) as Record<string, unknown>
371      if (l.session_id === sid && typeof l.worktree === 'string') {
372        out.set(l.worktree, typeof l.base_oid === 'string' ? l.base_oid : '')
373      }
374    } catch {
375      // mid-write or not a lease: skipped
376    }
377  }
378  return out
379}
380
381// What to diff against, in order: a ref the person picked, the PR's base
382// branch (a qa hotfix merges into qa, not develop), then develop/main/master,
383// then the session's claim base. The first whose merge-base resolves wins; a
384// wanted ref that does not is reported, not silently swapped.
385async function pickBase(
386  $: EngineInterface,
387  root: string,
388  leaseBase: string,
389  want: { picked?: string; pr?: string },
390): Promise<{ base: string; label: string; source: NonNullable<RepoSnap['baseSource']>; note?: string }> {
391  const tries: Array<{ ref: string; source: 'picked' | 'pr' | 'default' }> = []
392  // a ref that starts with - would reach git as an option, not a revision
393  if (want.picked && !want.picked.startsWith('-')) tries.push({ ref: want.picked, source: 'picked' })
394  if (want.pr && want.pr !== want.picked) tries.push({ ref: want.pr, source: 'pr' })
395  for (const ref of BASE_CANDIDATES) if (!tries.some(t => t.ref === ref)) tries.push({ ref, source: 'default' })
396  const missed: string[] = []
397  for (const t of tries) {
398    const r = await git($, root, ['merge-base', 'HEAD', t.ref]).catch(() => null)
399    if (r && r.exitCode === 0) {
400      const note = missed.length > 0 ? `${missed.join(', ')} not found locally (git fetch?)` : undefined
401      return { base: r.stdout.trim(), label: t.ref, source: t.source, ...(note ? { note } : {}) }
402    }
403    if (t.source !== 'default') missed.push(t.ref)
404  }
405  // none of the usual names: the default branch the remote says it has (origin/trunk, …)
406  const head = await git($, root, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']).catch(() => null)
407  const remoteDefault = head && head.exitCode === 0 ? head.stdout.trim() : ''
408  if (remoteDefault && !remoteDefault.startsWith('-') && !tries.some(t => t.ref === remoteDefault)) {
409    const r = await git($, root, ['merge-base', 'HEAD', remoteDefault]).catch(() => null)
410    if (r && r.exitCode === 0) {
411      const note = missed.length > 0 ? `${missed.join(', ')} not found locally (git fetch?)` : undefined
412      return { base: r.stdout.trim(), label: remoteDefault, source: 'default', ...(note ? { note } : {}) }
413    }
414  }
415  const note = missed.length > 0 ? `${missed.join(', ')} not found locally (git fetch?)` : undefined
416  if (leaseBase) return { base: leaseBase, label: `claim base ${leaseBase.slice(0, 8)}`, source: 'claim', ...(note ? { note } : {}) }
417  return { base: 'HEAD', label: 'HEAD', source: 'head', ...(note ? { note } : {}) }
418}
419
420async function snap(
421  $: EngineInterface,
422  root: string,
423  leaseBase: string,
424  source: RepoSnap['source'],
425  previous: RepoSnap | undefined,
426  scope: Scope,
427  want: { picked?: string; pr?: PrEntry } = {},
428  view: { commit?: string; ignoreWhitespace?: boolean; selected?: string } = {},
429): Promise<RepoSnap> {
430  const name = basename(root)
431  try {
432    const [head, headRev] = await Promise.all([
433      git($, root, ['rev-parse', '--abbrev-ref', 'HEAD']),
434      // its own call: --symbolic-full-name would apply to a second HEAD too
435      git($, root, ['rev-parse', '--verify', 'HEAD']).catch(() => null),
436    ])
437    if (head.exitCode !== 0) throw new Error(head.stderr.trim() || 'not a git repo')
438    const headSha = headRev && headRev.exitCode === 0 ? headRev.stdout.trim() || undefined : undefined
439    // a cached PR answer counts only for the branch and commit it was read for
440    const prInfo = want.pr && want.pr.key === `${head.stdout.trim()}@${headSha ?? ''}` ? want.pr.info : undefined
441    const prBase = prInfo?.kind === 'pr' && prInfo.baseRefName ? `origin/${prInfo.baseRefName}` : undefined
442    const picked =
443      scope === 'touched'
444        ? { base: 'HEAD', label: 'HEAD', source: 'head' as const, note: undefined }
445        : await pickBase($, root, leaseBase, { ...(want.picked ? { picked: want.picked } : {}), ...(prBase ? { pr: prBase } : {}) })
446    const { base } = picked
447    // the branch's commits since its base (lazygit's commits panel); one picked
448    // shows only what it introduced
449    const log = scope === 'touched' || base === 'HEAD' ? null : await git($, root, [...LOG_ARGS, `${base}..HEAD`]).catch(() => null)
450    const commits = log && log.exitCode === 0 ? parseLog(log.stdout) : []
451    // the last-turn scope is always the working tree against the base: a picked commit waits
452    const commit = view.commit && scope !== 'turn' ? commits.find(c => c.sha === view.commit) : undefined
453    const label = commit ? `commit ${commit.short}` : picked.label
454    const range = commit ? commitRange(commit) : scope === 'committed' ? [base, 'HEAD'] : [base]
455    const withUntracked = scope !== 'committed' && !commit
456    // -w on the listing too: a whitespace-only file leaves the tree, not just its patch
457    const listFlags = view.ignoreWhitespace ? ['-w'] : []
458    const noFiles = { exitCode: 0, stdout: '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false }
459    // -z: paths come raw, never C-quoted (tabs, quotes, non-ASCII)
460    const [num, stat, untracked, dirs, unmerged, baseRefFull] = await Promise.all([
461      git($, root, ['diff', '--numstat', '-z', ...PATCH_FLAGS, ...listFlags, ...range]),
462      git($, root, ['diff', '--name-status', '-z', ...PATCH_FLAGS, ...listFlags, ...range]),
463      withUntracked ? git($, root, ['ls-files', '--others', '--exclude-standard', '-z']) : Promise.resolve(noFiles),
464      git($, root, ['rev-parse', '--absolute-git-dir', '--git-common-dir', '--symbolic-full-name', 'HEAD']).catch(() => null),
465      withUntracked ? git($, root, ['ls-files', '-u', '-z']) : Promise.resolve(noFiles),
466      // the base as git names it (refs/remotes/origin/qa, refs/heads/x,
467      // refs/tags/v1), for the watcher; a sha or HEAD~3 prints nothing. No
468      // --end-of-options: rev-parse echoes it; a picked ref starting with -
469      // never gets here (pickBase drops it)
470      picked.source === 'head' || picked.source === 'claim'
471        ? Promise.resolve(null)
472        : git($, root, ['rev-parse', '--symbolic-full-name', picked.label]).catch(() => null),
473    ])
474    for (const r of [num, stat, untracked, unmerged]) {
475      if (r.exitCode !== 0) throw new Error(r.stderr.trim() || 'git failed')
476      if (r.isStdoutTruncated) throw new Error('git output too large')
477    }
478    const status = parseNameStatus(stat.stdout)
479    const files: FileChange[] = parseNumstat(num.stdout).map(row => ({ ...row, status: status.get(row.path) ?? 'M' }))
480    for (const path of records(untracked.stdout)) files.push({ path, status: '?', added: 0, removed: 0 })
481    // conflicts belong to the working tree: shown in the views that include it
482    if (withUntracked) {
483      for (const path of parseUnmerged(unmerged.stdout)) {
484        const f = files.find(x => x.path === path)
485        if (f) f.status = '!'
486        else files.push({ path, status: '!', added: 0, removed: 0 })
487      }
488    }
489    files.sort((x, y) => x.path.localeCompare(y.path))
490    // last turn: the all-scope listing, kept to the files the latest turn edited
491    if (scope === 'turn') {
492      const edited = new Set((await read($, turnEdits)).paths)
493      const kept = files.filter(f => edited.has(keyOf(root, f.path)) || (f.oldPath !== undefined && edited.has(keyOf(root, f.oldPath))))
494      files.splice(0, files.length, ...kept)
495    }
496    // the selected file too: a mark made on it certifies the text this listing read
497    const marks = await read($, viewed)
498    const selected = view.selected
499    const wanted = files
500      .filter(f => f.status !== 'D' && (marks[keyOf(root, f.path)] !== undefined || keyOf(root, f.path) === selected))
501      .map(f => f.path)
502    const ids = await contentIds($, root, range, wanted)
503    const side = range.map(r => (r === 'HEAD' ? (headSha ?? r) : r)).join('..')
504    for (const f of files) {
505      f.side = side
506      const id = ids.get(f.path)
507      if (id) f.blob = id
508    }
509    const w = watchInfo(root, dirs)
510    const op = w ? await inProgress($, w.gitDir) : undefined
511    return {
512      root,
513      name,
514      branch: head.stdout.trim(),
515      base,
516      baseLabel: label,
517      source,
518      files,
519      range,
520      // the base ref in use, apart from the label (a commit view's label is
521      // "commit abc1234"); watched whatever is on screen
522      baseUsed: picked.label,
523      watch: w && { ...w, baseRef: refOf(baseRefFull) },
524      ...(op ? { op } : {}),
525      ...(headSha ? { head: headSha } : {}),
526      baseSource: picked.source,
527      ...(picked.note ? { baseNote: picked.note } : {}),
528      ...(commits.length > 0 ? { commits } : {}),
529      ...(commit ? { commit } : {}),
530    }
531  } catch (err) {
532    // a failed or timed-out call (index.lock, a rebase) keeps the last good
533    // listing, marked stale, rather than flashing "no changes"
534    if (previous) return { ...previous, source, isStale: true }
535    return { root, name, branch: '?', base: 'HEAD', baseLabel: '?', source, files: [], range: ['HEAD'], error: String(err) }
536  }
537}
538
539// Where to look for commits, checkouts and staging in this repo; a linked
540// worktree keeps HEAD and index in its own git dir, refs in the common one.
541function watchInfo(root: string, r: { exitCode: number; stdout: string } | null): RepoSnap['watch'] {
542  if (!r || r.exitCode !== 0) return undefined
543  const [gitDir, common, ref] = lines(r.stdout)
544  if (!gitDir || !common) return undefined
545  const commonDir = common.startsWith('/') ? common : `${root}/${common}`
546  return { gitDir, commonDir, ref: ref?.startsWith('refs/') ? ref : '', baseRef: '' }
547}
548
549// The base candidate in use (origin/develop) as a loose ref path: a fetch
550// elsewhere moves the merge-base without touching HEAD or the index.
551// git's own name for the base (rev-parse --symbolic-full-name): a refs/...
552// path to watch, or none for a sha or an expression.
553const refOf = (r: { exitCode: number; stdout: string } | null) => {
554  const name = r && r.exitCode === 0 ? r.stdout.trim() : ''
555  return name.startsWith('refs/') ? name : ''
556}
557
558// A rebase, merge, cherry-pick or revert stopped part-way, by what it leaves
559// in the git dir; the first found.
560async function inProgress($: EngineInterface, gitDir: string): Promise<RepoSnap['op']> {
561  const has = (f: string) => $.fs.exists(`${gitDir}/${f}`).catch(() => false)
562  for (const { file, op } of IN_PROGRESS) {
563    if (!(await has(file))) continue
564    // `git am` stops in rebase-apply too, marked by an `applying` file
565    if (file === 'rebase-apply' && (await has('rebase-apply/applying'))) return 'am'
566    return op
567  }
568  return undefined
569}
570
571async function mtime($: EngineInterface, path: string): Promise<number> {
572  try {
573    return (await $.fs.stat(path)).mtimeMs
574  } catch {
575    return 0
576  }
577}
578
579async function statSig($: EngineInterface, w: NonNullable<RepoSnap['watch']>): Promise<string> {
580  const paths = [`${w.gitDir}/HEAD`, `${w.gitDir}/index`, `${w.commonDir}/packed-refs`]
581  if (w.ref) paths.push(`${w.commonDir}/${w.ref}`)
582  if (w.baseRef) paths.push(`${w.commonDir}/${w.baseRef}`)
583  // a rebase/merge stopping or ending (even `rebase --quit`, which leaves
584  // HEAD and the index alone) adds or removes one of these
585  for (const { file } of IN_PROGRESS) paths.push(`${w.gitDir}/${file}`)
586  return (await Promise.all(paths.map(p => mtime($, p)))).join(':')
587}
588
589async function statusSig($: EngineInterface, root: string): Promise<string | null> {
590  const r = await git($, root, ['status', '--porcelain', '-z', '--untracked-files=all']).catch(() => null)
591  if (!r || r.exitCode !== 0) return null
592  // records are "XY path"; a rename adds its old path as the next record
593  const paths: string[] = []
594  const recs = records(r.stdout)
595  for (let i = 0; i < recs.length && paths.length < STATUS_STAT_CAP; i++) {
596    const rec = recs[i]!
597    paths.push(rec.slice(3))
598    if (rec[0] === 'R' || rec[0] === 'C') i++
599  }
600  const times = await Promise.all(paths.map(p => mtime($, `${root}/${p}`)))
601  return `${r.stdout}\u0000${times.join(':')}`
602}
603
604type Sigs = Map<string, { stat: string; status: string | null }>
605
606async function takeSigs($: EngineInterface, snaps: readonly RepoSnap[]): Promise<Sigs> {
607  const out: Sigs = new Map()
608  await Promise.all(
609    snaps.map(async s => {
610      if (s.watch) out.set(s.root, { stat: await statSig($, s.watch), status: await statusSig($, s.root) })
611    }),
612  )
613  return out
614}
615
616// The baseline the watcher compares against. Taken BEFORE the listing where
617// the repo was already known, so a commit landing mid-refresh shows up as a
618// change on the next tick instead of being absorbed; a new repo is taken
619// after. Pane reads never move it (--no-optional-locks).
620async function recordSigs($: EngineInterface, snaps: readonly RepoSnap[], before: Sigs) {
621  const after = await takeSigs($, snaps.filter(s => !before.has(s.root)))
622  statSigs.clear()
623  statusSigs.clear()
624  for (const s of snaps) {
625    const sig = before.get(s.root) ?? after.get(s.root)
626    if (!sig) continue
627    statSigs.set(s.root, sig.stat)
628    if (sig.status !== null) statusSigs.set(s.root, sig.status)
629  }
630}
631
632// Another session may commit, stage or edit in a leased worktree; this
633// session's tool calls never see that, so look for it on a timer.
634// Whether the pane is up; a host that cannot say counts as up, so watching never
635// stops for want of an answer.
636async function isPaneUp($: EngineInterface) {
637  try {
638    return (await $.ui.panes()).some(p => p.id === PANE)
639  } catch {
640    return true
641  }
642}
643
644async function watchTick($: EngineInterface) {
645  if (running || isTicking) return
646  // nobody is looking: stop polling git; the next refresh (an edit, /diffs) starts it again
647  if (!(await isPaneUp($))) {
648    watchTimer?.cancel()
649    watchTimer = null
650    return
651  }
652  isTicking = true
653  try {
654    watchTicks++
655    const isSlowTick = watchTicks % STATUS_EVERY === 0
656    const snaps = await read($, snapshot)
657    let changed = false
658    for (const s of snaps) {
659      // a repo that failed or has nothing to watch is retried, not forgotten
660      if (!s.watch || s.isStale || s.error) {
661        if (isSlowTick) changed = true
662        continue
663      }
664      if ((await statSig($, s.watch)) !== statSigs.get(s.root)) changed = true
665    }
666    if (!changed && isSlowTick) {
667      for (const s of snaps) {
668        if (!s.watch) continue
669        const st = await statusSig($, s.root)
670        if (st !== null && st !== statusSigs.get(s.root)) changed = true
671      }
672    }
673    if (changed) await refresh($)
674    else if (isSlowTick) void refreshPrs($, snaps).catch(() => undefined)
675  } catch {
676    // a failed poll waits for the next tick
677  } finally {
678    isTicking = false
679  }
680}
681
682const optionsOf = (v: View): DiffOptions => ({
683  context: v.context ?? DEFAULT_OPTIONS.context,
684  ignoreWhitespace: v.ignoreWhitespace ?? DEFAULT_OPTIONS.ignoreWhitespace,
685})
686
687// The file's patch, cleaned for Code; throws with a reason the card shows.
688// Context and -w go on this call only: -U on --numstat would print patches.
689async function fileDiff($: EngineInterface, s: RepoSnap, f: FileChange, isFull: boolean, o: DiffOptions): Promise<string> {
690  const isUntracked = f.status === '?'
691  const flags = diffFlags(o, isFull)
692  const r = isUntracked
693    ? await git($, s.root, ['diff', '--no-index', '--no-color', '--no-ext-diff', ...flags, '--', '/dev/null', f.path])
694    : await git($, s.root, [
695        'diff',
696        ...PATCH_FLAGS,
697        ...flags,
698        ...(s.range ?? [s.base]),
699        '--',
700        ...(f.oldPath ? [f.oldPath, f.path] : [f.path]),
701      ])
702  // --no-index exits 1 when the files differ, which is the point
703  const isOk = r.exitCode === 0 || (isUntracked && r.exitCode === 1)
704  if (!isOk) throw new Error(r.stderr.trim().split('\n')[0] || `git exited ${r.exitCode}`)
705  if (r.isStdoutTruncated) throw new Error('This diff is larger than 4 MB; open it in your editor.')
706  return cleanPatch(f.oldPath ? pickSection(r.stdout, f.path) : r.stdout)
707}
708
709// The files the tree lists: all but those an exclude glob hides. A conflict
710// is never hidden: it needs resolving whatever its name.
711function shownFiles(s: RepoSnap, hidden: readonly string[]): FileChange[] {
712  return hidden.length === 0 ? s.files : s.files.filter(f => f.status === '!' || !isExcluded(f.path, hidden))
713}
714
715// The active repo and selected file, kept valid against a new snapshot.
716function settleView(v: View, snaps: readonly RepoSnap[], hidden: readonly string[] = []): View {
717  const repo = snaps.find(s => s.root === v.repo) ?? snaps[0]
718  if (!repo) return { ...v, repo: null, selected: null }
719  const order = fileOrder(buildTree(shownFiles(repo, hidden)))
720  const keys = new Set(order.map(f => keyOf(repo.root, f.path)))
721  const selected = v.selected && keys.has(v.selected) ? v.selected : order[0] ? keyOf(repo.root, order[0].path) : null
722  // forget preferences for files and folders that left every diff
723  const allFiles = new Set(snaps.flatMap(x => x.files.map(f => keyOf(x.root, f.path))))
724  const allDirs = new Set(
725    snaps.flatMap(x => visibleRows(buildTree(x.files), new Set()).flatMap(n => (n.kind === 'dir' ? [keyOf(x.root, n.path)] : []))),
726  )
727  return {
728    repo: repo.root,
729    selected,
730    scope: v.scope ?? 'all',
731    ...(v.wrap ? { wrap: true } : {}),
732    ...(v.context !== undefined ? { context: v.context } : {}),
733    ...(v.ignoreWhitespace ? { ignoreWhitespace: true } : {}),
734    ...(v.syntax ? { syntax: true } : {}),
735    ...(v.folded?.length ? { folded: v.folded.filter(k => allFiles.has(k)) } : {}),
736    // a picked commit stays only while the repo still lists it
737    // (an older refresh that started before the pick lists it too, so it no
738    // longer drops a pick made while it ran)
739    ...(v.commit && snaps.some(x => x.root === v.commit!.root && x.commits?.some(c => c.sha === v.commit!.sha))
740      ? { commit: v.commit }
741      : {}),
742    full: v.full.filter(k => allFiles.has(k)),
743    collapsed: v.collapsed.filter(k => allDirs.has(k)),
744  }
745}
746
747// Loads the selected file's diff. Every call takes a generation first, before
748// any await, and every write checks it inside the update, so an older, slower
749// load can never overwrite a newer selection (update may re-run its fn). A
750// cache hit on the file a load is already fetching (a refresh, a scope
751// change) hands the generation back to that load instead of cancelling it.
752async function loadDiff($: EngineInterface, force: boolean) {
753  await loadSelected($, force)
754  await loadRest($, force)
755}
756
757// the cache key of a file's diff: the range and the flags it was read with
758const rangeKey = (s: RepoSnap | undefined, opts: DiffOptions, isFull: boolean) =>
759  `${(s?.range ?? [s?.base ?? '']).join(' ')}|${diffFlags(opts, isFull).join(' ')}`
760
761// whether a file's container is open: not folded by its chevron, not viewed
762// (unless opened again), and, unless selected, not too large to draw unasked
763function isOpenFile(v: View, key: string, f: FileChange, marks: Record<string, string>, opened: readonly string[]) {
764  if ((v.folded ?? []).includes(key)) return false
765  if (opened.includes(key)) return true
766  if (marks[key] === stampOf(f)) return false
767  return key === v.selected || f.added + f.removed <= LARGE_LINES
768}
769
770// Loads the diffs of the open containers drawn besides the selected one, a few
771// git calls at a time; what left the drawn window leaves the cache. A newer
772// call supersedes an older one between batches.
773// the first file container drawn: the list's top, or the selected file's own
774// place once it sits past the first window, so it and the files after it show
775const windowStart = (selIndex: number, limit: number) => (selIndex >= limit ? selIndex : 0)
776
777async function loadRest($: EngineInterface, force: boolean) {
778  const gen = ++restGen
779  const v = await read($, view)
780  const s = (await read($, snapshot)).find(x => x.root === v.repo)
781  if (!s) {
782    await update($, diffs, m => (gen === restGen ? {} : m))
783    return
784  }
785  const order = fileOrder(buildTree(shownFiles(s, await activeExclude($))))
786  const marks = await read($, viewed)
787  const opened = await read($, openViewed)
788  const limit = await read($, fileLimit)
789  const opts = optionsOf(v)
790  const cache = await read($, diffs)
791  // the selected file past the window is loadSelected's, never this cache's
792  const at = order.findIndex(f => keyOf(s.root, f.path) === v.selected)
793  const start = windowStart(at, limit)
794  const want = order.slice(start, start + limit).filter(f => {
795    const k = keyOf(s.root, f.path)
796    return k !== v.selected && isOpenFile(v, k, f, marks, opened)
797  })
798  const wanted = new Set(want.map(f => keyOf(s.root, f.path)))
799  const todo: { f: FileChange; key: string; isFull: boolean; range: string; stamp: string }[] = []
800  for (const f of want) {
801    const key = keyOf(s.root, f.path)
802    const isFull = v.full.includes(key)
803    const range = rangeKey(s, opts, isFull)
804    const c = cache[key]
805    const isSame = c && c.status === 'ready' && c.range === range && c.isFull === isFull && c.stamp === stampOf(f)
806    if (force || !isSame) todo.push({ f, key, isFull, range, stamp: stampOf(f) })
807  }
808  // what left the window goes; a reloading diff keeps its old text meanwhile
809  await update($, diffs, m => (gen === restGen ? Object.fromEntries(Object.entries(m ?? {}).filter(([k]) => wanted.has(k))) : m))
810  for (let i = 0; i < todo.length; i += LOAD_PARALLEL) {
811    const done = await Promise.all(
812      todo.slice(i, i + LOAD_PARALLEL).map(async (t): Promise<DiffState> => {
813        try {
814          return { key: t.key, isFull: t.isFull, range: t.range, stamp: t.stamp, text: await fileDiff($, s, t.f, t.isFull, opts), status: 'ready' }
815        } catch (err) {
816          const error = err instanceof Error ? err.message : String(err)
817          return { key: t.key, isFull: t.isFull, range: t.range, stamp: t.stamp, text: '', status: 'error', error }
818        }
819      }),
820    )
821    if (gen !== restGen) return
822    await update($, diffs, m => (gen === restGen ? { ...(m ?? {}), ...Object.fromEntries(done.map(d => [d.key, d])) } : m))
823  }
824}
825
826async function loadSelected($: EngineInterface, force: boolean) {
827  const gen = ++diffGen
828  const v = await read($, view)
829  const cur = await read($, diff)
830  const key = v.selected
831  if (!key) {
832    await update($, diff, d => (gen === diffGen ? null : d))
833    return
834  }
835  const isFull = v.full.includes(key)
836  const [root, path] = splitKey(key)
837  const s = (await read($, snapshot)).find(x => x.root === root)
838  const f = s?.files.find(x => x.path === path)
839  const opts = optionsOf(v)
840  // the cache knows the range and the flags it was read with
841  const range = rangeKey(s, opts, isFull)
842  const isSame = cur?.key === key && cur.isFull === isFull && cur.status === 'ready' && cur.range === range
843  if (!force && isSame) {
844    if (gen === diffGen && pendingLoad?.key === key) diffGen = pendingLoad.gen
845    return
846  }
847  if (!s || !f) return
848  pendingLoad = { key, gen }
849  const loading: DiffState = { key, isFull, range, text: '', status: 'loading' }
850  if (!isSame) await update($, diff, d => (gen === diffGen ? loading : d))
851  let next: DiffState
852  try {
853    next = { key, isFull, range, text: await fileDiff($, s, f, isFull, opts), status: 'ready' }
854  } catch (err) {
855    next = { key, isFull, range, text: '', status: 'error', error: err instanceof Error ? err.message : String(err) }
856  }
857  await update($, diff, d => (gen === diffGen ? next : d))
858  if (pendingLoad?.gen === gen) pendingLoad = null
859}
860
861async function refreshOnce($: EngineInterface) {
862  const leases = await ledgerTrees($)
863  const touchedRoots = await read($, touched)
864  const previous = await read($, snapshot)
865  const scope = (await read($, view)).scope ?? 'all'
866  const before = await takeSigs($, previous.filter(p => p.watch && !p.isStale && !p.error))
867  const roots = new Map<string, RepoSnap['source']>()
868  for (const r of touchedRoots) roots.set(r, 'touched')
869  const leased = leases ?? new Map(previous.filter(p => p.source !== 'touched').map(p => [p.root, '']))
870  for (const r of leased.keys()) roots.set(r, roots.has(r) ? 'both' : 'ledger')
871  if (cwdRoot) roots.delete(cwdRoot)
872  const overrides = await read($, baseOverride)
873  const prCache = await read($, prs)
874  const wantFor = (root: string) => ({
875    ...(overrides[root] ? { picked: overrides[root] } : {}),
876    ...(prCache[root] ? { pr: prCache[root] } : {}),
877  })
878  const cur = await read($, view)
879  const viewFor = (root: string) => ({
880    ...(cur.commit && cur.commit.root === root ? { commit: cur.commit.sha } : {}),
881    ...(cur.ignoreWhitespace ? { ignoreWhitespace: true } : {}),
882    ...(cur.selected ? { selected: cur.selected } : {}),
883  })
884  const snaps = await Promise.all(
885    [...roots].map(([root, src]) =>
886      snap($, root, leased.get(root) ?? '', src, previous.find(p => p.root === root), scope, wantFor(root), viewFor(root)),
887    ),
888  )
889  await update($, snapshot, () => snaps)
890  await update($, refreshedAt, () => Date.now())
891  const hidden = await activeExclude($)
892  await update($, view, v => settleView(v ?? EMPTY_VIEW, snaps, hidden))
893  await seedCollapsed($, snaps, hidden)
894  await noteSelectedBlob($)
895  await loadDiff($, true)
896  await recordSigs($, snaps, before)
897  // restarted on every refresh: a refused period ends an interval silently
898  if (snaps.length > 0) {
899    watchTimer?.cancel()
900    watchTimer = $.clock.every(WATCH_MS, () => void watchTick($))
901  }
902  if (snaps.length > 0 && !(await read($, autoOpened))) {
903    await update($, autoOpened, () => true)
904    await $.ui.open({ id: PANE, title: TITLE })
905  }
906  // network, so never on the listing's path
907  void refreshPrs($, snaps).catch(() => undefined)
908}
909
910// The branch's pull request per repo, via gh in the repo's folder. Re-read
911// when branch@head changes or the answer is older than PR_TTL_MS; one call
912// per repo at a time.
913const prKey = (s: RepoSnap) => `${s.branch}@${s.head ?? ''}`
914
915async function refreshPrs($: EngineInterface, snaps: readonly RepoSnap[]) {
916  // the engine's clock, so the TTL follows a test's mock clock too
917  const now = await $.clock.now().catch(() => Date.now())
918  const cache = await read($, prs)
919  await Promise.all(
920    snaps.map(async s => {
921      if (!s.head || s.error || prInFlight.has(s.root)) return
922      const key = prKey(s)
923      const had = cache[s.root]
924      if (had && had.key === key && now - had.at < PR_TTL_MS) return
925      prInFlight.add(s.root)
926      try {
927        let info: PrInfo
928        try {
929          // the branch named, so a checkout mid-call cannot change the question
930          const which = s.branch && s.branch !== 'HEAD' ? [s.branch] : []
931          const r = await $.process.run(['gh', 'pr', 'view', ...which, '--json', PR_FIELDS], {
932            cwd: s.root,
933            timeoutMs: PR_TIMEOUT_MS,
934          })
935          info =
936            r.exitCode !== 0
937              ? prFailure(r.stderr)
938              : r.isStdoutTruncated
939                ? { kind: 'error', message: 'gh output too large' }
940                : parsePrView(r.stdout)
941        } catch (err) {
942          info = { kind: 'error', message: err instanceof Error ? err.message : String(err) }
943        }
944        await update($, prs, m => ({ ...(m ?? {}), [s.root]: { key, at: now, info } }))
945        // the PR merges somewhere else than the base in use: list again, once
946        // per root and base (an unfetched base would otherwise loop)
947        const prBase = info.kind === 'pr' && info.baseRefName ? `origin/${info.baseRefName}` : ''
948        // compared with the base in use, never the label (a commit view shows
949        // "commit abc1234"); the latest attempt per root is remembered, so a
950        // retarget qa → main → qa still re-lists while an unfetched base does not loop
951        if (prBase && prBase !== s.baseUsed && s.baseSource !== 'picked' && prBaseTried.get(s.root) !== prBase) {
952          prBaseTried.set(s.root, prBase)
953          void refresh($).catch(() => undefined)
954        }
955      } finally {
956        prInFlight.delete(s.root)
957      }
958      // the branch moved while gh answered: ask again for the current one
959      const now2 = (await read($, snapshot)).find(x => x.root === s.root)
960      if (now2 && now2.head && prKey(now2) !== key) await refreshPrs($, [now2])
961    }),
962  )
963}
964
965// one refresh at a time; a request during a run schedules exactly one more
966function refresh($: EngineInterface): Promise<void> {
967  if (running) {
968    queued = true
969    return running
970  }
971  running = (async () => {
972    await update($, busy, () => true)
973    do {
974      queued = false
975      await refreshOnce($)
976    } while (queued)
977  })().finally(() => {
978    running = null
979    void update($, busy, () => false).catch(() => undefined)
980  })
981  return running
982}
983
984// Bash calls come in bursts: refresh once they go quiet
985function refreshSoon($: EngineInterface) {
986  bashTimer?.cancel()
987  bashTimer = $.clock.after(BASH_DEBOUNCE_MS, () => {
988    bashTimer = null
989    void refresh($)
990  })
991}
992
993// The selected file's content id, read as its diff is about to load, so a mark made
994// on it certifies the text the pane shows (a listing hashes it only once selected).
995async function noteSelectedBlob($: EngineInterface) {
996  const key = (await read($, view))?.selected
997  if (!key) return
998  const s = (await read($, snapshot)).find(x => key.startsWith(`${x.root}\u0000`))
999  const f = s?.files.find(x => keyOf(s.root, x.path) === key)
1000  if (!s || !f || f.blob || f.status === 'D') return
1001  const id = (await contentIds($, s.root, s.range, [f.path])).get(f.path)
1002  if (!id) return
1003  await update($, snapshot, snaps =>
1004    (snaps ?? []).map(x =>
1005      x.root !== s.root ? x : { ...x, files: x.files.map(y => (y.path === f.path && stampOf(y) === stampOf(f) ? { ...y, blob: id } : y)) },
1006    ),
1007  )
1008}
1009
1010async function select($: EngineInterface, key: string) {
1011  // a folder collapsed around the file opens: the selected row is always drawn
1012  const [root, path] = splitKey(key)
1013  const isAround = (k: string) => {
1014    const [r, dir] = splitKey(k)
1015    return r === root && path.startsWith(`${dir}/`)
1016  }
1017  await update($, view, v => {
1018    const cur = v ?? EMPTY_VIEW
1019    return { ...cur, selected: key, collapsed: cur.collapsed.filter(k => !isAround(k)) }
1020  })
1021  await noteSelectedBlob($)
1022  await loadDiff($, false)
1023}
1024
1025async function step($: EngineInterface, by: 1 | -1) {
1026  const v = await read($, view)
1027  const s = (await read($, snapshot)).find(x => x.root === v.repo)
1028  if (!s) return
1029  const order = fileOrder(buildTree(shownFiles(s, await activeExclude($)))).map(f => keyOf(s.root, f.path))
1030  const at = v.selected ? order.indexOf(v.selected) : -1
1031  const next = order[(at + by + order.length) % order.length]
1032  if (next) {
1033    await select($, next)
1034    await reveal($, next)
1035  }
1036}
1037
1038// a tree row: select the file and bring its container into view
1039async function jump($: EngineInterface, key: string) {
1040  await select($, key)
1041  await reveal($, key)
1042}
1043
1044// brings a file's container to the top of the pane, as GitHub's tree jumps;
1045// quiet when it cannot (the file is not drawn, the surface does not scroll)
1046async function reveal($: EngineInterface, key: string) {
1047  await $.ui.scroll({ to: { key: `file:${key}` }, in: PANE, block: 'start' }).catch(() => undefined)
1048}
1049
1050// a container's chevron: folds it, or opens it (past a viewed fold too)
1051async function toggleFold($: EngineInterface, key: string, isOpen: boolean) {
1052  await update($, view, v => {
1053    const cur = v ?? EMPTY_VIEW
1054    const folded = (cur.folded ?? []).filter(k => k !== key)
1055    return { ...cur, folded: isOpen ? [...folded, key] : folded }
1056  })
1057  await update($, openViewed, list => {
1058    const rest = (list ?? []).filter(k => k !== key)
1059    return isOpen ? rest : [...rest, key]
1060  })
1061  await loadRest($, false)
1062}
1063
1064// collapse all (keys: every file shown) / expand all (keys: the viewed ones,
1065// opened too); a large diff still waits for its own "load diff", as on GitHub
1066async function foldAll($: EngineInterface, keys: string[], isFolding: boolean) {
1067  await update($, view, v => ({ ...(v ?? EMPTY_VIEW), folded: isFolding ? keys : [] }))
1068  await update($, openViewed, () => (isFolding ? [] : keys))
1069  await loadRest($, false)
1070}
1071
1072async function showMoreFiles($: EngineInterface, limit: number) {
1073  await update($, fileLimit, () => limit)
1074  await loadRest($, false)
1075}
1076
1077async function toggleFull($: EngineInterface, key: string) {
1078  await update($, view, v => {
1079    const cur = v ?? EMPTY_VIEW
1080    const full = cur.full.includes(key) ? cur.full.filter(k => k !== key) : [...cur.full, key]
1081    return { ...cur, full }
1082  })
1083  await loadDiff($, false)
1084}
1085
1086// a repo's first listing past FOLD_TREE_AT files starts with its top folders
1087// collapsed (the selected file's own folder stays open: its row is always drawn)
1088async function seedCollapsed($: EngineInterface, snaps: readonly RepoSnap[], hidden: readonly string[]) {
1089  const done = await read($, seeded)
1090  const fresh = snaps.filter(s => !done.includes(s.root))
1091  if (fresh.length === 0) return
1092  await update($, seeded, list => [...new Set([...(list ?? []), ...fresh.map(s => s.root)])])
1093  const keys = fresh.flatMap(s => {
1094    const files = shownFiles(s, hidden)
1095    if (files.length <= FOLD_TREE_AT) return []
1096    return buildTree(files).flatMap(n => (n.kind === 'dir' ? [keyOf(s.root, n.path)] : []))
1097  })
1098  if (keys.length === 0) return
1099  await update($, view, v => {
1100    const cur = v ?? EMPTY_VIEW
1101    const [selRoot, selPath] = cur.selected ? splitKey(cur.selected) : ['', '']
1102    const holdsSelected = (k: string) => {
1103      const [r, dir] = splitKey(k)
1104      return r === selRoot && selPath.startsWith(`${dir}/`)
1105    }
1106    return { ...cur, collapsed: [...new Set([...cur.collapsed, ...keys.filter(k => !holdsSelected(k))])] }
1107  })
1108}
1109
1110// a block's chevron: folds it or opens it
1111async function toggleShut($: EngineInterface, key: string) {
1112  await update($, shut, list => {
1113    const cur = list ?? []
1114    return cur.includes(key) ? cur.filter(k => k !== key) : [...cur, key]
1115  })
1116}
1117
1118async function toggleDir($: EngineInterface, key: string) {
1119  await update($, view, v => {
1120    const cur = v ?? EMPTY_VIEW
1121    const collapsed = cur.collapsed.includes(key) ? cur.collapsed.filter(k => k !== key) : [...cur.collapsed, key]
1122    return { ...cur, collapsed }
1123  })
1124}
1125
1126async function pickRepo($: EngineInterface, root: string) {
1127  const snaps = await read($, snapshot)
1128  const hidden = await activeExclude($)
1129  await update($, view, v => settleView({ ...(v ?? EMPTY_VIEW), repo: root, selected: null }, snaps, hidden))
1130  await loadDiff($, false)
1131}
1132
1133// A new scope is a new listing: the files and the base both change.
1134// n/p: scroll the pane to the next or previous hunk of the selected file,
1135// wrapping at either end.
1136async function stepHunk($: EngineInterface, by: 1 | -1, count: number, key: string) {
1137  if (count === 0) return
1138  // moved before any await, so two quick presses land on two hunks
1139  const at = nextHunkIndex(hunkCursor.key === key ? hunkCursor.at : -1, by, count)
1140  hunkCursor = { key, at }
1141  try {
1142    const r = await $.ui.scroll({ to: { key: `hunk:${at}` }, in: PANE, block: 'start' })
1143    if (r.deny) $.ui.toast(`Repo diffs: could not scroll (${r.deny})`)
1144  } catch (err) {
1145    $.ui.toast(`Repo diffs: could not scroll (${err instanceof Error ? err.message : String(err)})`)
1146  }
1147}
1148
1149// New hide globs: kept for later sessions, and a hidden selection moves on.
1150async function setExclude($: EngineInterface, patterns: string[]) {
1151  await update($, exclude, () => patterns)
1152  await update($, editingHide, () => false)
1153  await update($, revealHidden, () => false)
1154  await $.store.set('exclude', patterns).catch(() => undefined)
1155  const snaps = await read($, snapshot)
1156  await update($, view, v => settleView(v ?? EMPTY_VIEW, snaps, patterns))
1157  await loadDiff($, false)
1158}
1159
1160// "show" / "hide again": hidden files come and go; the saved patterns stay
1161async function toggleReveal($: EngineInterface) {
1162  await update($, revealHidden, r => !r)
1163  const snaps = await read($, snapshot)
1164  const active = await activeExclude($)
1165  await update($, view, v => settleView(v ?? EMPTY_VIEW, snaps, active))
1166  await loadDiff($, false)
1167}
1168
1169// A base for this repo, or null for automatic (PR base, then develop/main/master).
1170async function setBase($: EngineInterface, root: string, ref: string | null) {
1171  await update($, baseOverride, m => {
1172    const next = { ...(m ?? {}) }
1173    if (ref) next[root] = ref
1174    else delete next[root]
1175    return next
1176  })
1177  await update($, editingBase, () => false)
1178  await refresh($)
1179}
1180
1181async function stepContextBy($: EngineInterface, by: 1 | -1) {
1182  await update($, view, v => {
1183    const cur = v ?? EMPTY_VIEW
1184    return { ...cur, context: stepContext(cur.context ?? DEFAULT_OPTIONS.context, by) }
1185  })
1186  await loadDiff($, false)
1187}
1188
1189// -w changes which files are listed, so it re-lists rather than reloading one
1190// g: GitHub's tinted rows, or syntax-coloured Code runs (a Code paints its own
1191// background, so it cannot carry the tint)
1192async function toggleSyntax($: EngineInterface) {
1193  await update($, view, v => {
1194    const cur = v ?? EMPTY_VIEW
1195    const { syntax: _drop, ...rest } = cur
1196    return cur.syntax ? rest : { ...rest, syntax: true }
1197  })
1198}
1199
1200async function toggleWhitespace($: EngineInterface) {
hooks/hunks.ts 208 lines
1// Splits a unified diff into hunks and counts the unchanged lines git left out
2// before each one, so the pane can draw "N unmodified lines" between them.
3
4export type Hunk = { text: string; gapBefore: number }
5
6const HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
7
8// Code draws tab and newline as its only control characters: drop CR (CRLF
9// files) and mark any other control character visibly.
10export function cleanPatch(text: string): string {
11  return text.replace(/\r(?=\n|$)/g, '').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '�')
12}
13
14export function splitHunks(diff: string): Hunk[] {
15  const out: Hunk[] = []
16  let cur: string[] | null = null
17  let prevEnd = 1 // first old-side line after the previous hunk
18  let gap = 0
19  const flush = () => {
20    if (cur) out.push({ text: cur.join('\n') + '\n', gapBefore: gap })
21  }
22  for (const line of diff.split('\n')) {
23    const m = HEADER.exec(line)
24    if (m) {
25      flush()
26      const start = Number(m[1])
27      const len = m[2] === undefined ? 1 : Number(m[2])
28      // an empty old range "-s,0" sits AFTER old line s (s is 0 for a new file)
29      const first = len === 0 ? start + 1 : start
30      gap = Math.max(0, first - prevEnd)
31      prevEnd = first + len
32      cur = [line]
33    } else if (cur && line !== '') {
34      cur.push(line)
35    }
36  }
37  flush()
38  return out
39}
40
41// A hunk cut to at most `budget` characters that still parses: whole lines
42// only, the header's counts rewritten to what is kept. Null when not even the
43// header and one line fit.
44export function clipHunk(text: string, budget: number): { text: string; droppedLines: number } | null {
45  const rows = text.split('\n').filter(l => l !== '')
46  const m = HEADER.exec(rows[0] ?? '')
47  if (!m) return null
48  const kept: string[] = []
49  let size = rows[0]!.length + 1
50  let oldN = 0
51  let newN = 0
52  for (const row of rows.slice(1)) {
53    if (size + row.length + 1 > budget) break
54    kept.push(row)
55    size += row.length + 1
56    if (row[0] === ' ' || row[0] === '-') oldN++
57    if (row[0] === ' ' || row[0] === '+') newN++
58  }
59  if (kept.length === 0) return null
60  const dropped = rows.length - 1 - kept.length
61  const header = `@@ -${m[1]},${oldN} +${m[3]},${newN} @@${m[5] ?? ''}`
62  return { text: [header, ...kept].join('\n') + '\n', droppedLines: dropped }
63}
64
65// The line a comment on this hunk points at: its first added line on the new
66// side (an edit is about the code that replaced the old), else its first
67// deleted line on the old side; null when the hunk shows no change at all
68// (a clipped full-context hunk can hold context only).
69export function firstChange(text: string): { line: number; side: 'old' | 'new'; newLine: number } | null {
70  const rows = text.split('\n')
71  const m = HEADER.exec(rows[0] ?? '')
72  let oldLine = m ? Number(m[1]) : 1
73  let newLine = m ? Number(m[3]) : 1
74  let firstDeleted: { old: number; at: number } | null = null
75  for (const row of rows.slice(1)) {
76    if (row[0] === '+') return { line: newLine, side: 'new', newLine }
77    if (row[0] === '-') {
78      firstDeleted ??= { old: oldLine, at: newLine }
79      oldLine++
80    } else if (row[0] === ' ') {
81      oldLine++
82      newLine++
83    }
84  }
85  // newLine: where the deletion sits in the current file, for a location
86  return firstDeleted === null ? null : { line: firstDeleted.old, side: 'old', newLine: firstDeleted.at }
87}
88
89// Whether a line on one side falls inside this hunk's range on that side.
90export function hunkHolds(text: string, line: number, side: 'old' | 'new'): boolean {
91  const m = HEADER.exec(text.split('\n')[0] ?? '')
92  if (!m) return false
93  const start = Number(side === 'old' ? m[1] : m[3])
94  const len = Number((side === 'old' ? m[2] : m[4]) ?? 1)
95  // a zero-length range (a pure addition's old side) holds no lines
96  return line >= start && line < start + len
97}
98
99export type ExportComment = { repo: string; path: string; line: number; side: 'old' | 'new'; tag: string; body: string }
100
101// One markdown block a coding agent can act on, in the shape GitHub-style diff
102// viewers hand their agents: tag, bold file:line, which side, then the note.
103export function exportComments(comments: readonly ExportComment[]): string {
104  const repos = new Set(comments.map(c => c.repo))
105  const lines = comments.map(c => {
106    const tag = c.tag ? `[${c.tag}] ` : ''
107    const where = `${repos.size > 1 ? `${c.repo}: ` : ''}${c.path}${c.line > 0 ? `:${c.line}` : ''}`
108    const side = c.line > 0 ? ` (${c.side} side)` : ''
109    return `- ${tag}**${where}**${side}: ${c.body}`
110  })
111  const scope = repos.size === 1 ? ` in ${[...repos][0]}` : ''
112  return `## Code review comments${scope}\n\nPlease address the following:\n\n${lines.join('\n')}\n`
113}
114
115// The function or section git names after the second @@ (its funcname
116// context), trimmed; '' when git found none.
117export function hunkContext(text: string): string {
118  const m = HEADER.exec(text.split('\n')[0] ?? '')
119  return (m?.[5] ?? '').trim()
120}
121
122// n/p over `count` hunks: the next index from `from` (-1 before the first),
123// wrapping at either end.
124export function nextHunkIndex(from: number, by: 1 | -1, count: number): number {
125  if (count <= 0) return -1
126  const start = from < 0 || from >= count ? (by === 1 ? -1 : count) : from
127  return (start + by + count) % count
128}
129
130// The one file section of a patch whose new path is `path` (git can return
131// more than the pair asked for: `-- old new` also matches files under old/).
132// The whole patch when no section names it, so nothing is lost.
133export function pickSection(patch: string, path: string): string {
134  const sections = patch.split(/^(?=diff --git )/m).filter(x => x.length > 0)
135  if (sections.length <= 1) return patch
136  const hit = sections.find(x => x.includes(`\n+++ b/${path}\n`) || x.includes(`\nrename to ${path}\n`))
137  return hit ?? patch
138}
139
140// What a patch says that its hunks don't: GitHub's "File renamed without
141// changes", a mode change, a symlink swapped for a file. Read from the git
142// headers, which the card otherwise drops; one line each, plain words.
143export function patchNotes(patch: string): string[] {
144  const notes: string[] = []
145  const oldMode = /^old mode (\d+)$/m.exec(patch)?.[1]
146  const newMode = /^new mode (\d+)$/m.exec(patch)?.[1]
147  if (oldMode && newMode) {
148    const exec = (m: string) => m.endsWith('755')
149    const what = exec(newMode) && !exec(oldMode) ? 'now executable' : exec(oldMode) && !exec(newMode) ? 'no longer executable' : 'mode changed'
150    notes.push(`${what} (${oldMode} → ${newMode})`)
151  }
152  if (/^similarity index 100%$/m.test(patch)) notes.push('renamed without changes')
153  const gone = /^deleted file mode (\d+)$/m.exec(patch)?.[1]
154  const born = /^new file mode (\d+)$/m.exec(patch)?.[1]
155  const kind = (m: string) => (m === '120000' ? 'symlink' : m === '160000' ? 'submodule' : 'file')
156  if (gone && born && kind(gone) !== kind(born)) notes.push(`${kind(gone)} replaced by a ${kind(born)}`)
157  return notes
158}
159
160// 1001 -> "1,001", as GitHub prints counts (no Intl assumed in the mod's runtime)
161export function grouped(n: number): string {
162  return String(Math.trunc(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
163}
164
165// One hunk as GitHub draws it: its "@@" line, then runs of unchanged, added and
166// removed lines, each numbered from the file it belongs to (new for unchanged
167// and added, old for removed). The marker stays on every line, so the kind is
168// never told by colour alone. "\ No newline at end of file" joins the run above.
169export type HunkRun = { kind: 'add' | 'del' | 'ctx'; start: number; oldStart: number; newStart: number; lines: string[] }
170export function hunkRuns(hunk: string): { header: string; runs: HunkRun[] } {
171  const lines = hunk.replace(/\n$/, '').split('\n')
172  const header = lines[0] ?? ''
173  const m = HEADER.exec(header)
174  let oldNo = m ? Number(m[1]) : 1
175  let newNo = m ? Number(m[3]) : 1
176  const runs: HunkRun[] = []
177  for (const line of lines.slice(1)) {
178    if (line.startsWith('\\')) {
179      runs[runs.length - 1]?.lines.push(line)
180      continue
181    }
182    const kind = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'del' : 'ctx'
183    const no = kind === 'del' ? oldNo : newNo
184    const last = runs[runs.length - 1]
185    if (last && last.kind === kind) last.lines.push(line)
186    else runs.push({ kind, start: no, oldStart: oldNo, newStart: newNo, lines: [line] })
187    if (kind !== 'add') oldNo++
188    if (kind !== 'del') newNo++
189  }
190  return { header, runs }
191}
192
193// Horizontal pan for a cut hunk: the surfaces have no sideways scroll, so a
194// block is panned by dropping its lines' first `off` columns (code points, a
195// tab as one). The +/- marker stays; a "\ No newline" line is left whole.
196export const PAN_STEP = 20
197export function panLine(line: string, off: number): string {
198  if (off <= 0 || line.startsWith('\\')) return line
199  return line.slice(0, 1) + Array.from(line.slice(1)).slice(off).join('')
200}
201// how far a hunk can pan: the last whole step that still leaves part of its
202// widest line in view (64 columns stop at 60, showing the final 4)
203export function panMax(text: string): number {
204  let widest = 0
205  for (const l of text.split('\n').slice(1)) if (!l.startsWith('\\')) widest = Math.max(widest, Array.from(l).length - 1)
206  return Math.max(0, Math.ceil((widest - PAN_STEP) / PAN_STEP) * PAN_STEP)
207}
208
hooks/git-parse.ts 70 lines
1// Parsers for git's -z output with rename detection on: a rename carries its
2// old and new paths as two NUL-terminated fields instead of one.
3
4export type NumstatRow = { path: string; oldPath?: string; added: number; removed: number; isBinary: boolean }
5
6// `git diff --numstat -z -M`: "added\tremoved\tpath\0", or for a rename
7// "added\tremoved\t\0old\0new\0". Paths may hold tabs, so only the first two
8// tabs split a row.
9export function parseNumstat(raw: string): NumstatRow[] {
10  const out: NumstatRow[] = []
11  let i = 0
12  while (i < raw.length) {
13    const t1 = raw.indexOf('\t', i)
14    const t2 = t1 < 0 ? -1 : raw.indexOf('\t', t1 + 1)
15    if (t2 < 0) break
16    const a = raw.slice(i, t1)
17    const r = raw.slice(t1 + 1, t2)
18    i = t2 + 1
19    let path: string
20    let oldPath: string | undefined
21    if (raw[i] === '\u0000') {
22      const o = raw.indexOf('\u0000', i + 1)
23      const n = o < 0 ? -1 : raw.indexOf('\u0000', o + 1)
24      if (n < 0) break
25      oldPath = raw.slice(i + 1, o)
26      path = raw.slice(o + 1, n)
27      i = n + 1
28    } else {
29      const e = raw.indexOf('\u0000', i)
30      if (e < 0) break
31      path = raw.slice(i, e)
32      i = e + 1
33    }
34    const isBinary = a === '-' && r === '-'
35    out.push({ path, ...(oldPath !== undefined ? { oldPath } : {}), added: Number(a) || 0, removed: Number(r) || 0, isBinary })
36  }
37  return out
38}
39
40// `git diff --name-status -z -M`: "S\0path\0", or "R100\0old\0new\0" for a
41// rename (C for a copy). Keyed by the new path.
42export function parseNameStatus(raw: string): Map<string, string> {
43  const recs = raw.split('\u0000').filter(r => r.length > 0)
44  const out = new Map<string, string>()
45  for (let i = 0; i < recs.length; ) {
46    const st = recs[i]!
47    if (st[0] === 'R' || st[0] === 'C') {
48      const to = recs[i + 2]
49      if (to !== undefined) out.set(to, st)
50      i += 3
51    } else {
52      const path = recs[i + 1]
53      if (path !== undefined) out.set(path, st)
54      i += 2
55    }
56  }
57  return out
58}
59
60// `git ls-files -u -z`: "mode sha stage\tpath\0", one row per stage of each
61// unmerged path; the distinct paths.
62export function parseUnmerged(raw: string): string[] {
63  const out = new Set<string>()
64  for (const rec of raw.split('\u0000')) {
65    const tab = rec.indexOf('\t')
66    if (tab >= 0) out.add(rec.slice(tab + 1))
67  }
68  return [...out]
69}
70
hooks/pr.ts 168 lines
1// The branch's pull request, read from `gh pr view --json`, and the exclude
2// globs `gh pr diff --exclude` takes. The check grouping and re-run
3// de-duplication follow gh's own (pkg/cmd/pr/checks/aggregate.go).
4
5import type { PrChecks, PrInfo } from '../types'
6
7export const PR_FIELDS = 'number,state,isDraft,reviewDecision,mergeable,url,baseRefName,statusCheckRollup'
8
9type Rollup = {
10  __typename?: string
11  name?: string
12  context?: string
13  workflowName?: string
14  status?: string
15  conclusion?: string
16  state?: string
17  startedAt?: string
18  detailsUrl?: string
19  targetUrl?: string
20}
21
22export type Bucket = keyof PrChecks
23
24export function bucketOf(state: string): Bucket {
25  switch (state) {
26    case 'SUCCESS':
27      return 'pass'
28    case 'SKIPPED':
29    case 'NEUTRAL':
30      return 'skipping'
31    case 'ERROR':
32    case 'FAILURE':
33    case 'TIMED_OUT':
34    case 'ACTION_REQUIRED':
35      return 'fail'
36    case 'CANCELLED':
37      return 'cancel'
38    default:
39      // EXPECTED, REQUESTED, WAITING, QUEUED, PENDING, IN_PROGRESS, STALE
40      return 'pending'
41  }
42}
43
44// A check run's state is its conclusion once completed, else its status; a
45// commit status carries `state` itself.
46const stateOf = (c: Rollup) => c.state || (c.status === 'COMPLETED' ? (c.conclusion ?? '') : (c.status ?? ''))
47
48// Newest run per status context, or per check name + workflow: a re-run
49// replaces the run it repeats instead of counting twice.
50function newestOnly(items: readonly Rollup[]): Rollup[] {
51  const sorted = [...items].sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''))
52  const seen = new Set<string>()
53  return sorted.filter(c => {
54    const key = c.context ? `ctx:${c.context}` : `run:${c.name ?? ''}/${c.workflowName ?? ''}`
55    if (seen.has(key)) return false
56    seen.add(key)
57    return true
58  })
59}
60
61export function parsePrView(stdout: string): PrInfo {
62  const j = JSON.parse(stdout) as {
63    number: number
64    state: string
65    isDraft: boolean
66    reviewDecision?: string
67    mergeable?: string
68    url: string
69    baseRefName?: string
70    statusCheckRollup?: Rollup[] | null
71  }
72  const checks: PrChecks = { pass: 0, fail: 0, pending: 0, skipping: 0, cancel: 0 }
73  const failing: Array<{ name: string; link: string }> = []
74  for (const c of newestOnly(j.statusCheckRollup ?? [])) {
75    const bucket = bucketOf(stateOf(c))
76    checks[bucket]++
77    if (bucket === 'fail') failing.push({ name: c.name || c.context || 'check', link: c.detailsUrl || c.targetUrl || '' })
78  }
79  return {
80    kind: 'pr',
81    number: j.number,
82    state: j.state,
83    isDraft: Boolean(j.isDraft),
84    reviewDecision: j.reviewDecision ?? '',
85    mergeable: j.mergeable ?? '',
86    url: j.url,
87    baseRefName: j.baseRefName ?? '',
88    checks,
89    failing,
90  }
91}
92
93// What a failed `gh pr view` means: no PR for this branch is an answer, the
94// rest (no auth, no network, not a GitHub remote) is "unavailable".
95export function prFailure(stderr: string): PrInfo {
96  if (/no (open )?pull requests? found/i.test(stderr)) return { kind: 'none' }
97  return { kind: 'error', message: stderr.trim().split('\n')[0] || 'gh failed' }
98}
99
100// Go's path.Match, which gh's --exclude uses: `*` any run of non-slash
101// characters, `?` exactly one (a whole character, not a UTF-16 half),
102// `[...]` a class negated only by `^` (`!` is an ordinary member), `\` escapes
103// the next character. A malformed pattern matches nothing, as gh ignores it.
104export function globMatch(pattern: string, name: string): boolean {
105  let re = '^'
106  const lit = (ch: string) => ch.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&')
107  for (let i = 0; i < pattern.length; i++) {
108    const ch = pattern[i]!
109    if (ch === '*') re += '[^/]*'
110    else if (ch === '?') re += '[^/]'
111    else if (ch === '\\') {
112      const next = pattern[++i]
113      if (next === undefined) return false
114      re += lit(next)
115    } else if (ch === '[') {
116      let j = i + 1
117      let body = ''
118      if (pattern[j] === '^') {
119        body = '^'
120        j++
121      }
122      let closed = false
123      for (; j < pattern.length; j++) {
124        const c = pattern[j]!
125        if (c === ']' && body.replace(/^\^/, '').length > 0) {
126          closed = true
127          break
128        }
129        if (c === '\\') {
130          const next = pattern[++j]
131          if (next === undefined) return false
132          body += lit(next)
133        } else if (c === '-' && body.length > 0 && pattern[j + 1] !== ']') body += '-'
134        else body += lit(c)
135      }
136      if (!closed) return false
137      re += `[${body}]`
138      i = j
139    } else re += lit(ch)
140  }
141  try {
142    return new RegExp(`${re}$`, 'u').test(name)
143  } catch {
144    return false
145  }
146}
147
148// gh's rule: a pattern hides a file when it matches the whole path or, so
149// `*.yml` reaches `dir/file.yml`, the file's own name.
150export function isExcluded(path: string, patterns: readonly string[]): boolean {
151  const base = path.slice(path.lastIndexOf('/') + 1)
152  return patterns.some(p => globMatch(p, path) || globMatch(p, base))
153}
154
155// Space-separated patterns; one holding a space is written in double quotes.
156export function parsePatterns(text: string): string[] {
157  const out: string[] = []
158  const re = /"([^"]*)"|(\S+)/g
159  for (let m = re.exec(text); m; m = re.exec(text)) {
160    const p = m[1] ?? m[2] ?? ''
161    if (p.length > 0) out.push(p)
162  }
163  return out
164}
165
166// The inverse, for the field's text: a pattern with a space goes in quotes.
167export const formatPatterns = (patterns: readonly string[]) => patterns.map(p => (/\s/.test(p) ? `"${p}"` : p)).join(' ')
168
hooks/commits.ts 61 lines
1// The branch's own commits since its base, as lazygit's commits panel lists
2// them, and the range that shows one commit's diff on its own.
3
4export type Commit = { sha: string; short: string; parents: string[]; author: string; at: number; subject: string }
5
6const FS = '\u001f'
7const RS = '\u001e'
8
9// Newest first, one record per commit: fields split by US (0x1f), records
10// ended by RS (0x1e). Neither byte can occur in a name or subject, so tabs,
11// quotes and unicode come through untouched. The caller appends the range.
12export const LOG_ARGS: readonly string[] = [
13  'log',
14  '--no-color',
15  '--no-decorate',
16  '--format=%H%x1f%h%x1f%P%x1f%an%x1f%at%x1f%s%x1e',
17  '--max-count=200',
18]
19
20// `--format=` is tformat, so git adds a newline after each RS; trimming the
21// record drops it (a subject never starts or ends with whitespace, git strips
22// that). A record without exactly six fields is skipped, not fatal.
23export function parseLog(stdout: string): Commit[] {
24  const out: Commit[] = []
25  for (const raw of stdout.split(RS)) {
26    const rec = raw.trim()
27    if (rec.length === 0) continue
28    const f = rec.split(FS)
29    if (f.length !== 6) continue
30    const [sha, short, parents, author, at, subject] = f as [string, string, string, string, string, string]
31    const secs = Number(at)
32    if (!sha || !Number.isFinite(secs)) continue
33    out.push({ sha, short, parents: parents.split(' ').filter(p => p.length > 0), author, at: secs * 1000, subject })
34  }
35  return out
36}
37
38// Git's empty tree: the "parent" of a root commit, so its diff is every file.
39export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
40
41// What the commit itself introduced: against its first parent (a merge shows
42// what it brought onto the branch), or against nothing for a root commit.
43export const commitRange = (c: Commit): string[] => [c.parents[0] ?? EMPTY_TREE, c.sha]
44
45const MIN = 60_000
46const HOUR = 60 * MIN
47const DAY = 24 * HOUR
48
49// Compact age for a list row. Weeks start at 14 days so "1w" never hides
50// 7-13 days; months are 30 days. A future time (clock skew) is "just now".
51export function relTime(ms: number, now: number): string {
52  const d = now - ms
53  if (d < MIN) return 'just now'
54  if (d < HOUR) return `${Math.floor(d / MIN)}m ago`
55  if (d < DAY) return `${Math.floor(d / HOUR)}h ago`
56  if (d < 14 * DAY) return `${Math.floor(d / DAY)}d ago`
57  if (d < 60 * DAY) return `${Math.floor(d / (7 * DAY))}w ago`
58  if (d < 365 * DAY) return `${Math.floor(d / (30 * DAY))}mo ago`
59  return `${Math.floor(d / (365 * DAY))}y ago`
60}
61
hooks/options.ts 69 lines
1// How the selected file's patch is drawn: context lines around each change
2// (lazygit's diffContextSize) and whether whitespace-only edits count
3// (lazygit's ignoreWhitespaceInDiffView, git's -w), plus the key legend.
4
5export type DiffOptions = { context: number; ignoreWhitespace: boolean }
6
7// git's own default context is 3 lines.
8export const DEFAULT_OPTIONS: DiffOptions = { context: 3, ignoreWhitespace: false }
9
10// Past this the "Show full file" toggle is the better tool.
11export const MAX_CONTEXT = 30
12
13// Engine hotkeys are one digit or one lowercase letter, so lazygit's `{` `}`
14// and `?` become letters clear of 1-9, r, j, k, n, p and w: More / Less
15// context, Ignore whitespace, Help.
16export const KEY_MORE_CONTEXT = 'm'
17export const KEY_LESS_CONTEXT = 'l'
18export const KEY_IGNORE_WHITESPACE = 'i'
19export const KEY_LEGEND = 'h'
20export const KEY_VIEWED = 'v'
21export const KEY_NUMBERS = 'g'
22// sideways pan of the current hunk (h/l were taken by the legend and less context)
23export const KEY_PAN_LEFT = 'a'
24export const KEY_PAN_RIGHT = 'd'
25
26// One step of m/l, held to [0, MAX_CONTEXT]; a value that is not a whole
27// number (a corrupt store entry) restarts from git's default.
28export function stepContext(n: number, by: 1 | -1): number {
29  const from = Number.isInteger(n) ? n : DEFAULT_OPTIONS.context
30  return Math.min(MAX_CONTEXT, Math.max(0, from + by))
31}
32
33// The git diff arguments for these options. The context flag is always
34// written, -U3 included, so a user's diff.context config cannot change the
35// hunk shape comment anchoring relies on. -U implies -p: pass these to the
36// patch call only, never to --numstat or --name-status.
37export function diffFlags(o: DiffOptions, isFull: boolean): string[] {
38  const flags = [isFull ? '-U100000' : `-U${o.context}`]
39  if (o.ignoreWhitespace) flags.push('-w')
40  return flags
41}
42
43// Every hotkey the pane binds, for the legend; `when` names the part of the
44// pane that has to be showing for the key to do anything.
45export const KEYS: ReadonlyArray<{ key: string; label: string; when?: 'tabs' | 'hunks' }> = [
46  { key: '1-9', label: 'switch repo', when: 'tabs' },
47  { key: 'r', label: 'refresh' },
48  { key: 'j / k', label: 'next / previous file' },
49  { key: 'n / p', label: 'next / previous change', when: 'hunks' },
50  { key: 'w', label: 'wrap long lines' },
51  { key: `${KEY_PAN_LEFT} / ${KEY_PAN_RIGHT}`, label: 'scroll the current change left / right (cut lines)', when: 'hunks' },
52  { key: `${KEY_MORE_CONTEXT} / ${KEY_LESS_CONTEXT}`, label: 'more / less context' },
53  { key: KEY_IGNORE_WHITESPACE, label: 'ignore whitespace' },
54  { key: KEY_VIEWED, label: 'mark viewed, go to the next unviewed file' },
55  { key: KEY_NUMBERS, label: 'tinted rows / syntax colours' },
56  { key: KEY_LEGEND, label: 'show / hide keys' },
57]
58
59// The legend rows that apply right now: rows without `when` always do.
60export function legendRows(have: { tabs: boolean; hunks: boolean }): Array<{ key: string; label: string }> {
61  return KEYS.filter(k => k.when === undefined || have[k.when]).map(({ key, label }) => ({ key, label }))
62}
63
64// Compact status for the card header: `±3 lines`, `±5 lines · no whitespace`.
65export function optionsLabel(o: DiffOptions): string {
66  const ctx = `±${o.context} ${o.context === 1 ? 'line' : 'lines'}`
67  return o.ignoreWhitespace ? `${ctx} · no whitespace` : ctx
68}
69
hooks/tree.ts 66 lines
1import type { FileChange } from '../types'
2
3// Folder/file tree for the left column: files grouped by directory, a folder
4// holding only one subfolder and no files joined onto it ("src/features/x").
5
6export type DirNode = { kind: 'dir'; path: string; label: string; depth: number; children: TreeNode[] }
7export type FileNode = { kind: 'file'; file: FileChange; label: string; depth: number }
8export type TreeNode = DirNode | FileNode
9
10type Draft = { dirs: Map<string, Draft>; files: FileChange[] }
11
12const draft = (): Draft => ({ dirs: new Map(), files: [] })
13
14export function buildTree(files: readonly FileChange[]): TreeNode[] {
15  const root = draft()
16  for (const f of files) {
17    const parts = f.path.split('/')
18    let at = root
19    for (const part of parts.slice(0, -1)) {
20      let next = at.dirs.get(part)
21      if (!next) at.dirs.set(part, (next = draft()))
22      at = next
23    }
24    at.files.push(f)
25  }
26  return emit(root, '', 0)
27}
28
29function emit(d: Draft, prefix: string, depth: number): TreeNode[] {
30  const out: TreeNode[] = []
31  for (const [name, sub] of [...d.dirs].sort(([a], [b]) => a.localeCompare(b))) {
32    let label = name
33    let node = sub
34    // join a chain of folders that each hold exactly one folder and no files
35    while (node.files.length === 0 && node.dirs.size === 1) {
36      const [[childName, child]] = [...node.dirs] as [[string, Draft]]
37      label = `${label}/${childName}`
38      node = child
39    }
40    const path = prefix ? `${prefix}/${label}` : label
41    out.push({ kind: 'dir', path, label, depth, children: emit(node, path, depth + 1) })
42  }
43  for (const f of [...d.files].sort((a, b) => a.path.localeCompare(b.path))) {
44    out.push({ kind: 'file', file: f, label: f.path.slice(f.path.lastIndexOf('/') + 1), depth })
45  }
46  return out
47}
48
49// The rows drawn, in order, skipping the insides of collapsed folders.
50export function visibleRows(nodes: readonly TreeNode[], collapsed: ReadonlySet<string>): TreeNode[] {
51  const out: TreeNode[] = []
52  const walk = (list: readonly TreeNode[]) => {
53    for (const n of list) {
54      out.push(n)
55      if (n.kind === 'dir' && !collapsed.has(n.path)) walk(n.children)
56    }
57  }
58  walk(nodes)
59  return out
60}
61
62// Files in tree order: what prev/next steps through.
63export function fileOrder(nodes: readonly TreeNode[]): FileChange[] {
64  return visibleRows(nodes, new Set()).flatMap(n => (n.kind === 'file' ? [n.file] : []))
65}
66
hooks/highlight.ts 102 lines
1// A small line highlighter for the tinted diff rows: a Code paints its own
2// background, so rows that carry GitHub's tint are Text, and their colours come
3// from here. Line by line, no state across lines: a block comment or string
4// that spans lines is coloured only on the line it opens. Kinds are the scopes
5// delta's syntect themes and GitHub's prettylights both colour apart: keyword
6// (pl-k), function and decorator names (pl-en), types (pl-v), the module path
7// after import / package / from / using (pl-ent), constants and numbers
8// (pl-c1), strings (pl-s), comments (pl-c).
9
10export type TokenKind = 'keyword' | 'entity' | 'type' | 'namespace' | 'constant' | 'string' | 'comment'
11export type Token = { text: string; kind?: TokenKind }
12
13const KEYWORDS = new Set(
14  (
15    'abstract as async await break case catch class const continue data def default defer del delete do else elif enum ' +
16    'export extends extension final finally fn for from fun func function get go guard if impl implements import in init ' +
17    'inout interface internal is lambda let match mod module mut namespace new nil not object of open operator or ' +
18    'override package pass private protected public raise readonly return sealed self set static struct super switch ' +
19    'throw throws trait try type typealias typeof unsafe use using val var when where while with yield require'
20  ).split(' '),
21)
22const CONSTANTS = new Set('true false null undefined None True False nil this'.split(' '))
23// built-in type names that are not capitalised
24const TYPES = new Set(
25  'string number boolean bigint symbol any unknown never object void int long short byte char float double bool uint u8 u16 u32 u64 i8 i16 i32 i64 f32 f64 usize isize str dict list tuple set'.split(' '),
26)
27// the words a module path follows
28const IMPORTISH = new Set('import package from using namespace module use require'.split(' '))
29
30// '#' starts a comment in these; elsewhere '//' does
31const HASH_COMMENT = /\.(py|sh|bash|zsh|rb|ya?ml|toml|conf|ini|r|pl|mk)$|(^|\/)(Makefile|Dockerfile|Gemfile|Podfile|\.[a-z]*rc)$/i
32const NO_SYNTAX = /\.(md|mdx|txt|rst|csv|lock|log|svg)$/i
33
34const tokenRe = (comment: string) =>
35  new RegExp(
36    `(${comment})|("(?:\\\\.|[^"\\\\])*"?|'(?:\\\\.|[^'\\\\])*'?|\`(?:\\\\.|[^\`\\\\])*\`?)` +
37      `|(\\b0x[0-9a-fA-F_]+\\b|\\b\\d[\\d_]*(?:\\.\\d+)?(?:[eE][+-]?\\d+)?[a-zA-Z]?\\b)` +
38      `|(@[A-Za-z_][\\w.]*)|([A-Za-z_$][\\w$]*)(?=\\s*\\()|([A-Za-z_$][\\w$]*)`,
39    'g',
40  )
41const SLASH = tokenRe('\\/\\/.*|\\/\\*.*?(?:\\*\\/|$)')
42const HASH = tokenRe('#.*')
43
44// one line of code (no diff marker) as runs of plain and coloured text
45export function highlightLine(line: string, path: string): Token[] {
46  if (NO_SYNTAX.test(path)) return [{ text: line }]
47  const isHash = HASH_COMMENT.test(path)
48  const out: Token[] = []
49  let at = 0
50  const push = (text: string, kind?: TokenKind) => {
51    if (!text) return
52    const last = out[out.length - 1]
53    if (last && last.kind === kind) last.text += text
54    else out.push(kind ? { text, kind } : { text })
55  }
56  // inside a module path: from an import-ish keyword to the first gap that is
57  // not a dot (a brace, a comma, "as")
58  let isPath = false
59  for (const m of line.matchAll(isHash ? HASH : SLASH)) {
60    const [whole, comment, str, num, deco, call, word] = m
61    const i = m.index ?? 0
62    const gap = line.slice(at, i)
63    if (isPath && !/^\s*$|^\.$/.test(gap)) isPath = false
64    push(gap, isPath && gap === '.' ? 'namespace' : undefined)
65    const name = call ?? word
66    if (comment) push(whole, 'comment')
67    else if (str) push(whole, 'string')
68    else if (num) push(whole, 'constant')
69    else if (deco) push(whole, 'entity')
70    else if (name && KEYWORDS.has(name)) {
71      push(whole, 'keyword')
72      isPath = IMPORTISH.has(name)
73    } else if (name && isPath) {
74      // a path's last segment, capitalised, is the type it brings in
75      const isLast = line[i + whole.length] !== '.'
76      push(whole, isLast && /^[A-Z]/.test(name) ? 'type' : 'namespace')
77    }
78    else if (call) push(whole, /^[A-Z]/.test(call) ? 'type' : 'entity')
79    else if (word) push(whole, CONSTANTS.has(word) ? 'constant' : TYPES.has(word) || /^[A-Z][a-z0-9]\w*$/.test(word) ? 'type' : undefined)
80    at = i + whole.length
81  }
82  push(line.slice(at))
83  return out
84}
85
86// a highlighted line with its first `off` code points dropped, kinds kept
87export function panTokens(tokens: Token[], off: number): Token[] {
88  if (off <= 0) return tokens
89  const out: Token[] = []
90  let skip = off
91  for (const t of tokens) {
92    const cps = Array.from(t.text)
93    if (skip >= cps.length) {
94      skip -= cps.length
95      continue
96    }
97    out.push({ ...t, text: cps.slice(skip).join('') })
98    skip = 0
99  }
100  return out
101}
102
types/index.d.ts 164 lines
1// status: git's letter (M A D R…), '?' untracked, '!' unmerged (a conflict);
2// oldPath: where a renamed file came from
3// blob: the content id on the diff's new side, read for marked and selected files;
4// side: the commit(s) the diff starts from, HEAD resolved
5export type FileChange = {
6  path: string
7  oldPath?: string
8  status: string
9  added: number
10  removed: number
11  isBinary?: boolean
12  blob?: string
13  side?: string
14}
15export type RepoSnap = {
16  root: string
17  name: string
18  branch: string
19  base: string
20  baseLabel: string
21  source: 'ledger' | 'touched' | 'both'
22  files: FileChange[]
23  error?: string
24  // the last good listing, kept because this refresh's git calls failed
25  isStale?: boolean
26  // the `git diff` range for the scope the listing was taken in
27  range: string[]
28  // where change detection looks: this worktree's git dir, the shared one
29  // (refs, packed-refs) and the branch ref; absent when they could not be read
30  watch?: { gitDir: string; commonDir: string; ref: string; baseRef: string }
31  // an operation stopped part-way in this checkout
32  op?: 'rebase' | 'merge' | 'cherry-pick' | 'revert' | 'am'
33  // HEAD's commit, so a PR answer is re-read when the branch moves
34  head?: string
35  // where the base came from: picked by the person, the PR's base branch, the
36  // develop/main/master default, the session's claim, or HEAD
37  baseSource?: 'picked' | 'pr' | 'default' | 'claim' | 'head'
38  // the base ref in use (origin/qa), apart from a commit view's label
39  baseUsed?: string
40  // a wanted base (picked or the PR's) that could not be used, and why
41  baseNote?: string
42  // the branch's commits since its base, newest first; the one shown alone
43  commits?: Commit[]
44  commit?: Commit
45}
46// one branch commit, as hooks/commits.ts parses it from git log (kept inline:
47// the contract must not import)
48export type Commit = { sha: string; short: string; parents: string[]; author: string; at: number; subject: string }
49// the branch's pull request, from `gh pr view`
50export type PrChecks = { pass: number; fail: number; pending: number; skipping: number; cancel: number }
51export type PrInfo =
52  | {
53      kind: 'pr'
54      number: number
55      state: string
56      isDraft: boolean
57      reviewDecision: string
58      mergeable: string
59      url: string
60      // the branch the PR merges into: the pane's default base
61      baseRefName: string
62      checks: PrChecks
63      failing: Array<{ name: string; link: string }>
64    }
65  | { kind: 'none' }
66  | { kind: 'error'; message: string }
67// key: branch@head the answer was read for; at: when
68export type PrEntry = { key: string; at: number; info: PrInfo }
69// all: merge-base→working tree (+ untracked); committed: merge-base→HEAD (the
70// PR); touched: HEAD→working tree (+ untracked), the uncommitted work
71export type Scope = 'all' | 'committed' | 'touched' | 'turn'
72export type CommentTag = 'must-fix' | 'suggestion' | 'nit' | 'question' | ''
73export type ReviewComment = {
74  id: string
75  root: string
76  path: string
77  line: number
78  side: 'old' | 'new'
79  tag: CommentTag
80  body: string
81  // the view it was written in: line numbers mean different code per scope
82  scope?: Scope
83  // the branch commit being viewed alone when it was written, if any
84  commit?: string
85}
86// the comment being written: which file and line, and the tag picked so far
87export type Composing = { root: string; path: string; line: number; side: 'old' | 'new'; tag: CommentTag; scope: Scope; commit?: string }
88// what the pane shows: the active repo, the selected file (root\0path), the
89// files expanded to full context and the collapsed folders (root\0dir)
90// context/ignoreWhitespace: lazygit's diff options; commit: one branch commit
91// shown alone (root + sha)
92export type View = {
93  repo: string | null
94  selected: string | null
95  full: string[]
96  collapsed: string[]
97  scope: Scope
98  wrap?: boolean
99  context?: number
100  ignoreWhitespace?: boolean
101  // syntax-coloured Code runs instead of GitHub's tinted rows (g)
102  syntax?: boolean
103  // file containers the person folded with their chevron (root\0path)
104  folded?: string[]
105  commit?: { root: string; sha: string }
106}
107export type DiffState = { key: string; isFull: boolean; range?: string; stamp?: string; text: string; status: 'loading' | 'ready' | 'error'; error?: string }
108
109declare module 'claude-code' {
110  interface PluginState {
111    'repo-diffs': {
112      touched: string[]
113      turnEdits: TurnEdits
114      /** 0.3.0's last-turn list and overflow flag, read once on a reload and emptied. */
115      lastTurn: string[]
116      lastTurnFull: boolean
117      snapshot: RepoSnap[]
118      refreshedAt: number
119      autoOpened: boolean
120      view: View
121      diff: DiffState | null
122      busy: boolean
123      comments: ReviewComment[]
124      composing: Composing | null
125      prs: Record<string, PrEntry>
126      exclude: string[]
127      editingHide: boolean
128      baseOverride: Record<string, string>
129      editingBase: boolean
130      showKeys: boolean
131      showCommits: boolean
132      // Clear asks before it drops every comment
133      confirmClear: boolean
134      // hidden files shown for now; the saved patterns are kept
135      revealHidden: boolean
136      // tree rows drawn before "show more"
137      treeLimit: number
138      // the pinned file list's own scroll, in rows (the wheel over the list)
139      treeScroll: number
140      // GitHub's Viewed: file key -> the listing stamp it was viewed at
141      viewed: Record<string, string>
142      // files opened past an automatic fold: viewed, or too large to draw unasked
143      openViewed: string[]
144      // other files' diffs drawn in their own containers below the selected one
145      diffs: Record<string, DiffState>
146      // each cut hunk's sideways pan in columns, by `fileKey#hunk index`
147      pan: Record<string, number>
148      // file containers drawn before "show more files"
149      fileLimit: number
150      // the diff options row (context, wrap, colours, pan) is shown
151      showOptions: boolean
152      // blocks folded by their chevron: `sec:header`, `sec:files`,
153      // `sec:conflicts`, and hunks as `hunk:<root\0path>#<index>`
154      shut: string[]
155      // repos whose tree was seeded with its top folders collapsed (a large
156      // change set starts folded; done once per repo)
157      seeded: string[]
158    }
159  }
160}
161
162/** The latest main turn's edited files: its generation, the paths as git keys, and whether it edited more than the list holds. */
163export type TurnEdits = { gen: number; paths: string[]; isFull: boolean }
164