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

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.

~/.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.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 ?.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.▸ 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.▸ Files N · n/N viewed ‹ n/N › row (see v24).▾ 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.‹ n/m › file stepping, ↑ / ↓ change stepping, ▸ collapse all and options on the right.▾ / ▸, 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.selectionBg, bands and hover use userMessageBackground(Hover).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.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).+ 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.−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 ↻.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).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.hooks/commits.ts and hooks/options.ts were built by sub-agents, wired here):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 (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.±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.)␣ (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.)? (key h) shows the key legend, built from the same constants as the buttons. (now: a keys button, key h.)model_reasoning_effort=high) and Kimi K3. Fixed from it:git rev-parse --symbolic-full-name (no --end-of-options, which rev-parse echoes back), and stays watched while a commit is shown.- never reaches git.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.Retry beside the error.⧉ 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.)✎, a text glyph the theme colour reaches, not the 💬 emoji; muted text is inactive alone, no longer also dimmed.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.columnGap, so wrapped lines carry no blank row.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.)inactive on the dark band (4.18) and the light colour-blind theme's warning (2.14 on white).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.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.--effort high):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.)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.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.)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.)pickSection (it uses includes), CRLF reaches Code.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.→ 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.)▾ 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.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.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 toolbhooks/register.tsx 3034 lines1import { 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 lines1// 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}
208hooks/git-parse.ts 70 lines1// 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}
70hooks/pr.ts 168 lines1// 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(' ')
168hooks/commits.ts 61 lines1// 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}
61hooks/options.ts 69 lines1// 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}
69hooks/tree.ts 66 lines1import 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}
66hooks/highlight.ts 102 lines1// 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}
102types/index.d.ts 164 lines1// 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