@young1lin/dsh-ui-gitworkbench 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3446 @@
1
+ /**
2
+ * Session-header environment card + Codex-style changes drawer.
3
+ *
4
+ * The header shows a compact environment card — branch (or detached-HEAD sha),
5
+ * ahead/behind against upstream, and +adds/−dels/file count. Clicking opens a
6
+ * right-side drawer holding a collapsible DIRECTORY TREE (aggregated
7
+ * per-directory counts, status-badged files with dim-directory paths) and a
8
+ * per-file diff beside it — word-level highlights inside +/- line tints, over a
9
+ * light per-extension syntax pass. Files whose diff did not fit the bundled
10
+ * payload (or untracked files beyond the budget) fetch theirs on demand through
11
+ * `fetchFileDiff`. Binary files and renames get dedicated presentation. Polls
12
+ * every 15s while open.
13
+ *
14
+ * The drawer has three peer tabs. Changes is the working tree. History
15
+ * puts a commit list left of the same tree and diff panes — three peers, each
16
+ * scrolling on its own, which is what GitHub Desktop and the JetBrains git log
17
+ * do; it pages by scroll sentinel rather than by a button. Compare fills
18
+ * those panes from `base...head` between any two branches.
19
+ *
20
+ * A commit hash addresses content that cannot change, so a visited commit is
21
+ * kept and re-shown with neither a round trip nor a loading flash; the working
22
+ * tree is never cached, and selecting a commit does not refetch it.
23
+ *
24
+ * Source: a session is not confined to one worktree. The picker lists every
25
+ * worktree of the repository (git allows at most one per branch, so that list is
26
+ * also the branch list) and opens on the session's own — the bound worktree when
27
+ * the agent entered one, else the session cwd. Expansion/selection state is NOT
28
+ * reset on switch.
29
+ *
30
+ * Shell: the drawer is a card inset from every viewport edge, with a maximize
31
+ * toggle for full bleed. Three edges drag — the card's own leading edge and the
32
+ * dividers between the panes — each clamped so the diff keeps a readable width.
33
+ *
34
+ * Theme: a colour mode plus a palette family ({@link ./themes.ts}). The mode
35
+ * defaults to `system`, which follows dsh's resolved palette
36
+ * (`body[data-ds-dark-theme]`), not the computer's `prefers-color-scheme`.
37
+ * Both, and the dragged widths, are browser-local preferences and live in
38
+ * localStorage.
39
+ *
40
+ * Styling: a background image and a custom stylesheet, each settable for this
41
+ * project or globally with the project winning. Those are NOT browser-local —
42
+ * a project setting belongs to the project, so the host stores them and the
43
+ * panel reads them per source through `fetchStyle`. The stylesheet is injected
44
+ * as a document-level element, which is why `data-gs-part` attributes exist:
45
+ * CSS-module class names are hashed per build and cannot be targeted.
46
+ *
47
+ * All copy resolves through the app's locale runtime (`t`), so the panel follows
48
+ * the user's language preference.
49
+ */
50
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type Dispatch, type PointerEvent as ReactPointerEvent, type ReactNode, type Ref, type SetStateAction } from 'react'
51
+ import { createPortal } from 'react-dom'
52
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
53
+ import {
54
+ COLOR_MODES, DEFAULT_APPEARANCE, EMPTY_SETTINGS, STYLE_BLUR_MAX, STYLE_SCOPES, THEME_FAMILIES,
55
+ DSH_DARK_ATTR, effectiveBackground, effectiveCss, entryFor, hostSchemeDark, isAppearance, resolveTheme, withScope,
56
+ type Appearance, type ColorMode, type StyleEntry, type StyleScope, type StyleSettings, type ThemeFamily,
57
+ } from './themes.ts'
58
+ import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
59
+ import { layoutGraph, type GraphRow } from './commit-graph.ts'
60
+ import {
61
+ fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
62
+ type CheckState, type Tick, type TickAction,
63
+ } from './stage-tree.ts'
64
+ import { grammarLoadCount, highlightForRows, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
65
+ import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, probesClosedBinding, samePath, showsPending, splitPath, viewedPath } from './worktree-view.ts'
66
+ import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
67
+ import type { WorkbenchKey } from './locales.ts'
68
+ import css from './GitWorkbenchPanel.module.css'
69
+
70
+ export type GitFileStatus = 'added' | 'deleted' | 'modified' | 'renamed' | 'untracked'
71
+
72
+ export interface GitFile {
73
+ readonly path: string
74
+ readonly status: GitFileStatus
75
+ readonly addedLines: number
76
+ readonly deletedLines: number
77
+ readonly binary: boolean
78
+ readonly previousPath?: string
79
+ /**
80
+ * Which side of the index this file's change is on. Both can be true — a file
81
+ * staged and then edited again. Absent outside the working-tree view: a
82
+ * commit's files were staged long ago and the question is meaningless.
83
+ */
84
+ readonly staged?: boolean
85
+ readonly unstaged?: boolean
86
+ }
87
+
88
+ export interface GitCommit {
89
+ readonly hash: string
90
+ readonly subject: string
91
+ readonly when: string
92
+ /** Everything after the subject. Empty string when the commit has none. */
93
+ readonly body: string
94
+ /** Abbreviated parent hashes, first parent first — the graph's edges. */
95
+ readonly parents?: readonly string[]
96
+ /** Branch and tag names pointing here, already stripped of git's decoration syntax. */
97
+ readonly refs?: readonly string[]
98
+ }
99
+
100
+ export interface WorkbenchStats {
101
+ readonly worktreePath: string
102
+ readonly branch: string
103
+ readonly ahead: number
104
+ readonly behind: number
105
+ readonly detached: boolean
106
+ readonly addedLines: number
107
+ readonly deletedLines: number
108
+ readonly addedFiles: number
109
+ readonly deletedFiles: number
110
+ readonly modifiedFiles: number
111
+ readonly files: readonly GitFile[]
112
+ readonly diff: string
113
+ /**
114
+ * Commits this view is about: the single commit for a commit view, the range's
115
+ * commits for a comparison. Empty for the working tree — the history list
116
+ * loads its own pages so it can follow a ref of its own.
117
+ */
118
+ readonly commits: readonly GitCommit[]
119
+ readonly error?: string
120
+ }
121
+
122
+ /** One worktree of the repository, as `git worktree list --porcelain` reports it. */
123
+ export interface WorktreeEntry {
124
+ readonly path: string
125
+ readonly head: string
126
+ readonly branch: string
127
+ }
128
+
129
+ /** The session's worktree binding, as persisted by the worktree tools. */
130
+ export interface WorktreeBinding {
131
+ readonly repoRoot: string
132
+ readonly worktreePath: string
133
+ readonly name: string
134
+ readonly enteredAt: string
135
+ readonly baseCommit?: string
136
+ }
137
+
138
+ /**
139
+ * `gitWorkbench/worktreeStatus`: the session's binding (null when unbound) plus every
140
+ * worktree of the surrounding repository. Git allows at most one worktree per
141
+ * branch, so this one list is both the worktree picker and the branch picker.
142
+ */
143
+ export interface WorktreeStatus {
144
+ readonly binding: WorktreeBinding | null
145
+ readonly worktrees: readonly WorktreeEntry[]
146
+ /**
147
+ * Every local branch, most-recently-committed first. Distinct from
148
+ * {@link worktrees} on purpose: a branch without a worktree has no directory
149
+ * to read, so it can be browsed or compared but not viewed as a working tree.
150
+ */
151
+ readonly branches: readonly string[]
152
+ /** Whether the host cut {@link branches} short at its cap. */
153
+ readonly branchesTruncated: boolean
154
+ }
155
+
156
+ /**
157
+ * `gitWorkbench/syncStatus`: where the current branch stands against its upstream.
158
+ *
159
+ * `upstream: null` and "level with the upstream" are different states and the
160
+ * drawer treats them differently — the first is what makes the first push pass
161
+ * `--set-upstream`, and both otherwise read as zero ahead and zero behind.
162
+ */
163
+ export interface SyncStatus {
164
+ readonly branch: string
165
+ readonly upstream: string | null
166
+ readonly ahead: number
167
+ readonly behind: number
168
+ readonly detached: boolean
169
+ /** Whether the repository has any remote at all. No remote, no sync bar. */
170
+ readonly hasRemote: boolean
171
+ }
172
+
173
+ /** Why a write operation failed, in terms the drawer can explain. */
174
+ export type GitOpFailure =
175
+ | 'auth' | 'network' | 'no-upstream' | 'diverged' | 'conflict'
176
+ | 'nothing-to-commit' | 'dirty' | 'unknown'
177
+
178
+ export interface GitOpResult {
179
+ readonly ok: boolean
180
+ readonly failure?: GitOpFailure
181
+ /** git's own message on failure. Shown verbatim: a classification is a hint. */
182
+ readonly error?: string
183
+ readonly output?: string
184
+ }
185
+
186
+ /** The host endpoints under `gitWorkbench/` that change something. */
187
+ export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push'
188
+
189
+ /** Extra arguments an operation needs beyond the worktree path. */
190
+ export interface GitOpPayload {
191
+ readonly paths?: readonly string[]
192
+ readonly message?: string
193
+ readonly amend?: boolean
194
+ readonly mode?: 'ff-only' | 'rebase' | 'merge'
195
+ }
196
+
197
+ /** Translate a key of this plugin's namespace, with optional `{name}` params. */
198
+ type Translate = (key: string, params?: Record<string, string | number>) => string
199
+
200
+ type Props = PropsRuntime<'conversation.session.header.actions'> & {
201
+ readonly t: Translate
202
+ readonly fetchStats: (worktreePath: string | undefined, signal: AbortSignal) => Promise<WorkbenchStats | null>
203
+ readonly fetchFileDiff: (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal) => Promise<string>
204
+ readonly fetchWorktreeStatus: (sessionId: string, repoPath: string | undefined, signal: AbortSignal) => Promise<WorktreeStatus | null>
205
+ /** Binding only, no git — the probe the shut chip can afford to poll. */
206
+ readonly fetchSessionBinding: (sessionId: string, signal: AbortSignal) => Promise<{ worktreePath: string | null; name: string | null } | null>
207
+ readonly fetchCommitStats: (worktreePath: string | undefined, hash: string, signal: AbortSignal) => Promise<WorkbenchStats | null>
208
+ readonly fetchCommits: (worktreePath: string | undefined, ref: string, skip: number, limit: number, signal: AbortSignal) => Promise<{ commits: GitCommit[]; hasMore: boolean } | null>
209
+ readonly fetchCompare: (worktreePath: string | undefined, base: string, head: string, signal: AbortSignal) => Promise<WorkbenchStats | null>
210
+ readonly fetchStyle: (worktreePath: string | undefined, signal: AbortSignal) => Promise<StyleSettings | null>
211
+ readonly saveStyle: (worktreePath: string | undefined, scope: StyleScope, entry: StyleEntry, signal: AbortSignal) => Promise<{ ok: boolean; error?: string }>
212
+ readonly fetchSync: (worktreePath: string | undefined, signal: AbortSignal) => Promise<SyncStatus | null>
213
+ readonly runGitOp: (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal) => Promise<GitOpResult>
214
+ }
215
+
216
+ /**
217
+ * Drawer tab. `changes` is the working tree, `history` a commit picked from the
218
+ * log, `compare` two refs diffed against each other — peer surfaces rather than
219
+ * modes with a back action, so returning to the working tree is always one click
220
+ * (the pattern GitHub Desktop, VS Code and the JetBrains git tooling converge on).
221
+ */
222
+ type Tab = 'changes' | 'history' | 'compare'
223
+
224
+ /** How many further commits one page request loads. */
225
+ const HISTORY_PAGE = 30
226
+
227
+ /** Narrowest the drawer may be dragged: three panes at their minimums, plus a
228
+ * diff column still wide enough to read code in. */
229
+ const MIN_DRAWER_WIDTH = 760
230
+ /** Pane minimums. The diff's is enforced against the drawer rather than on the
231
+ * pane itself: it is the pane with no fallback, since code cannot reflow. */
232
+ const MIN_COMMITS_WIDTH = 190
233
+ const MIN_TREE_WIDTH = 170
234
+ const MIN_DIFF_WIDTH = 300
235
+
236
+ /** Longest edge a chosen background image is resampled to before storage. Past
237
+ * this the file grows fast while a blurred backdrop gains nothing. */
238
+ const IMAGE_MAX_EDGE = 2560
239
+ /** JPEG quality for that resample. */
240
+ const IMAGE_QUALITY = 0.82
241
+ /** Refuse an image whose encoded data URL exceeds this; matches the host's cap. */
242
+ const IMAGE_MAX_BYTES = 3_000_000
243
+
244
+ /** Element carrying the user's custom stylesheet. One per document. */
245
+ const CUSTOM_STYLE_ID = 'dsh-ui-gitworkbench-custom-css'
246
+
247
+ /** localStorage keys. Namespaced, since the whole app shares one origin.
248
+ * Layout and palette live here; the background image and custom CSS do not —
249
+ * they are per-project state the host owns, see `styleGet`/`styleSet`. */
250
+ const STORE_APPEARANCE = 'dsh-ui-gitworkbench:appearance'
251
+ const STORE_WIDTH = 'dsh-ui-gitworkbench:width'
252
+ const STORE_PANES = 'dsh-ui-gitworkbench:panes'
253
+
254
+ /** Dragged pane widths in px; null on either side keeps that pane's CSS default. */
255
+ interface PaneWidths {
256
+ readonly commits: number | null
257
+ readonly tree: number | null
258
+ }
259
+
260
+ const DEFAULT_PANES: PaneWidths = { commits: null, tree: null }
261
+
262
+ /**
263
+ * @param value - value read back from storage.
264
+ * @returns whether it is a pane-width pair this build can use.
265
+ */
266
+ function isPaneWidths(value: unknown): value is PaneWidths {
267
+ if (typeof value !== 'object' || value === null) return false
268
+ const { commits, tree } = value as Partial<PaneWidths>
269
+ const ok = (v: unknown): boolean => v === null || (typeof v === 'number' && Number.isFinite(v))
270
+ return ok(commits) && ok(tree)
271
+ }
272
+
273
+ /**
274
+ * Read a persisted preference.
275
+ *
276
+ * Storage is a durable boundary holding values an older build wrote, so every
277
+ * read is validated and anything unrecognized falls back rather than propagating.
278
+ * @param key - storage key.
279
+ * @param accept - narrows a parsed value to the expected type.
280
+ * @param fallback - used when the key is absent, unparsable, or rejected.
281
+ * @returns the stored value, or the fallback.
282
+ */
283
+ function readStored<T>(key: string, accept: (value: unknown) => value is T, fallback: T): T {
284
+ try {
285
+ const raw = localStorage.getItem(key)
286
+ if (raw === null) return fallback
287
+ const parsed: unknown = JSON.parse(raw)
288
+ return accept(parsed) ? parsed : fallback
289
+ } catch {
290
+ // Storage can be disabled outright, and a half-written value can fail to
291
+ // parse; a preference is never worth failing a render over.
292
+ return fallback
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Persist a preference, ignoring a storage that refuses writes.
298
+ * @param key - storage key.
299
+ * @param value - JSON-serializable value.
300
+ */
301
+ function writeStored(key: string, value: unknown): void {
302
+ try {
303
+ localStorage.setItem(key, JSON.stringify(value))
304
+ } catch {
305
+ // Private mode and a full quota both throw here. The session keeps the
306
+ // choice in memory; only its durability is lost.
307
+ }
308
+ }
309
+
310
+ /**
311
+ * One pointer-captured horizontal drag, shared by the drawer's leading edge and
312
+ * both pane dividers.
313
+ *
314
+ * Pointer capture is what keeps a drag alive over every pane and past the window
315
+ * edge; a plain mousemove listener on the handle loses it as soon as the pointer
316
+ * crosses a child that stops propagation.
317
+ * @returns the active flag for styling, and the pointerdown handler to attach.
318
+ */
319
+ function useHorizontalDrag(): {
320
+ dragging: boolean
321
+ start: (event: ReactPointerEvent<HTMLElement>, onDrag: (clientX: number, done: boolean) => void) => void
322
+ } {
323
+ const [dragging, setDragging] = useState(false)
324
+ const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (clientX: number, done: boolean) => void): void => {
325
+ event.preventDefault()
326
+ const handle = event.currentTarget
327
+ handle.setPointerCapture(event.pointerId)
328
+ setDragging(true)
329
+ const onMove = (move: PointerEvent): void => { onDrag(move.clientX, false) }
330
+ // `pointercancel` ends a drag the browser took over (a touch became a
331
+ // gesture, the window lost focus). It releases capture itself, so only the
332
+ // pointerup path releases — and both must detach, or the next drag stacks a
333
+ // second set of listeners on the same handle.
334
+ const finish = (end: PointerEvent): void => {
335
+ if (end.type === 'pointerup') {
336
+ onDrag(end.clientX, true)
337
+ handle.releasePointerCapture(end.pointerId)
338
+ }
339
+ handle.removeEventListener('pointermove', onMove)
340
+ handle.removeEventListener('pointerup', finish)
341
+ handle.removeEventListener('pointercancel', finish)
342
+ setDragging(false)
343
+ }
344
+ handle.addEventListener('pointermove', onMove)
345
+ handle.addEventListener('pointerup', finish)
346
+ handle.addEventListener('pointercancel', finish)
347
+ }
348
+ return { dragging, start }
349
+ }
350
+
351
+ /**
352
+ * Drag handle between two panes.
353
+ * @param label - accessible name.
354
+ * @param onDrag - receives the pointer's x and whether the drag just ended.
355
+ * @returns the divider element.
356
+ */
357
+ function PaneDivider({ label, onDrag }: {
358
+ label: string
359
+ onDrag: (clientX: number, done: boolean) => void
360
+ }): ReactNode {
361
+ const { dragging, start } = useHorizontalDrag()
362
+ return (
363
+ <div
364
+ className={dragging ? `${css.paneDivider} ${css.paneDividerActive}` : css.paneDivider}
365
+ role="separator"
366
+ aria-orientation="vertical"
367
+ aria-label={label}
368
+ onPointerDown={event => start(event, onDrag)}
369
+ />
370
+ )
371
+ }
372
+
373
+ /** Commit change sets kept in the browser before the least recently used is dropped. */
374
+ const COMMIT_CACHE_CAPACITY = 24
375
+
376
+ /** Stand-in while a commit's change set is in flight — every pane renders empty. */
377
+ const EMPTY_STATS: WorkbenchStats = {
378
+ worktreePath: '', branch: '', ahead: 0, behind: 0, detached: false,
379
+ addedLines: 0, deletedLines: 0, addedFiles: 0, deletedFiles: 0, modifiedFiles: 0,
380
+ files: [], diff: '', commits: [],
381
+ }
382
+
383
+ /** The overlay with nothing on it — one instance, so an empty overlay never
384
+ * re-renders the tree that receives it. */
385
+ const EMPTY_TICKS: ReadonlyMap<string, TickAction> = new Map()
386
+
387
+ /** How often a queued tick batch re-checks whether a heavy operation has
388
+ * released the git lock. Short: ticks are clicks someone is watching. */
389
+ const TICK_RETRY_MS = 25
390
+
391
+ /** How often the shut chip re-reads the session's binding while the agent is
392
+ * running. Matched to the open drawer's busy rate — the probe is a JSON read
393
+ * with no git behind it, so the cost that set the 15s idle rate is absent. */
394
+ const BINDING_PROBE_MS = 3_000
395
+
396
+ /**
397
+ * Pick the ref a comparison starts from.
398
+ *
399
+ * The integration branch is what one almost always compares against, so it wins
400
+ * when it exists and is not already the other side; otherwise any other branch
401
+ * beats an empty picker.
402
+ * @param branches - the repository's local branches.
403
+ * @param head - the ref being compared, which must not also be the base.
404
+ * @returns the default base ref, or an empty string when there is no candidate.
405
+ */
406
+ function defaultBase(branches: readonly string[], head: string): string {
407
+ for (const preferred of ['main', 'master']) {
408
+ if (branches.includes(preferred) && preferred !== head) return preferred
409
+ }
410
+ return branches.find(branch => branch !== head) ?? ''
411
+ }
412
+
413
+ const STATUS_BADGE: Record<GitFileStatus, string> = {
414
+ added: css.stAdded, untracked: css.stUntracked, modified: css.stModified,
415
+ renamed: css.stRenamed, deleted: css.stDeleted,
416
+ }
417
+
418
+ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp }: Props) {
419
+ const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
420
+ state?.byId?.[sessionId]?.cwd) as string | undefined
421
+ /** Whether the session's agent has a turn in flight — the store mirrors it
422
+ * live, so it is the signal for polling faster while there is something to
423
+ * watch (the agent may be staging, committing, or entering worktrees). */
424
+ const agentRunning = useSessions((state: { byId?: Record<string, { running?: boolean } | undefined> }) =>
425
+ state?.byId?.[sessionId]?.running) as boolean | undefined
426
+
427
+ const [stats, setStats] = useState<WorkbenchStats | null>(null)
428
+ const [open, setOpen] = useState(false)
429
+ const [selected, setSelected] = useState<string | null>(null)
430
+ /** Generation counter — bumped on drawer open, manual refresh and source switch:
431
+ * the events after which working-tree content can genuinely differ. Tab and
432
+ * commit selection do NOT bump it (they change which view is shown, not the
433
+ * working tree), and background polls never touch it. */
434
+ const [gen, setGen] = useState(0)
435
+ /** Tree expansion state, session-lifetime: survives polls, source switches and drawer close/reopen. */
436
+ const [collapsed, setCollapsed] = useState<Set<string> | undefined>(undefined)
437
+ /** Binding + the repository's worktrees, null until the first successful fetch. */
438
+ const [wtStatus, setWtStatus] = useState<WorktreeStatus | null>(null)
439
+ /** Worktree the drawer reads, by absolute path. null = follow the session's own
440
+ * (the bound worktree when one exists, else the session cwd); reset on open. */
441
+ const [sourcePath, setSourcePath] = useState<string | null>(null)
442
+ /** Active drawer tab; the drawer always opens on the working tree. */
443
+ const [tab, setTab] = useState<Tab>('changes')
444
+ /** Commit selected in the history tab; null until one is picked. */
445
+ const [commitHash, setCommitHash] = useState<string | null>(null)
446
+ /** That commit's change set, or null while its fetch is in flight. */
447
+ const [commitStats, setCommitStats] = useState<WorkbenchStats | null>(null)
448
+ /**
449
+ * Change sets already fetched, keyed by worktree + hash. A commit hash
450
+ * addresses content that cannot change, so a hit is served with no round trip
451
+ * AND without the null pass that blanks the panes — re-selecting a commit is
452
+ * immediate rather than a second loading flash. Bounded; a Map's insertion
453
+ * order is its recency order.
454
+ */
455
+ const commitCache = useRef(new Map<string, WorkbenchStats>())
456
+ /** Ref the history tab walks; null follows the active worktree's own branch.
457
+ * A branch needs no worktree to have a log, so this is how a branch that is
458
+ * checked out nowhere still becomes browsable. */
459
+ const [historyRef, setHistoryRef] = useState<string | null>(null)
460
+ /** Every history page loaded for the current worktree + ref. */
461
+ const [historyCommits, setHistoryCommits] = useState<readonly GitCommit[]>([])
462
+ const [historyHasMore, setHistoryHasMore] = useState(false)
463
+ /** First page of the history list in flight — the pane says "loading", not
464
+ * "no commit history", which is a claim about the repository. */
465
+ const [historyLoading, setHistoryLoading] = useState(false)
466
+ const [loadingMore, setLoadingMore] = useState(false)
467
+ /** In-flight marker for paging, read synchronously — see {@link loadMoreCommits}. */
468
+ const loadingRef = useRef(false)
469
+ /** Drawer occupies the whole viewport. Panel-level state, so the choice holds
470
+ * across tab switches and reopens rather than resetting under the user. */
471
+ const [maximized, setMaximized] = useState(false)
472
+ /** Dragged width in px; null keeps the responsive default. */
473
+ const [width, setWidth] = useState<number | null>(
474
+ () => readStored(STORE_WIDTH, (value): value is number => typeof value === 'number' && Number.isFinite(value), null as number | null),
475
+ )
476
+ const [mode, setMode] = useState<ColorMode>(
477
+ () => readStored(STORE_APPEARANCE, isAppearance, DEFAULT_APPEARANCE).mode,
478
+ )
479
+ const [family, setFamily] = useState<ThemeFamily>(
480
+ () => readStored(STORE_APPEARANCE, isAppearance, DEFAULT_APPEARANCE).family,
481
+ )
482
+ /** Dragged pane widths, persisted so a layout survives a reload. */
483
+ const [panes, setPanes] = useState<PaneWidths>(
484
+ () => readStored(STORE_PANES, isPaneWidths, DEFAULT_PANES),
485
+ )
486
+ /** Per-project and global styling; both scopes, unresolved. */
487
+ const [style, setStyle] = useState<StyleSettings>(EMPTY_SETTINGS)
488
+ /** Whether dsh's resolved palette is currently dark. */
489
+ const [hostDark, setHostDark] = useState(
490
+ () => typeof document !== 'undefined' && hostSchemeDark(document.body),
491
+ )
492
+
493
+ // dsh can flip light/dark from Settings without remounting this panel, so
494
+ // watch the attribute ThemePresenter toggles. A one-time read would leave
495
+ // the drawer stranded in whichever scheme it happened to mount in.
496
+ useEffect(() => {
497
+ const sync = (): void => { setHostDark(hostSchemeDark(document.body)) }
498
+ sync()
499
+ const observer = new MutationObserver(sync)
500
+ observer.observe(document.body, { attributes: true, attributeFilter: [DSH_DARK_ATTR] })
501
+ return () => observer.disconnect()
502
+ }, [])
503
+ /** Compare tab refs; null follows the computed default rather than pinning one,
504
+ * so switching worktree moves the comparison with it. */
505
+ const [compareBase, setCompareBase] = useState<string | null>(null)
506
+ const [compareHead, setCompareHead] = useState<string | null>(null)
507
+ /** The comparison's change set, or null while its fetch is in flight. */
508
+ const [compareStats, setCompareStats] = useState<WorkbenchStats | null>(null)
509
+ /** Divergence from the upstream; null until the first read, or outside a repo. */
510
+ const [sync, setSync] = useState<SyncStatus | null>(null)
511
+ /** The write operation currently running, or null. One at a time on purpose:
512
+ * git takes an index lock, so a second concurrent op fails on the lock rather
513
+ * than queueing, and a disabled button explains that better than an error. */
514
+ const [busy, setBusy] = useState<GitOpName | null>(null)
515
+ /** Outcome of the last write operation, shown until the next one starts. */
516
+ const [opResult, setOpResult] = useState<{ op: GitOpName; result: GitOpResult } | null>(null)
517
+ /** Ticks clicked but not yet confirmed by a payload — the optimistic layer
518
+ * between a click and the git call it queues. Keyed by path; the newest
519
+ * click for a path wins. */
520
+ const [pendingTicks, setPendingTicks] = useState<ReadonlyMap<string, TickAction>>(EMPTY_TICKS)
521
+ /** Clicks waiting for their git call, in the order they arrived. */
522
+ const tickQueueRef = useRef<readonly Tick[]>([])
523
+ /** Whether a drain loop is running — one at a time, so ticks queue up behind
524
+ * a batch in flight instead of racing it for the git lock. */
525
+ const drainingRef = useRef(false)
526
+ /** Bumped when the drawer's source changes: ticks belong to the worktree
527
+ * they were clicked in, and a loop started under one source must not run
528
+ * batches queued under the next. */
529
+ const tickEpochRef = useRef(0)
530
+ /** The newest render's drain loop. A loop outlives the render it started
531
+ * in; everything it must see fresh it reads through a ref, and this is how
532
+ * a retired loop hands the queue to a current one. */
533
+ const drainRef = useRef((): Promise<void> => Promise.resolve())
534
+ /** The git lock, as a ref. `busy` above is state and stays for display; a
535
+ * drain loop issuing calls from one long-lived closure would never see a
536
+ * state value change under it. */
537
+ const busyRef = useRef<GitOpName | null>(null)
538
+ /**
539
+ * The commit message being written, and whether it amends.
540
+ *
541
+ * Held here rather than in the commit box, because the box unmounts whenever
542
+ * the drawer leaves the Changes tab — a glance at the history would otherwise
543
+ * throw away a message the user had already typed, with no way to get it back.
544
+ */
545
+ const [commitDraft, setCommitDraft] = useState('')
546
+ const [commitAmend, setCommitAmend] = useState(false)
547
+
548
+ const binding = wtStatus?.binding ?? null
549
+ const worktrees = wtStatus?.worktrees ?? []
550
+ const branches = wtStatus?.branches ?? []
551
+ const branchesTruncated = wtStatus?.branchesTruncated ?? false
552
+ /** Branches that have a worktree — what the pickers group to the top. */
553
+ const worktreeBranches = worktrees.map(entry => entry.branch).filter(branch => branch.length > 0)
554
+ /** The session's own worktree: the bound one, else its cwd. The default view. */
555
+ const sessionPath = binding?.worktreePath ?? worktreePath
556
+ /** What everything here is about. The drawer's pin only counts while the
557
+ * drawer is open — see {@link viewedPath} for why that is a rule and not a
558
+ * reset in the close handler. */
559
+ const statsPath = viewedPath(open, sourcePath, sessionPath)
560
+ /** The latest `statsPath`, readable by a response that started under an older
561
+ * one. The 15s poll's in-flight fetch survives a source switch (clearing the
562
+ * interval does not abort it), and without this check it would repaint the
563
+ * tree with the worktree the user just left. */
564
+ const statsPathRef = useRef(statsPath)
565
+ statsPathRef.current = statsPath
566
+ /** Whether the KEYED stats fetch (source switch, refresh, open) is in flight.
567
+ * The tree must say "loading", not render the empty placeholder as "no
568
+ * changes" — those are different sentences and the wrong one reads as data. */
569
+ const [statsLoading, setStatsLoading] = useState(true)
570
+
571
+ // Binding and worktree list keep up with the agent's enter/exit and with
572
+ // worktrees created outside dsh: mount + every drawer open/close.
573
+ useEffect(() => {
574
+ const ctrl = new AbortController()
575
+ fetchWorktreeStatus(sessionId, worktreePath, ctrl.signal)
576
+ .then(value => { if (value !== null) setWtStatus(value) })
577
+ .catch(() => {})
578
+ return () => ctrl.abort()
579
+ }, [sessionId, worktreePath, fetchWorktreeStatus, open])
580
+
581
+ /** The binding on screen, readable by a probe loop that outlives its render.
582
+ * The full status arrives on a timer, so a loop that closed over one would
583
+ * keep comparing the probe against whatever was true when it started. */
584
+ const wtStatusRef = useRef(wtStatus)
585
+ wtStatusRef.current = wtStatus
586
+
587
+ /**
588
+ * Keep the SHUT chip's binding honest while the agent works.
589
+ *
590
+ * The effect above is the only thing that reads the binding, and none of its
591
+ * deps move when `worktree_enter` runs: dsh's `session.header.cwd` is
592
+ * immutable, so the sessions store says nothing, and the 3-15s poll below
593
+ * starts at `if (!open) return`. The chip therefore sat on `main` after the
594
+ * agent had entered a worktree until someone opened the drawer — the one act
595
+ * that flips `open` — which is exactly the wrong thing for an indicator to do.
596
+ *
597
+ * What runs here is a probe, not a fetch: `sessionWorktree` reads the bindings
598
+ * JSON and spawns no git, so the quiet case costs one file read. Only when it
599
+ * disagrees with the chip does one full `worktreeStatus` follow, and that one
600
+ * brings the worktree list and branches the badge and picker need.
601
+ *
602
+ * The window is narrow by construction ({@link probesClosedBinding}): a
603
+ * binding moves only inside a turn, and this panel is mounted in every session
604
+ * header, so an idle session opens no timer at all. The unconditional probe on
605
+ * entry is the backstop for a turn shorter than one interval — the deps carry
606
+ * `agentRunning`, so the end of every turn re-runs this and asks once.
607
+ */
608
+ useEffect(() => {
609
+ if (open) return
610
+ let alive = true
611
+ const probe = (): void => {
612
+ // Nothing to disagree with until the first full status has landed; the
613
+ // mount fetch above is still in flight and will answer this itself.
614
+ if (wtStatusRef.current === null) return
615
+ const ctrl = new AbortController()
616
+ fetchSessionBinding(sessionId, ctrl.signal)
617
+ .then(value => {
618
+ if (!alive || value === null) return
619
+ if (!bindingChanged(value, wtStatusRef.current?.binding ?? null)) return
620
+ const full = new AbortController()
621
+ fetchWorktreeStatus(sessionId, worktreePath, full.signal)
622
+ .then(status => { if (alive && status !== null) setWtStatus(status) })
623
+ .catch(() => {})
624
+ })
625
+ .catch(() => {})
626
+ }
627
+ probe()
628
+ if (!probesClosedBinding(open, agentRunning)) return () => { alive = false }
629
+ const id = setInterval(probe, BINDING_PROBE_MS)
630
+ return () => { alive = false; clearInterval(id) }
631
+ }, [open, agentRunning, sessionId, worktreePath, fetchSessionBinding, fetchWorktreeStatus])
632
+
633
+ // Stats for the active source: on mount, on source change and on gen bumps
634
+ // (manual refresh / source switch). Cleanup aborts a superseded in-flight fetch.
635
+ useEffect(() => {
636
+ const ctrl = new AbortController()
637
+ let alive = true
638
+ setStatsLoading(true)
639
+ fetchStats(statsPath, ctrl.signal)
640
+ .then(value => {
641
+ if (!alive) return
642
+ setStatsLoading(false)
643
+ if (value !== null && statsPathRef.current === statsPath) setStats(value)
644
+ })
645
+ .catch(() => { if (alive) setStatsLoading(false) })
646
+ return () => { alive = false; ctrl.abort() }
647
+ }, [statsPath, fetchStats, gen])
648
+
649
+ // Divergence from the upstream, refetched with the stats. Cheap (two git
650
+ // reads, no diff), and it has to move in step with the file list: committing
651
+ // changes both, and a stale ahead count beside a fresh tree is worse than none.
652
+ useEffect(() => {
653
+ if (!open) return
654
+ const ctrl = new AbortController()
655
+ fetchSync(statsPath, ctrl.signal).then(value => { if (value !== null) setSync(value) }).catch(() => {})
656
+ return () => ctrl.abort()
657
+ }, [statsPath, fetchSync, gen, open])
658
+
659
+ // Styling follows the source, since the project scope is keyed by repository:
660
+ // switching to a worktree of another repo must bring that repo's background.
661
+ // Not polled — nothing else writes this file while the drawer is open.
662
+ useEffect(() => {
663
+ const ctrl = new AbortController()
664
+ fetchStyle(statsPath, ctrl.signal).then(value => { if (value !== null) setStyle(value) }).catch(() => {})
665
+ return () => ctrl.abort()
666
+ }, [statsPath, fetchStyle, open])
667
+
668
+ const background = effectiveBackground(style)
669
+ const customCss = effectiveCss(style)
670
+
671
+ // The custom stylesheet is a document-level element rather than a <style> in
672
+ // the tree: it must be able to reach the overlay, which React portals aside,
673
+ // and it has to survive the drawer closing so a reopen does not reflow.
674
+ useEffect(() => {
675
+ if (customCss.length === 0) {
676
+ document.getElementById(CUSTOM_STYLE_ID)?.remove()
677
+ return
678
+ }
679
+ let element = document.getElementById(CUSTOM_STYLE_ID)
680
+ if (element === null) {
681
+ element = document.createElement('style')
682
+ element.id = CUSTOM_STYLE_ID
683
+ document.head.append(element)
684
+ }
685
+ element.textContent = customCss
686
+ }, [customCss])
687
+
688
+ // Background poll. A working tree changes under the plugin's feet — an editor
689
+ // saves, a build writes, another shell commits — and none of that reaches the
690
+ // session log, so it cannot be pushed and freshness is bought with polling.
691
+ // (dsh does offer a push channel, `ctx.sessionProjections`, but a projection
692
+ // is a fold over committed session events, which is a different question from
693
+ // "what does `git status` say".) Two rates: while the session's agent is
694
+ // running it may stage, commit or enter worktrees at any moment, and a drawer
695
+ // that claims to show the working tree should keep up with it; idle, 15s is
696
+ // plenty. The stats write is guarded so an in-flight response from a retired
697
+ // source can never repaint the tree.
698
+ const pollMs = agentRunning === true ? 3_000 : 15_000
699
+ useEffect(() => {
700
+ if (!open) return
701
+ const id = setInterval(() => {
702
+ const ctrl = new AbortController()
703
+ fetchStats(statsPath, ctrl.signal)
704
+ .then(value => { if (value !== null && statsPathRef.current === statsPath) setStats(value) })
705
+ .catch(() => {})
706
+ const bctrl = new AbortController()
707
+ fetchWorktreeStatus(sessionId, worktreePath, bctrl.signal).then(value => { if (value !== null) setWtStatus(value) }).catch(() => {})
708
+ }, pollMs)
709
+ return () => clearInterval(id)
710
+ }, [open, pollMs, statsPath, fetchStats, sessionId, worktreePath, fetchWorktreeStatus])
711
+
712
+ /** Ref the history list actually walks. Empty asks the host for the worktree's
713
+ * own HEAD, which is right for a detached checkout too. */
714
+ const effectiveHistoryRef = historyRef ?? stats?.branch ?? ''
715
+
716
+ /** The worktree list, readable by the switch effect below without joining its
717
+ * deps. The poll hands back a fresh array every 3-15s, so a dependency here
718
+ * would blank the tree on a timer. */
719
+ const worktreesRef = useRef(worktrees)
720
+ worktreesRef.current = worktrees
721
+
722
+ // A source switch drops everything that names the worktree the user just
723
+ // left: the ref override, the selection, the divergence, and the file list
724
+ // itself. The list is the one correctness rides on — until the new stats land
725
+ // there is no tree to trust, and a tick clicked in that window would hand
726
+ // paths from the old worktree to `git` in the new one ("pathspec did not
727
+ // match any file(s)"). The placeholder keeps `stats` non-null on purpose: the
728
+ // panel renders nothing at all when it is null, and unmounting the drawer
729
+ // mid-switch would be a bigger disruption than the blank tree.
730
+ //
731
+ // The branch, though, is already known: it came with the worktree list the
732
+ // user just picked from. Leaving it empty made the header claim `(no branch)`
733
+ // for the length of a `git status` — not a slower answer but a wrong one, and
734
+ // on a large repository it sat there for seconds.
735
+ useEffect(() => {
736
+ setHistoryRef(null)
737
+ setSelected(null)
738
+ // The old worktree's ahead/behind would otherwise ride out the switch above
739
+ // a file list that has already been emptied.
740
+ setSync(null)
741
+ // Ticks belong to the worktree they were clicked in. The queue goes with
742
+ // them; the epoch bump retires any drain loop still working through it, so
743
+ // a queued batch can never run `git add` in the worktree the user just
744
+ // left — the pathspec error the switch effect above already guards the
745
+ // click itself against.
746
+ tickQueueRef.current = []
747
+ tickEpochRef.current += 1
748
+ setPendingTicks(EMPTY_TICKS)
749
+ setStats({
750
+ ...EMPTY_STATS,
751
+ worktreePath: statsPath,
752
+ branch: branchOfWorktree(statsPath, worktreesRef.current),
753
+ })
754
+ }, [statsPath])
755
+
756
+ // Follow the agent across worktree_enter/exit. When the session's binding
757
+ // moves, the work the drawer exists to show moved with it — a drawer still
758
+ // pointed at the worktree the session just left is describing the past, and
759
+ // the reader has no way to know without clicking the chip themselves. A
760
+ // source the user pinned to some THIRD worktree is a deliberate choice and
761
+ // survives; only the view of the place the session used to be follows.
762
+ const bindingPath = binding?.worktreePath ?? null
763
+ const lastBindingRef = useRef(bindingPath)
764
+ useEffect(() => {
765
+ const prevBinding = lastBindingRef.current
766
+ lastBindingRef.current = bindingPath
767
+ if (prevBinding === bindingPath) return
768
+ const prevSource = prevBinding ?? worktreePath
769
+ if (sourcePath !== null && prevSource.replace(/\\/g, '/') === sourcePath.replace(/\\/g, '/')) {
770
+ setSourcePath(null)
771
+ }
772
+ }, [bindingPath, sourcePath, worktreePath])
773
+
774
+ // First page of the history list, reloaded whenever the worktree or the ref
775
+ // changes. The selection is dropped with it — a hash from another ref's log
776
+ // has no place in this one.
777
+ //
778
+ // Gated on the drawer being open: this panel is mounted in every session
779
+ // header, and a log nobody is looking at is a git spawn nobody asked for.
780
+ useEffect(() => {
781
+ if (!open) return
782
+ const ctrl = new AbortController()
783
+ let alive = true
784
+ setHistoryCommits([])
785
+ setHistoryHasMore(false)
786
+ setCommitHash(null)
787
+ setCommitStats(null)
788
+ setHistoryLoading(true)
789
+ fetchCommits(statsPath, effectiveHistoryRef, 0, HISTORY_PAGE, ctrl.signal)
790
+ .then(page => {
791
+ if (!alive) return
792
+ setHistoryLoading(false)
793
+ if (page === null) return
794
+ setHistoryCommits(page.commits)
795
+ setHistoryHasMore(page.hasMore)
796
+ })
797
+ .catch(() => { if (alive) setHistoryLoading(false) })
798
+ return () => { alive = false; ctrl.abort() }
799
+ }, [open, statsPath, effectiveHistoryRef, fetchCommits, gen])
800
+
801
+ // Never leave the history pane empty: with a list loaded and nothing picked,
802
+ // the newest commit is the selection.
803
+ useEffect(() => {
804
+ if (tab !== 'history' || commitHash !== null) return
805
+ const newest = historyCommits[0]
806
+ if (newest !== undefined) setCommitHash(newest.hash)
807
+ }, [tab, commitHash, historyCommits])
808
+
809
+ useEffect(() => {
810
+ if (commitHash === null) return
811
+ const key = `${statsPath ?? ''}\x1f${commitHash}`
812
+ const hit = commitCache.current.get(key)
813
+ if (hit !== undefined) { setCommitStats(hit); return }
814
+ const ctrl = new AbortController()
815
+ setCommitStats(null)
816
+ fetchCommitStats(statsPath, commitHash, ctrl.signal)
817
+ .then(value => {
818
+ if (value === null) return
819
+ const cache = commitCache.current
820
+ cache.delete(key)
821
+ cache.set(key, value)
822
+ if (cache.size > COMMIT_CACHE_CAPACITY) {
823
+ const oldest = cache.keys().next()
824
+ if (!oldest.done) cache.delete(oldest.value)
825
+ }
826
+ setCommitStats(value)
827
+ })
828
+ .catch(() => {})
829
+ return () => ctrl.abort()
830
+ }, [commitHash, statsPath, fetchCommitStats])
831
+
832
+ /** Refs the compare tab reads. An explicit pick wins; otherwise the session's
833
+ * own branch is compared against the integration branch. */
834
+ const headRef = compareHead ?? stats?.branch ?? ''
835
+ const baseRef = compareBase ?? defaultBase(branches, headRef)
836
+ /** A comparison needs two distinct, named refs; anything else has nothing to show. */
837
+ const comparable = baseRef.length > 0 && headRef.length > 0 && baseRef !== headRef
838
+
839
+ useEffect(() => {
840
+ if (tab !== 'compare' || !comparable) return
841
+ const ctrl = new AbortController()
842
+ setCompareStats(null)
843
+ fetchCompare(statsPath, baseRef, headRef, ctrl.signal)
844
+ .then(value => { if (value !== null) setCompareStats(value) })
845
+ .catch(() => {})
846
+ return () => ctrl.abort()
847
+ }, [tab, comparable, baseRef, headRef, statsPath, fetchCompare])
848
+
849
+ // A tick stays on the overlay only until a payload confirms it. The first
850
+ // fetch after the git call is exactly that confirmation; without this the
851
+ // optimistic layer would sit over the real flags forever. The functional
852
+ // update keeps an all-settled payload from re-rendering the tree for nothing.
853
+ useEffect(() => {
854
+ setPendingTicks(prev => {
855
+ if (prev.size === 0) return prev
856
+ const settled = settledTicks(stats?.files ?? [], prev)
857
+ if (settled.size === 0) return prev
858
+ const next = new Map(prev)
859
+ for (const path of settled.keys()) next.delete(path)
860
+ return next
861
+ })
862
+ }, [stats?.files])
863
+
864
+ /** What the drawer's tree, diff and totals describe. */
865
+ const shown = tab === 'history' ? commitStats : tab === 'compare' ? compareStats : stats
866
+ const segments = useMemo(() => splitDiff(shown?.diff ?? ''), [shown?.diff])
867
+ /** Names the view the per-file diff cache belongs to: one path means different
868
+ * content in the working tree, in each commit, and in each comparison. */
869
+ const viewKey = tab === 'history'
870
+ ? `commit:${commitHash ?? ''}`
871
+ : tab === 'compare' ? `compare:${baseRef}...${headRef}` : 'worktree'
872
+ /** Per-file diff fetcher bound to the active view. Stable per view, so the
873
+ * drawer's on-demand effect stops re-running on every render. */
874
+ const fetchDiffForView = useCallback(
875
+ (path: string, signal: AbortSignal): Promise<string> => {
876
+ // A comparison's per-file diff would need the ref range, which `fileDiff`
877
+ // does not take. Answering with the working tree's diff for that path
878
+ // would be plainly wrong, so a file past the payload cap simply has no
879
+ // detail on this tab.
880
+ if (tab === 'compare') return Promise.resolve('')
881
+ return fetchFileDiff(statsPath, path, tab === 'history' ? commitHash ?? undefined : undefined, signal)
882
+ },
883
+ [fetchFileDiff, statsPath, tab, commitHash],
884
+ )
885
+
886
+ // First stats fetch still in flight: render nothing. The cheap binding RPC
887
+ // often resolves before the heavy stats one — without this guard a persisted
888
+ // binding would paint the chip with stats === null and crash EnvCard.
889
+ if (stats === null) return null
890
+
891
+ // Chip discipline: the environment card is the session's branch indicator —
892
+ // it renders whenever the directory is a git repo, clean tree included
893
+ // (branch + ahead/behind + 0-count totals). Only a stats error (not a repo /
894
+ // git unavailable) hides the card. An OPEN drawer always stays mounted, so an
895
+ // empty source can be switched away from.
896
+ if (stats.error !== undefined && !open) return null
897
+
898
+ const refresh = (): void => {
899
+ setGen(g => g + 1)
900
+ const ctrl = new AbortController()
901
+ fetchWorktreeStatus(sessionId, worktreePath, ctrl.signal).then(value => { if (value !== null) setWtStatus(value) }).catch(() => {})
902
+ }
903
+
904
+ /**
905
+ * Run one write operation, then refresh whatever it could have changed.
906
+ *
907
+ * The refresh is unconditional — a FAILED operation can still have changed the
908
+ * tree. A pull that stops on a conflict has already written conflict markers
909
+ * into the files, and a drawer still showing the pre-pull list would be
910
+ * describing a working tree that no longer exists.
911
+ */
912
+ const runOp = async (op: GitOpName, payload: GitOpPayload = {}): Promise<GitOpResult> => {
913
+ // The lock is the ref, not the `busy` state: a drain loop issues its calls
914
+ // from one long-lived closure, and the state value captured at render
915
+ // never changes under it. `busy` is set below for display only.
916
+ if (busyRef.current !== null) return { ok: false, failure: 'unknown', error: 'another git operation is running' }
917
+ busyRef.current = op
918
+ setBusy(op)
919
+ try {
920
+ const result = await runGitOp(op, statsPath, payload, new AbortController().signal)
921
+ // The banner lives above the body, so every change to it moves the whole
922
+ // pane. Clearing it at the start of an op and re-showing it ~100ms later
923
+ // made the drawer shake on every tick — and a tick's outcome is already
924
+ // visible in place, in the box the user just clicked. So: the old banner
925
+ // stays while the op runs (it still describes the last outcome), a
926
+ // successful stage/unstage clears it rather than replacing it, and only
927
+ // heavy operations and failures announce themselves at all.
928
+ if (result.ok && (op === 'stage' || op === 'unstage')) setOpResult(null)
929
+ else setOpResult({ op, result })
930
+ return result
931
+ } finally {
932
+ busyRef.current = null
933
+ setBusy(null)
934
+ refresh()
935
+ }
936
+ }
937
+
938
+ /** Wait for the git lock, so a queued tick batch waits out a heavy
939
+ * operation instead of being refused by it. */
940
+ const waitNotBusy = async (): Promise<void> => {
941
+ while (busyRef.current !== null) await new Promise(resolve => { setTimeout(resolve, TICK_RETRY_MS) })
942
+ }
943
+
944
+ /**
945
+ * Hand queued ticks to git, one action-homogeneous batch at a time.
946
+ *
947
+ * One call at a time is what keeps a click from being dropped: two ticks
948
+ * 120ms apart used to race for the lock and the loser vanished without even
949
+ * an error. Now the second queues behind the first, and clicks arriving
950
+ * while a batch runs join the next batch as one `git add a b c`.
951
+ *
952
+ * Everything the loop must see fresh it reads through a ref; the queue
953
+ * itself is a ref because clicks arrive between the loop's awaits.
954
+ */
955
+ const drainTicks = async (): Promise<void> => {
956
+ if (drainingRef.current) return
957
+ drainingRef.current = true
958
+ const epoch = tickEpochRef.current
959
+ try {
960
+ while (tickEpochRef.current === epoch) {
961
+ // Wait for the lock before batching, not after: ticks that arrived
962
+ // while a heavy operation held it then join one batch instead of
963
+ // forming one per click.
964
+ await waitNotBusy()
965
+ if (tickEpochRef.current !== epoch) break
966
+ const batch = nextBatch(tickQueueRef.current)
967
+ if (batch === null) break
968
+ tickQueueRef.current = tickQueueRef.current.filter(
969
+ tick => tick.action !== batch.action || !batch.paths.includes(tick.path),
970
+ )
971
+ const result = await runOp(batch.action, { paths: batch.paths })
972
+ if (!result.ok) {
973
+ // The git call refused or failed. Take the paths back off the
974
+ // overlay so the box shows what git actually did — the banner runOp
975
+ // raised says why — but only where the overlay still carries this
976
+ // action: a later click may already have re-ticked the path.
977
+ setPendingTicks(prev => {
978
+ const next = new Map(prev)
979
+ for (const path of batch.paths) {
980
+ if (next.get(path) === batch.action) next.delete(path)
981
+ }
982
+ return next
983
+ })
984
+ }
985
+ }
986
+ } finally {
987
+ drainingRef.current = false
988
+ // A click that arrived during the last batch found `draining` set and
989
+ // trusted this loop to come back for it; a source switch retires this
990
+ // loop with the next source's clicks already queued. Either way the
991
+ // queue decides: empty means done, anything else is handed to the
992
+ // current render's loop.
993
+ if (nextBatch(tickQueueRef.current) !== null) void drainRef.current()
994
+ }
995
+ }
996
+ drainRef.current = drainTicks
997
+
998
+ /**
999
+ * Record ticks the moment they are clicked and hand their git calls to the
1000
+ * queue.
1001
+ *
1002
+ * The overlay update is the part the click is felt by: the box and the "N
1003
+ * ticked" counter move in the same frame as the click, and the refetch that
1004
+ * used to be the click's whole latency becomes a confirmation nobody waits
1005
+ * for.
1006
+ */
1007
+ const queueTicks = (action: TickAction, paths: readonly string[]): void => {
1008
+ if (paths.length === 0) return
1009
+ setPendingTicks(prev => {
1010
+ const next = new Map(prev)
1011
+ for (const path of paths) next.set(path, action)
1012
+ return next
1013
+ })
1014
+ tickQueueRef.current = [...tickQueueRef.current, ...paths.map(path => ({ path, action }))]
1015
+ void drainRef.current()
1016
+ }
1017
+
1018
+ /** Drawer source switch: the new path flips `statsPath` (the stats effect
1019
+ * refetches); the gen bump clears on-demand diff caches, whose content is
1020
+ * per-source. Picking the session's own worktree clears the override rather
1021
+ * than pinning it, so a later agent enter/exit still moves the default. */
1022
+ const switchSource = (next: string): void => {
1023
+ setSourcePath(next === sessionPath ? null : next)
1024
+ setGen(g => g + 1)
1025
+ }
1026
+
1027
+ const appearance: Appearance = { mode, family }
1028
+ const theme = resolveTheme(appearance, hostDark)
1029
+
1030
+ /** Persist alongside the state update, so the choice survives a reload. */
1031
+ const applyMode = (next: ColorMode): void => {
1032
+ setMode(next)
1033
+ writeStored(STORE_APPEARANCE, { mode: next, family })
1034
+ }
1035
+ const applyFamily = (next: ThemeFamily): void => {
1036
+ setFamily(next)
1037
+ writeStored(STORE_APPEARANCE, { mode, family: next })
1038
+ }
1039
+
1040
+ /**
1041
+ * Apply one scope's styling, and optionally store it.
1042
+ *
1043
+ * The local update always happens first: a background is judged by looking at
1044
+ * it, and a round trip between the control and the change makes that
1045
+ * impossible. `persist` is false while a slider is being dragged — each store
1046
+ * is a file write on the host, and a range input emits one event per pixel.
1047
+ * @param scope - which scope to write.
1048
+ * @param entry - its new value.
1049
+ * @param persist - whether to send it to the host.
1050
+ * @returns the host's verdict, or a bare success when nothing was sent.
1051
+ */
1052
+ const applyStyle = async (scope: StyleScope, entry: StyleEntry, persist: boolean): Promise<{ ok: boolean; error?: string }> => {
1053
+ setStyle(prev => withScope(prev, scope, entry))
1054
+ if (!persist) return { ok: true }
1055
+ // A refusal leaves the optimistic value on screen but unsaved; re-reading
1056
+ // would silently discard what the user is looking at, so the menu says so.
1057
+ return saveStyle(statsPath, scope, entry, new AbortController().signal)
1058
+ }
1059
+
1060
+ /**
1061
+ * Drag a pane divider.
1062
+ *
1063
+ * The upper bound is what keeps the diff readable: a pane may grow only into
1064
+ * space the other two do not need, so it is derived from the drawer's measured
1065
+ * width minus the neighbour's current width and the diff's minimum.
1066
+ * @param which - the pane being resized.
1067
+ * @param next - width in px the pointer implies.
1068
+ * @param measured - the drawer's inner width and the panes' current widths.
1069
+ * @param persist - whether to store it; false for intermediate drag frames.
1070
+ */
1071
+ const applyPane = (which: keyof PaneWidths, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean): void => {
1072
+ const min = which === 'commits' ? MIN_COMMITS_WIDTH : MIN_TREE_WIDTH
1073
+ const other = which === 'commits' ? measured.tree : measured.commits
1074
+ const max = Math.max(min, measured.drawer - other - MIN_DIFF_WIDTH)
1075
+ const clamped = Math.min(Math.max(next, min), max)
1076
+ setPanes(prev => {
1077
+ const updated = { ...prev, [which]: clamped }
1078
+ if (persist) writeStored(STORE_PANES, updated)
1079
+ return updated
1080
+ })
1081
+ }
1082
+
1083
+ /**
1084
+ * Drag the leading edge. Clamped at both ends — below the minimum the three
1085
+ * panes stop fitting, and past the viewport there is nothing to reveal. The
1086
+ * drag itself measures from the card's own right edge, so the upper clamp
1087
+ * lands on the inset card width without restating the inset here.
1088
+ * Dragging ends maximization, since the user just chose a width.
1089
+ * @param next - width in px the pointer implies.
1090
+ * @param persist - whether to store it. False for every intermediate frame of
1091
+ * a drag: `localStorage` writes synchronously, and one per pointermove would
1092
+ * put a disk write in the middle of the resize.
1093
+ */
1094
+ const applyWidth = (next: number, persist: boolean): void => {
1095
+ const clamped = Math.min(Math.max(next, MIN_DRAWER_WIDTH), window.innerWidth)
1096
+ setWidth(clamped)
1097
+ setMaximized(false)
1098
+ if (persist) writeStored(STORE_WIDTH, clamped)
1099
+ }
1100
+
1101
+ /** Tab switch. No direction refetches the working tree: `viewKey` already
1102
+ * separates the tabs' per-file diff caches, so bumping `gen` here only cost a
1103
+ * redundant round trip. */
1104
+ const switchTab = (next: Tab): void => {
1105
+ setTab(next)
1106
+ }
1107
+
1108
+ /** Append the next page of the log.
1109
+ *
1110
+ * The guard is a ref, not the `loadingMore` state: the scroll sentinel can
1111
+ * fire again before React has re-rendered with the state set, and two calls
1112
+ * at the same offset would append the same page twice. */
1113
+ const loadMoreCommits = (): void => {
1114
+ if (loadingRef.current || !historyHasMore) return
1115
+ loadingRef.current = true
1116
+ setLoadingMore(true)
1117
+ const ctrl = new AbortController()
1118
+ fetchCommits(statsPath, effectiveHistoryRef, historyCommits.length, HISTORY_PAGE, ctrl.signal)
1119
+ .then(page => {
1120
+ if (page === null) return
1121
+ setHistoryCommits(prev => [...prev, ...page.commits])
1122
+ setHistoryHasMore(page.hasMore)
1123
+ })
1124
+ .catch(() => {})
1125
+ .finally(() => { loadingRef.current = false; setLoadingMore(false) })
1126
+ }
1127
+
1128
+ /** Selecting a commit changes which view is rendered; the working tree it is
1129
+ * shown beside has not moved, so nothing about `stats` is refetched. */
1130
+ const selectCommit = (hash: string): void => {
1131
+ setCommitHash(hash)
1132
+ }
1133
+
1134
+ return (
1135
+ <>
1136
+ <EnvCard
1137
+ stats={stats}
1138
+ t={t}
1139
+ wtName={binding?.name ?? null}
1140
+ title={stats.worktreePath}
1141
+ onClick={() => { setOpen(true); setSourcePath(null); setTab('changes'); setGen(g => g + 1) }}
1142
+ />
1143
+ {open ? (
1144
+ <Drawer
1145
+ stats={stats}
1146
+ shown={shown}
1147
+ /** Per-tab in-flight flags, so an empty pane can say "loading" instead
1148
+ * of claiming the repository has nothing in it. */
1149
+ treeLoading={tab === 'changes' ? statsLoading
1150
+ : tab === 'history' ? historyLoading || (commitHash !== null && commitStats === null)
1151
+ : comparable && compareStats === null}
1152
+ historyLoading={historyLoading}
1153
+ tab={tab}
1154
+ onSwitchTab={switchTab}
1155
+ commits={historyCommits}
1156
+ commitHash={commitHash}
1157
+ onSelectCommit={selectCommit}
1158
+ hasMoreCommits={historyHasMore}
1159
+ loadingMore={loadingMore}
1160
+ onLoadMoreCommits={loadMoreCommits}
1161
+ historyRef={effectiveHistoryRef}
1162
+ onHistoryRef={setHistoryRef}
1163
+ branches={branches}
1164
+ worktreeBranches={worktreeBranches}
1165
+ branchesTruncated={branchesTruncated}
1166
+ baseRef={baseRef}
1167
+ headRef={headRef}
1168
+ onBaseRef={setCompareBase}
1169
+ onHeadRef={setCompareHead}
1170
+ comparable={comparable}
1171
+ t={t}
1172
+ binding={binding}
1173
+ worktrees={worktrees}
1174
+ sessionPath={sessionPath}
1175
+ statsPath={statsPath}
1176
+ onSwitchSource={switchSource}
1177
+ segments={segments}
1178
+ selected={selected}
1179
+ onSelect={setSelected}
1180
+ maximized={maximized}
1181
+ onToggleMaximized={() => setMaximized(value => !value)}
1182
+ theme={theme}
1183
+ mode={mode}
1184
+ family={family}
1185
+ onMode={applyMode}
1186
+ onFamily={applyFamily}
1187
+ style={style}
1188
+ background={background}
1189
+ onStyle={applyStyle}
1190
+ width={width}
1191
+ onWidth={applyWidth}
1192
+ panes={panes}
1193
+ onPane={applyPane}
1194
+ onClose={() => setOpen(false)}
1195
+ onRefresh={refresh}
1196
+ commitDraft={commitDraft}
1197
+ onCommitDraft={setCommitDraft}
1198
+ commitAmend={commitAmend}
1199
+ onCommitAmend={setCommitAmend}
1200
+ sync={sync}
1201
+ busy={busy}
1202
+ opResult={opResult}
1203
+ runOp={runOp}
1204
+ pendingTicks={pendingTicks}
1205
+ onTick={queueTicks}
1206
+ fetchFileDiff={fetchDiffForView}
1207
+ viewKey={viewKey}
1208
+ gen={gen}
1209
+ collapsed={collapsed}
1210
+ onCollapsedChange={setCollapsed}
1211
+ />
1212
+ ) : null}
1213
+ </>
1214
+ )
1215
+ }
1216
+
1217
+ /* ---------- header environment card ---------- */
1218
+
1219
+ /**
1220
+ * The drawer's window controls, as glyphs.
1221
+ *
1222
+ * Four words in four identical pills read as a paragraph, not as controls — and
1223
+ * three of these four are the actions every window on the machine already spells
1224
+ * with a picture. Bootstrap Icons (MIT), one 16 viewBox, one fill, so the row
1225
+ * reads as a set rather than four drawings that happen to sit together.
1226
+ */
1227
+ const CHROME_GLYPH = {
1228
+ settings: 'M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z',
1229
+ maximize: 'M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z',
1230
+ restore: 'M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z',
1231
+ // Single arrow, deliberately: the sync bar's Fetch is the two-arrow circle,
1232
+ // and at 14px the only thing telling them apart is the arrow count.
1233
+ refresh: 'M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z',
1234
+ close: 'M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z',
1235
+ } as const
1236
+
1237
+ function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
1238
+ return (
1239
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
1240
+ <path d={CHROME_GLYPH[of]} />
1241
+ </svg>
1242
+ )
1243
+ }
1244
+
1245
+ /**
1246
+ * Tree glyph: a root with two working copies hanging off it.
1247
+ *
1248
+ * This was git's fork glyph — the three-dot branch symbol — which named the
1249
+ * wrong thing. A worktree is not a branch; the picker beside it is already full
1250
+ * of branch names, and the two ideas need to stay tellable apart at 12px. A
1251
+ * hierarchy reads as "one repository, several directories", which is what a
1252
+ * worktree list is.
1253
+ */
1254
+ function WorktreeGlyph(): ReactNode {
1255
+ return (
1256
+ <svg className={css.cardGlyph} width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
1257
+ {/* Trunk down from the root, and the two limbs it puts out. */}
1258
+ <path d="M2.25 2.75h1.5v10.5h-1.5zM3 6.75h7.25v1.5H3zM3 11.75h7.25v1.5H3z" />
1259
+ {/* The root, then the worktrees. */}
1260
+ <circle cx="3" cy="2.75" r="1.75" />
1261
+ <circle cx="12" cy="7.5" r="1.75" />
1262
+ <circle cx="12" cy="12.5" r="1.75" />
1263
+ </svg>
1264
+ )
1265
+ }
1266
+
1267
+ interface EnvCardProps { stats: WorkbenchStats; t: Translate; wtName: string | null; title: string; onClick: () => void }
1268
+
1269
+ function EnvCard({ stats, t, wtName, title, onClick }: EnvCardProps): ReactNode {
1270
+ /* dsh derives the branch from the worktree's name, so for anything it created
1271
+ the badge was a second printing of the chip beside it — `wt/fixture-03`
1272
+ next to `fixture-03`. The glyph still says "this session is in a worktree";
1273
+ only the repeated word goes. Where the two names are independent (a
1274
+ worktree made outside dsh) the badge is the only thing naming the
1275
+ directory, so it stays — and its presence then means something. */
1276
+ const repeats = wtName !== null && badgeRepeatsBranch(stats.branch, wtName)
1277
+ return (
1278
+ <button type="button" className={css.card} title={title} onClick={onClick}>
1279
+ <span className={css.cardBranch}>
1280
+ {repeats ? <WorktreeGlyph /> : null}
1281
+ <Elided text={branchLabel(stats.branch, t('noBranch'))} className={css.cardBranchName} />
1282
+ </span>
1283
+ {wtName !== null && !repeats ? <span className={css.cardWt}><WorktreeGlyph />{wtName}</span> : null}
1284
+ {stats.detached ? <span className={css.cardDetached}>detached</span> : null}
1285
+ {stats.ahead > 0 ? <span className={css.cardAhead} title={t('aheadTitle', { count: stats.ahead })}>↑{stats.ahead}</span> : null}
1286
+ {stats.behind > 0 ? <span className={css.cardBehind} title={t('behindTitle', { count: stats.behind })}>↓{stats.behind}</span> : null}
1287
+ {stats.files.length > 0 ? (
1288
+ <>
1289
+ <span className={css.cardSep} />
1290
+ <span className={css.cardAdded}>+{stats.addedLines}</span>
1291
+ <span className={css.cardDeleted}>−{stats.deletedLines}</span>
1292
+ <span className={css.cardFiles}>{t('files', { count: stats.files.length })}</span>
1293
+ </>
1294
+ ) : null}
1295
+ </button>
1296
+ )
1297
+ }
1298
+
1299
+ /* ---------- drawer ---------- */
1300
+
1301
+ interface DrawerProps {
1302
+ /** The working tree — always the commit-list source and the header's branch. */
1303
+ stats: WorkbenchStats
1304
+ /** What the tree/diff/totals describe: the working tree, or the picked commit
1305
+ * (null while its fetch is in flight). */
1306
+ shown: WorkbenchStats | null
1307
+ tab: Tab
1308
+ onSwitchTab: (next: Tab) => void
1309
+ /** The whole history list: the bundled first page plus every page loaded since. */
1310
+ commits: readonly GitCommit[]
1311
+ commitHash: string | null
1312
+ onSelectCommit: (hash: string) => void
1313
+ hasMoreCommits: boolean
1314
+ loadingMore: boolean
1315
+ onLoadMoreCommits: () => void
1316
+ /** Ref the history list walks. */
1317
+ historyRef: string
1318
+ onHistoryRef: (ref: string) => void
1319
+ /** Every local branch — the ref pickers' options, worktree or not. */
1320
+ branches: readonly string[]
1321
+ /** Branches that have a worktree, grouped to the top of every picker. */
1322
+ worktreeBranches: readonly string[]
1323
+ /** Whether the host cut the branch list short. */
1324
+ branchesTruncated: boolean
1325
+ baseRef: string
1326
+ headRef: string
1327
+ onBaseRef: (ref: string) => void
1328
+ onHeadRef: (ref: string) => void
1329
+ /** False when the two refs are missing or identical, which has nothing to show. */
1330
+ comparable: boolean
1331
+ t: Translate
1332
+ binding: WorktreeBinding | null
1333
+ /** Every worktree of the repository — the source picker's options. */
1334
+ worktrees: readonly WorktreeEntry[]
1335
+ /** The session's own worktree (bound one, else its cwd): the default option. */
1336
+ sessionPath: string | undefined
1337
+ /** Worktree currently read; kept at the panel (it owns the fetch path). */
1338
+ statsPath: string | undefined
1339
+ onSwitchSource: (next: string) => void
1340
+ segments: Map<string, string>
1341
+ selected: string | null
1342
+ onSelect: (path: string | null) => void
1343
+ /** Whether the drawer fills the viewport. */
1344
+ maximized: boolean
1345
+ onToggleMaximized: () => void
1346
+ /** Resolved palette name for `data-gs-theme`. */
1347
+ theme: string
1348
+ mode: ColorMode
1349
+ family: ThemeFamily
1350
+ onMode: (next: ColorMode) => void
1351
+ onFamily: (next: ThemeFamily) => void
1352
+ /** Both styling scopes, unresolved — the menu edits them separately. */
1353
+ style: StyleSettings
1354
+ /** The background actually shown, already resolved; null for none. */
1355
+ background: StyleEntry | null
1356
+ /** Applies a styling change; `persist` is false for intermediate slider frames. */
1357
+ onStyle: (scope: StyleScope, entry: StyleEntry, persist: boolean) => Promise<{ ok: boolean; error?: string }>
1358
+ /** Dragged width in px; null keeps the responsive default. */
1359
+ width: number | null
1360
+ /** Applies a dragged width; `persist` is true only for the frame that ends the drag. */
1361
+ onWidth: (next: number, persist: boolean) => void
1362
+ /** Dragged pane widths; null on either side keeps that pane's CSS default. */
1363
+ panes: PaneWidths
1364
+ onPane: (which: keyof PaneWidths, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean) => void
1365
+ onClose: () => void
1366
+ onRefresh: () => void
1367
+ /** Commit draft, lifted so a tab switch cannot discard it. */
1368
+ commitDraft: string
1369
+ onCommitDraft: (next: string) => void
1370
+ commitAmend: boolean
1371
+ onCommitAmend: (next: boolean) => void
1372
+ /** Divergence from the upstream; null outside a repo or before the first read. */
1373
+ sync: SyncStatus | null
1374
+ /** Whether the tree's file list is still in flight for the view on screen.
1375
+ * The pane says "loading" rather than rendering the empty stand-in as a
1376
+ * "no changes" claim the data has not made yet. */
1377
+ treeLoading: boolean
1378
+ /** Whether the history list's first page is in flight — same rule. */
1379
+ historyLoading: boolean
1380
+ /** The write operation in flight, or null. Disables the others while set. */
1381
+ busy: GitOpName | null
1382
+ /** The last write operation's outcome, or null once a new one starts. */
1383
+ opResult: { op: GitOpName; result: GitOpResult } | null
1384
+ runOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
1385
+ /** Ticks awaiting their git call, keyed by path — overlaid over the file
1386
+ * list so the click is on screen before git confirms it. */
1387
+ pendingTicks: ReadonlyMap<string, TickAction>
1388
+ /** Queue the git calls for a tick batch. */
1389
+ onTick: (action: TickAction, paths: readonly string[]) => void
1390
+ fetchFileDiff: (path: string, signal: AbortSignal) => Promise<string>
1391
+ /** Identifies the view the per-file diff cache belongs to (working tree, or one commit). */
1392
+ viewKey: string
1393
+ gen: number
1394
+ collapsed: Set<string> | undefined
1395
+ onCollapsedChange: (next: Set<string>) => void
1396
+ }
1397
+
1398
+ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }: DrawerProps): ReactNode {
1399
+ // Empty stand-in while a commit's change set loads, so every hook below keeps a
1400
+ // stable shape and the panes simply render nothing.
1401
+ const body = shown ?? EMPTY_STATS
1402
+ /** The file list with ticks still awaiting git laid over them. The tree and
1403
+ * the commit box read this, so a click moves its box and the "N ticked"
1404
+ * counter in the click's own frame rather than a refetch later. Same
1405
+ * reference as `body.files` whenever nothing is pending. */
1406
+ const tickedFiles = withPendingTicks(body.files, pendingTicks)
1407
+ /** Whether this view has nothing to show YET, as opposed to showing good data
1408
+ * while a refresh lands over it. Derived once and handed to both the header
1409
+ * and the tree: spelling it twice is what let the header get it wrong. */
1410
+ const pending = showsPending(treeLoading, body.files.length)
1411
+ // A selection the current source no longer lists (e.g. after a source or tab
1412
+ // switch) falls back to the first file — never a dangling highlight.
1413
+ const active = selected !== null && body.files.some(file => file.path === selected)
1414
+ ? selected
1415
+ : body.files[0]?.path ?? null
1416
+ const activeFile = body.files.find(file => file.path === active) ?? null
1417
+ const [fetched, setFetched] = useState<Map<string, string>>(new Map())
1418
+ const [loading, setLoading] = useState(false)
1419
+ const bundled = active === null ? '' : segments.get(active) ?? ''
1420
+ // The cache spans views, so its key names one: the same path holds different
1421
+ // content in the working tree and in every commit.
1422
+ const activeKey = active === null ? null : `${viewKey}\x1f${active}`
1423
+ const segment = bundled.length > 0 ? bundled : activeKey === null ? '' : fetched.get(activeKey) ?? ''
1424
+
1425
+ // On-demand diff for files absent from the bundled payload (cap-truncated
1426
+ // untracked files, oversize paths).
1427
+ useEffect(() => {
1428
+ const path = active
1429
+ if (path === null || activeKey === null || bundled.length > 0) return
1430
+ const file = body.files.find(f => f.path === path)
1431
+ if (file === undefined || file.binary) return
1432
+ if (fetched.has(activeKey)) return
1433
+ const ctrl = new AbortController()
1434
+ setLoading(true)
1435
+ fetchFileDiff(path, ctrl.signal)
1436
+ .then(diff => { setFetched(prev => new Map(prev).set(activeKey, diff)) })
1437
+ .catch(() => {})
1438
+ .finally(() => { setLoading(false) })
1439
+ return () => ctrl.abort()
1440
+ }, [active, activeKey, bundled, body.files, fetched, fetchFileDiff])
1441
+
1442
+ // Reset the on-demand cache when the generation (refresh) advances.
1443
+ useEffect(() => { setFetched(new Map()) }, [gen])
1444
+
1445
+ const selectAndReveal = (path: string): void => onSelect(path)
1446
+
1447
+ const drawerRef = useRef<HTMLDivElement>(null)
1448
+ const commitsRef = useRef<HTMLDivElement>(null)
1449
+ const treeRef = useRef<HTMLDivElement>(null)
1450
+ const edgeDrag = useHorizontalDrag()
1451
+
1452
+ /**
1453
+ * What a pane drag is clamped against: the drawer's inner width and what the
1454
+ * panes currently occupy. Read live, because the drawer itself can have been
1455
+ * resized since the last render.
1456
+ * @returns the three widths in px.
1457
+ */
1458
+ const measurePanes = (): { drawer: number; commits: number; tree: number } => ({
1459
+ drawer: drawerRef.current?.clientWidth ?? window.innerWidth,
1460
+ commits: commitsRef.current?.getBoundingClientRect().width ?? 0,
1461
+ tree: treeRef.current?.getBoundingClientRect().width ?? 0,
1462
+ })
1463
+
1464
+ /**
1465
+ * @param which - the pane a divider resizes.
1466
+ * @param ref - that pane's element, whose left edge the width is measured from.
1467
+ * @returns a drag handler for {@link PaneDivider}.
1468
+ */
1469
+ const paneDrag = (which: keyof PaneWidths, ref: { current: HTMLDivElement | null }) =>
1470
+ (clientX: number, done: boolean): void => {
1471
+ const left = ref.current?.getBoundingClientRect().left
1472
+ if (left === undefined) return
1473
+ onPane(which, clientX - left, measurePanes(), done)
1474
+ }
1475
+
1476
+ /** The drawer's leading edge, measured from the card's own right edge — fixed
1477
+ * for the whole drag, so the inset between card and viewport is never
1478
+ * restated in JS. */
1479
+ const edgeDragHandler = (clientX: number, done: boolean): void => {
1480
+ const right = drawerRef.current?.getBoundingClientRect().right ?? window.innerWidth
1481
+ onWidth(right - clientX, done)
1482
+ }
1483
+
1484
+ /** A dragged pane width must beat the stylesheet's `max-width`, which was
1485
+ * written for the undragged default. */
1486
+ const paneStyle = (px: number | null): CSSProperties | undefined =>
1487
+ px === null ? undefined : { width: `${px}px`, maxWidth: 'none' }
1488
+
1489
+ // Width and the background's three tunables are inline because both are live
1490
+ // user values; the stylesheet only says what reads them. The pane floors are
1491
+ // inline for a different reason: they belong to the drag clamp above, and
1492
+ // restating them in CSS would give one fact two homes that can disagree.
1493
+ const cardStyle: CSSProperties = {
1494
+ // `@types/react` 18's CSSProperties has no index signature for custom
1495
+ // properties, so every --gs-* group is asserted rather than declared.
1496
+ ...{
1497
+ '--gs-min-commits': `${MIN_COMMITS_WIDTH}px`,
1498
+ '--gs-min-tree': `${MIN_TREE_WIDTH}px`,
1499
+ '--gs-min-diff': `${MIN_DIFF_WIDTH}px`,
1500
+ } as CSSProperties,
1501
+ ...maximized || width === null ? {} : { width: `${width}px` },
1502
+ ...background === null ? {} : {
1503
+ '--gs-bg-image': `url("${background.image}")`,
1504
+ '--gs-bg-blur': `${background.blur}px`,
1505
+ '--gs-veil': `${background.veil}%`,
1506
+ } as CSSProperties,
1507
+ }
1508
+
1509
+ return (
1510
+ <div
1511
+ className={maximized ? `${css.overlay} ${css.overlayMax}` : css.overlay}
1512
+ data-gs-theme={theme}
1513
+ data-gs-part="overlay"
1514
+ onClick={onClose}
1515
+ >
1516
+ <div
1517
+ ref={drawerRef}
1518
+ className={css.drawer}
1519
+ style={cardStyle}
1520
+ data-gs-part="card"
1521
+ {...background === null ? {} : { 'data-gs-bg': '' }}
1522
+ role="dialog"
1523
+ aria-label={t('drawerLabel')}
1524
+ onClick={event => event.stopPropagation()}
1525
+ >
1526
+ <div
1527
+ className={edgeDrag.dragging ? `${css.resizer} ${css.resizerActive}` : css.resizer}
1528
+ role="separator"
1529
+ aria-orientation="vertical"
1530
+ aria-label={t('resizeLabel')}
1531
+ onPointerDown={event => edgeDrag.start(event, edgeDragHandler)}
1532
+ />
1533
+ {/* One row for "where am I": the worktree, its path, and — when the view
1534
+ is about something other than the working tree — what that is. The
1535
+ branch used to be stated here AND in a picker row of its own, and
1536
+ the divergence here AND in the sync bar; both now have one home. */}
1537
+ <div className={css.header} data-gs-part="header">
1538
+ <div className={css.headerLeft}>
1539
+ <SourceChip
1540
+ t={t}
1541
+ worktrees={worktrees}
1542
+ boundPath={binding?.worktreePath ?? null}
1543
+ sessionPath={sessionPath}
1544
+ statsPath={statsPath}
1545
+ fallbackBranch={stats.branch}
1546
+ onSwitch={onSwitchSource}
1547
+ />
1548
+ <Elided text={stats.worktreePath} className={css.headerPathMain} title={stats.worktreePath} />
1549
+ {tab === 'changes' && stats.detached ? <span className={css.headerDetached}>detached HEAD</span> : null}
1550
+ {tab === 'history' && commitHash !== null ? <span className={css.headerView}>{commitHash}</span> : null}
1551
+ {tab === 'compare' ? (
1552
+ <span className={css.headerView}>
1553
+ <Elided text={branchLabel(baseRef, t('noBranch'))} className={css.headerViewRef} />
1554
+ {' → '}
1555
+ <Elided text={branchLabel(headRef, t('noBranch'))} className={css.headerViewRef} />
1556
+ </span>
1557
+ ) : null}
1558
+ {/* A confident `+0 −1` for a view with nothing behind it yet is not
1559
+ a slower answer, it is a wrong one — so the totals say "pending".
1560
+ But only then: a refresh landing over numbers already on screen
1561
+ must leave them alone. See {@link showsPending}. */}
1562
+ {pending ? <span className={css.headerTotalsDim}>—</span> : (
1563
+ <>
1564
+ <span className={css.headerTotals}>
1565
+ <span className={css.headerTotalsAdd}>+{body.addedLines}</span>{' '}
1566
+ <span className={css.headerTotalsDel}>−{body.deletedLines}</span>
1567
+ </span>
1568
+ <span className={css.headerTotalsDim}>
1569
+ {t('totalsDim', { added: body.addedFiles, modified: body.modifiedFiles, deleted: body.deletedFiles })}
1570
+ </span>
1571
+ {tab === 'compare' && comparable ? (
1572
+ <span className={css.headerTotalsDim}>
1573
+ {t('compareCommits', { count: shown?.commits.length ?? 0 })}
1574
+ </span>
1575
+ ) : null}
1576
+ </>
1577
+ )}
1578
+ </div>
1579
+ {/* Window controls, not sentences. Each keeps its word on `title` and
1580
+ `aria-label`, so nothing is lost to a reader who cannot see the
1581
+ glyph or does not recognise it. */}
1582
+ <div className={css.headerRight}>
1583
+ <SettingsMenu
1584
+ t={t} mode={mode} family={family} onMode={onMode} onFamily={onFamily}
1585
+ settings={style} onStyle={onStyle}
1586
+ />
1587
+ <button
1588
+ type="button"
1589
+ className={`${css.btn} ${css.btnIcon}`}
1590
+ aria-pressed={maximized}
1591
+ aria-label={maximized ? t('restore') : t('maximize')}
1592
+ title={maximized ? t('restore') : t('maximize')}
1593
+ onClick={onToggleMaximized}
1594
+ ><ChromeGlyph of={maximized ? 'restore' : 'maximize'} /></button>
1595
+ <button
1596
+ type="button"
1597
+ className={`${css.btn} ${css.btnIcon}`}
1598
+ aria-label={t('refresh')} title={t('refresh')}
1599
+ onClick={onRefresh}
1600
+ ><ChromeGlyph of="refresh" /></button>
1601
+ <button
1602
+ type="button"
1603
+ className={`${css.btn} ${css.btnIcon} ${css.btnClose}`}
1604
+ aria-label={t('close')} title={t('close')}
1605
+ onClick={onClose}
1606
+ ><ChromeGlyph of="close" /></button>
1607
+ </div>
1608
+ </div>
1609
+ <div className={css.tabs} role="tablist" aria-label={t('tabsLabel')} data-gs-part="tabs">
1610
+ <button
1611
+ type="button"
1612
+ role="tab"
1613
+ aria-selected={tab === 'changes'}
1614
+ className={tab === 'changes' ? `${css.tab} ${css.tabActive}` : css.tab}
1615
+ onClick={() => onSwitchTab('changes')}
1616
+ >{t('tabChanges')}</button>
1617
+ <button
1618
+ type="button"
1619
+ role="tab"
1620
+ aria-selected={tab === 'history'}
1621
+ className={tab === 'history' ? `${css.tab} ${css.tabActive}` : css.tab}
1622
+ onClick={() => onSwitchTab('history')}
1623
+ >{t('tabHistory')}</button>
1624
+ <button
1625
+ type="button"
1626
+ role="tab"
1627
+ aria-selected={tab === 'compare'}
1628
+ className={tab === 'compare' ? `${css.tab} ${css.tabActive}` : css.tab}
1629
+ onClick={() => onSwitchTab('compare')}
1630
+ >{t('tabCompare')}</button>
1631
+ </div>
1632
+ {tab === 'compare' ? (
1633
+ <CompareBar
1634
+ t={t}
1635
+ branches={branches}
1636
+ worktreeBranches={worktreeBranches}
1637
+ truncated={branchesTruncated}
1638
+ baseRef={baseRef}
1639
+ headRef={headRef}
1640
+ onBaseRef={onBaseRef}
1641
+ onHeadRef={onHeadRef}
1642
+ />
1643
+ ) : null}
1644
+ {tab === 'history' && branches.length > 0 ? (
1645
+ <div className={css.compareBar}>
1646
+ <RefPicker
1647
+ t={t} label={t('historyRefLabel')} value={historyRef}
1648
+ branches={branches} worktreeBranches={worktreeBranches} truncated={branchesTruncated}
1649
+ onPick={onHistoryRef}
1650
+ />
1651
+ </div>
1652
+ ) : null}
1653
+ {/* Write operations act on the working tree, so they belong to the tab
1654
+ that shows it. A commit box under a historical diff would be asking
1655
+ which tree it commits. */}
1656
+ {tab === 'changes' && sync !== null && sync.hasRemote ? (
1657
+ <SyncBar t={t} sync={sync} busy={busy} onOp={(op, payload) => { void runOp(op, payload) }} />
1658
+ ) : null}
1659
+ {opResult !== null ? (
1660
+ <div
1661
+ className={opResult.result.ok ? `${css.opBanner} ${css.opBannerOk}` : `${css.opBanner} ${css.opBannerBad}`}
1662
+ role="status"
1663
+ >{opMessage(t, opResult.op, opResult.result)}</div>
1664
+ ) : null}
1665
+ <div className={css.body}>
1666
+ {tab === 'history' ? (
1667
+ <>
1668
+ <CommitList
1669
+ paneRef={commitsRef}
1670
+ style={paneStyle(panes.commits)}
1671
+ t={t}
1672
+ loading={historyLoading}
1673
+ commits={commits}
1674
+ active={commitHash}
1675
+ onSelect={onSelectCommit}
1676
+ hasMore={hasMoreCommits}
1677
+ loadingMore={loadingMore}
1678
+ onLoadMore={onLoadMoreCommits}
1679
+ />
1680
+ <PaneDivider label={t('resizeCommits')} onDrag={paneDrag('commits', commitsRef)} />
1681
+ </>
1682
+ ) : null}
1683
+ <div ref={treeRef} className={css.treeCol} style={paneStyle(panes.tree)} data-gs-part="tree">
1684
+ <FileTree
1685
+ t={t}
1686
+ loading={pending}
1687
+ lead={tab === 'changes' ? t('workingTree') : undefined}
1688
+ files={tickedFiles}
1689
+ active={active}
1690
+ onSelect={selectAndReveal}
1691
+ collapsed={collapsed}
1692
+ onCollapsedChange={onCollapsedChange}
1693
+ // Ticks exist only for the working tree. A commit's contents were
1694
+ // decided long ago and a range's never were, so the column is
1695
+ // absent there rather than present and inert.
1696
+ //
1697
+ // A tick IS still the git call — `add` on the way in,
1698
+ // `restore --staged` on the way out, applied now rather than
1699
+ // saved up for Commit — but the call is queued, not raced: the
1700
+ // click paints itself through the overlay, the drain loop batches
1701
+ // the git calls, and a click that lands while another runs waits
1702
+ // its turn instead of being dropped. `checked` carries the
1703
+ // overlaid flags, so a second click reads the state the user is
1704
+ // looking at, not the pre-click payload.
1705
+ onCheck={tab === 'changes' ? (checked, state) => {
1706
+ const action = nextAction(state)
1707
+ const paths = pathsFor(checked, action)
1708
+ if (paths.length > 0) onTick(action, paths)
1709
+ } : undefined}
1710
+ footer={tab === 'changes'
1711
+ ? (
1712
+ <CommitBox
1713
+ t={t} files={tickedFiles} busy={busy} onOp={runOp}
1714
+ message={commitDraft} onMessage={onCommitDraft}
1715
+ amend={commitAmend} onAmend={onCommitAmend}
1716
+ />
1717
+ )
1718
+ : undefined}
1719
+ />
1720
+ </div>
1721
+ <PaneDivider label={t('resizeTree')} onDrag={paneDrag('tree', treeRef)} />
1722
+ <div className={css.diffPane} data-gs-part="diff">
1723
+ {tab === 'compare' && !comparable ? (
1724
+ <div className={css.empty}>{t('comparePick')}</div>
1725
+ ) : shown === null && tab !== 'changes' ? (
1726
+ <div className={css.empty}>{tab === 'compare' ? t('loadingCompare') : t('loadingCommit')}</div>
1727
+ ) : activeFile !== null && activeFile.previousPath !== undefined ? (
1728
+ <div className={css.renameLine}>{t('renamedFrom')} <code>{activeFile.previousPath}</code></div>
1729
+ ) : null}
1730
+ {(shown === null && tab !== 'changes') || (tab === 'compare' && !comparable) ? null
1731
+ : activeFile !== null && activeFile.binary ? (
1732
+ <div className={css.empty}>{t('binaryFile')}</div>
1733
+ ) : loading && segment.length === 0 ? (
1734
+ <div className={css.empty}>{t('loadingDiff')}</div>
1735
+ ) : segment.length > 0 ? (
1736
+ <DiffView segment={segment} path={active ?? ''} palette={theme} />
1737
+ ) : (
1738
+ <div className={css.empty}>{t('noTextDiff')}</div>
1739
+ )}
1740
+ </div>
1741
+ </div>
1742
+ </div>
1743
+ </div>
1744
+ )
1745
+ }
1746
+
1747
+ /**
1748
+ * A slash-separated name that gives up its HEAD, never its tail.
1749
+ *
1750
+ * Paths and branches have the same shape and the same problem: the leaf is what
1751
+ * distinguishes siblings, and ordinary `text-overflow: ellipsis` eats exactly
1752
+ * that. `…/worktrees/fixture-07` and `…/worktrees/fixture-14` become the same
1753
+ * string; so do `feature/nested/deep/parser` and `feature/nested/deep/lexer`.
1754
+ * Splitting in two and letting only the head shrink keeps the half that
1755
+ * answers "which one".
1756
+ *
1757
+ * Truncating from the other end with `direction: rtl` was the one-line version
1758
+ * and the wrong one: it reorders the backslashes in a Windows path.
1759
+ *
1760
+ * When the name has no head to give — a bare `some-very-long-branch-name` — the
1761
+ * tail ellipsises after all rather than overflowing its row; the stylesheet
1762
+ * weights the shrink so that only happens once the head is gone.
1763
+ */
1764
+ function Elided({ text, className, title }: {
1765
+ text: string
1766
+ className: string
1767
+ /** Set only where the row does not already carry the full text itself. */
1768
+ title?: string
1769
+ }): ReactNode {
1770
+ if (text.length === 0) return null
1771
+ const { head, tail } = splitPath(text)
1772
+ return (
1773
+ <span className={`${css.elide} ${className}`} {...title === undefined ? {} : { title }}>
1774
+ {head.length > 0 ? <span className={css.elideHead}>{head}</span> : null}
1775
+ <span className={css.elideTail}>{tail}</span>
1776
+ </span>
1777
+ )
1778
+ }
1779
+
1780
+ /**
1781
+ * Which worktree the drawer is reading — the header's first control.
1782
+ *
1783
+ * Git allows at most one worktree per branch, so the repository's worktree list
1784
+ * IS the branch list and one control covers both. This used to be a row of its
1785
+ * own under the tabs, restating the branch the header had already named one
1786
+ * line above; now it IS that name, and clicking it changes the view.
1787
+ *
1788
+ * A repository with a single worktree has nothing to choose, so it renders as
1789
+ * the same chip without the menu — the identity still has to be stated, and a
1790
+ * control that opens an empty list is worse than none.
1791
+ */
1792
+ function SourceChip({ t, worktrees, boundPath, sessionPath, statsPath, fallbackBranch, onSwitch }: {
1793
+ t: Translate
1794
+ worktrees: readonly WorktreeEntry[]
1795
+ boundPath: string | null
1796
+ sessionPath: string | undefined
1797
+ statsPath: string | undefined
1798
+ /** What to name when the worktree list does not cover the active path — the
1799
+ * branch the stats themselves report. */
1800
+ fallbackBranch: string
1801
+ onSwitch: (next: string) => void
1802
+ }): ReactNode {
1803
+ if (worktrees.length < 2) {
1804
+ return (
1805
+ <span className={css.headerBranch}>
1806
+ <WorktreeGlyph />
1807
+ <Elided text={branchLabel(fallbackBranch, t('noBranch'))} className={css.refValue} />
1808
+ </span>
1809
+ )
1810
+ }
1811
+ return (
1812
+ <WorktreePicker
1813
+ t={t} worktrees={worktrees} boundPath={boundPath}
1814
+ sessionPath={sessionPath} statsPath={statsPath}
1815
+ fallbackBranch={fallbackBranch} onSwitch={onSwitch}
1816
+ />
1817
+ )
1818
+ }
1819
+
1820
+ /**
1821
+ * The worktree menu: the ref picker's scaffold — button, filter box, scrolling
1822
+ * list — over worktree rows, wearing the header chip's accent so it reads as
1823
+ * the subject of the drawer rather than one more grey button.
1824
+ *
1825
+ * Rows carry the tree glyph when the session is bound there and a dot when it
1826
+ * is the session's own; Enter takes the first match.
1827
+ *
1828
+ * The row gives its whole width to the branch. A dim path used to ride on the
1829
+ * right to tell same-named branches in different repositories apart, but it
1830
+ * cost half the row to earn that, and the half it took was the half that
1831
+ * mattered: `wt/fixture-03` truncated to `wt/fixtur…` beside a path whose tail
1832
+ * was repeating the name anyway. The full path stays one hover away on `title`,
1833
+ * and the header spells it out the moment a row is picked.
1834
+ */
1835
+ function WorktreePicker({ t, worktrees, boundPath, sessionPath, statsPath, fallbackBranch, onSwitch }: {
1836
+ t: Translate
1837
+ worktrees: readonly WorktreeEntry[]
1838
+ boundPath: string | null
1839
+ sessionPath: string | undefined
1840
+ statsPath: string | undefined
1841
+ fallbackBranch: string
1842
+ onSwitch: (next: string) => void
1843
+ }): ReactNode {
1844
+ const [open, setOpen] = useState(false)
1845
+ const [query, setQuery] = useState('')
1846
+ const rootRef = useDismissable(open, setOpen)
1847
+
1848
+ const needle = query.trim().toLowerCase()
1849
+ const matched = needle.length === 0 ? worktrees : worktrees.filter(entry =>
1850
+ entry.branch.toLowerCase().includes(needle) || entry.path.toLowerCase().includes(needle))
1851
+ const first = matched[0]
1852
+ const current = worktrees.find(entry => samePath(entry.path, statsPath))
1853
+
1854
+ const choose = (entry: WorktreeEntry): void => {
1855
+ onSwitch(entry.path)
1856
+ setOpen(false)
1857
+ setQuery('')
1858
+ }
1859
+
1860
+ return (
1861
+ <div className={css.refPicker} ref={rootRef}>
1862
+ <button
1863
+ type="button"
1864
+ className={`${css.refButton} ${css.headerPicker}`}
1865
+ aria-expanded={open}
1866
+ aria-label={t('sourceLabel')}
1867
+ title={current?.path ?? statsPath}
1868
+ onClick={() => setOpen(isOpen => !isOpen)}
1869
+ >
1870
+ <WorktreeGlyph />
1871
+ <Elided text={branchLabel(current?.branch ?? fallbackBranch, t('noBranch'))} className={css.refValue} />
1872
+ <span className={css.refCaret}>▾</span>
1873
+ </button>
1874
+ {open ? (
1875
+ <div className={css.refPop}>
1876
+ <input
1877
+ className={css.refSearch}
1878
+ autoFocus
1879
+ value={query}
1880
+ placeholder={t('refSearch')}
1881
+ onChange={event => setQuery(event.target.value)}
1882
+ onKeyDown={event => { if (event.key === 'Enter' && first !== undefined) choose(first) }}
1883
+ />
1884
+ <div className={css.refList} role="listbox" aria-label={t('sourceLabel')}>
1885
+ {matched.map(entry => {
1886
+ const active = samePath(entry.path, statsPath)
1887
+ return (
1888
+ <button
1889
+ key={entry.path}
1890
+ type="button"
1891
+ role="option"
1892
+ aria-selected={active}
1893
+ className={active ? `${css.refRow} ${css.refRowActive}` : css.refRow}
1894
+ title={entry.path}
1895
+ onClick={() => choose(entry)}
1896
+ >
1897
+ {samePath(boundPath, entry.path) ? <WorktreeGlyph /> : <span className={css.refRowSpacer} />}
1898
+ <Elided text={branchLabel(entry.branch, t('noBranch'))} className={css.refRowName} />
1899
+ {samePath(sessionPath, entry.path) ? <span className={css.wtCurrent}>●</span> : null}
1900
+ </button>
1901
+ )
1902
+ })}
1903
+ {matched.length === 0 ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
1904
+ </div>
1905
+ <div className={css.refFoot}>{t('refCount', { shown: matched.length, total: worktrees.length })}</div>
1906
+ </div>
1907
+ ) : null}
1908
+ </div>
1909
+ )
1910
+ }
1911
+
1912
+ /**
1913
+ * Settings: colour mode, palette, background image and custom CSS.
1914
+ *
1915
+ * Mode defaults to `system`, which follows dsh (`body[data-ds-dark-theme]`);
1916
+ * light and dark pin the drawer even when the host is the other scheme. The
1917
+ * palette is a pure token swap — every drawer colour resolves through the same
1918
+ * names, so nothing but the values differ between families.
1919
+ *
1920
+ * The background and the stylesheet are per-scope, and the scope switch is the
1921
+ * only control in here that changes what an edit WRITES rather than what it
1922
+ * looks like, so it sits at the top of that section rather than beside a field.
1923
+ *
1924
+ * This was a companion card portalled into the overlay to the LEFT of the
1925
+ * drawer, so a palette could be previewed against the diff without covering it.
1926
+ * It is a popover now, hung under its own gear: a card floating out in the page
1927
+ * beside the drawer read as a second window rather than as this drawer's
1928
+ * settings, and it was the one menu here that did not behave like the rest.
1929
+ * The preview still works — the popover covers the top of the diff, not all of
1930
+ * it, and the drawer repaints live underneath.
1931
+ */
1932
+ function SettingsMenu({ t, mode, family, onMode, onFamily, settings, onStyle }: {
1933
+ t: Translate
1934
+ mode: ColorMode
1935
+ family: ThemeFamily
1936
+ onMode: (next: ColorMode) => void
1937
+ onFamily: (next: ThemeFamily) => void
1938
+ settings: StyleSettings
1939
+ onStyle: (scope: StyleScope, entry: StyleEntry, persist: boolean) => Promise<{ ok: boolean; error?: string }>
1940
+ }): ReactNode {
1941
+ const [open, setOpen] = useState(false)
1942
+ const [scope, setScope] = useState<StyleScope>('project')
1943
+ /** Editor buffer for the stylesheet, so typing does not restyle on every key. */
1944
+ const [draft, setDraft] = useState<string | null>(null)
1945
+ const [note, setNote] = useState('')
1946
+ const rootRef = useDismissable(open, setOpen)
1947
+ const imageFileRef = useRef<HTMLInputElement>(null)
1948
+ const cssFileRef = useRef<HTMLInputElement>(null)
1949
+
1950
+ // The buffer belongs to one scope; switching scope must show that scope's
1951
+ // stylesheet rather than carry the other one's text across.
1952
+ useEffect(() => { setDraft(null); setNote('') }, [scope])
1953
+
1954
+ // The default scope is `project`, chosen before the host has said whether
1955
+ // there IS one. Outside a repository it has nothing to key by, so the menu
1956
+ // falls back rather than pointing every control at a scope that refuses
1957
+ // every write.
1958
+ useEffect(() => {
1959
+ if (settings.repoRoot === null) setScope('global')
1960
+ }, [settings.repoRoot])
1961
+
1962
+ const entry = entryFor(settings, scope)
1963
+ const cssText = draft ?? entry.css
1964
+
1965
+ /**
1966
+ * Apply a change to the scope being edited.
1967
+ * @param patch - the fields that changed.
1968
+ * @param persist - whether to store it; false previews without a file write.
1969
+ */
1970
+ const write = (patch: Partial<StyleEntry>, persist = true): void => {
1971
+ setNote('')
1972
+ void onStyle(scope, { ...entry, ...patch }, persist).then(result => {
1973
+ if (!result.ok) setNote(result.error ?? t('styleFailed'))
1974
+ })
1975
+ }
1976
+
1977
+ /**
1978
+ * Resample a chosen image and store it.
1979
+ *
1980
+ * Downscaling in the browser is what keeps this practical: a phone photograph
1981
+ * is 4-6MB, far past what is worth carrying on every drawer open, and none of
1982
+ * that detail survives a blur anyway.
1983
+ * @param file - the picked file.
1984
+ */
1985
+ const takeImage = async (file: File): Promise<void> => {
1986
+ setNote(t('bgWorking'))
1987
+ try {
1988
+ const bitmap = await createImageBitmap(file)
1989
+ const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(bitmap.width, bitmap.height))
1990
+ const canvas = document.createElement('canvas')
1991
+ canvas.width = Math.round(bitmap.width * scale)
1992
+ canvas.height = Math.round(bitmap.height * scale)
1993
+ const context = canvas.getContext('2d')
1994
+ if (context === null) { setNote(t('bgFailed')); return }
1995
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
1996
+ bitmap.close()
1997
+ const url = canvas.toDataURL('image/jpeg', IMAGE_QUALITY)
1998
+ if (url.length > IMAGE_MAX_BYTES) { setNote(t('bgTooBig')); return }
1999
+ setNote('')
2000
+ write({ image: url })
2001
+ } catch {
2002
+ // A file the decoder refuses (corrupt, or an image codec this browser
2003
+ // lacks) is a user mistake, not a fault worth propagating.
2004
+ setNote(t('bgFailed'))
2005
+ }
2006
+ }
2007
+
2008
+ const modeLabel: Record<ColorMode, string> = {
2009
+ system: t('modeSystem'), light: t('modeLight'), dark: t('modeDark'),
2010
+ }
2011
+ const modeChip: Record<ColorMode, string> = {
2012
+ system: css.chipSystem, light: css.chipLight, dark: css.chipDark,
2013
+ }
2014
+ const scopeLabel: Record<StyleScope, string> = {
2015
+ project: t('scopeProject'), global: t('scopeGlobal'),
2016
+ }
2017
+ const projectAvailable = settings.repoRoot !== null
2018
+
2019
+ return (
2020
+ <div className={css.theme} ref={rootRef}>
2021
+ <button
2022
+ type="button"
2023
+ className={`${css.btn} ${css.btnIcon}`}
2024
+ aria-expanded={open}
2025
+ aria-label={t('settings')} title={t('settings')}
2026
+ onClick={() => setOpen(value => !value)}
2027
+ ><ChromeGlyph of="settings" /></button>
2028
+ {open ? (
2029
+ <div className={`${css.refPop} ${css.settingsPop}`} data-gs-part="settings">
2030
+ {/* The popover positions and clips; this is the padded body that
2031
+ scrolls inside it. Collapsing the two put every section flush
2032
+ against the card's edge. */}
2033
+ <div className={css.themeRail} data-gs-part="theme-rail">
2034
+ <div className={css.themeGroup}>
2035
+ <span className={css.themeLabel}>{t('themeMode')}</span>
2036
+ <div className={css.segmented} role="group" aria-label={t('themeMode')}>
2037
+ {COLOR_MODES.map(option => (
2038
+ <button
2039
+ key={option}
2040
+ type="button"
2041
+ aria-pressed={mode === option}
2042
+ className={mode === option ? `${css.segment} ${css.segmentActive}` : css.segment}
2043
+ onClick={() => onMode(option)}
2044
+ >
2045
+ <span className={`${css.segmentChip} ${modeChip[option]}`} aria-hidden="true" />
2046
+ {modeLabel[option]}
2047
+ </button>
2048
+ ))}
2049
+ </div>
2050
+ </div>
2051
+
2052
+ <div className={css.themeGroup}>
2053
+ <span className={css.themeLabel}>{t('themePalette')}</span>
2054
+ {THEME_FAMILIES.map(option => (
2055
+ <button
2056
+ key={option.id}
2057
+ type="button"
2058
+ aria-pressed={family === option.id}
2059
+ className={family === option.id ? `${css.paletteRow} ${css.paletteRowActive}` : css.paletteRow}
2060
+ onClick={() => onFamily(option.id)}
2061
+ >
2062
+ <span className={css.swatch} aria-hidden="true">
2063
+ {option.swatch.map(color => <span key={color} style={{ background: color }} />)}
2064
+ </span>
2065
+ {option.label}
2066
+ </button>
2067
+ ))}
2068
+ </div>
2069
+
2070
+ <div className={css.themeGroup}>
2071
+ <span className={css.themeLabel}>{t('themeScope')}</span>
2072
+ <div className={css.scopeRow} role="group" aria-label={t('themeScope')}>
2073
+ {STYLE_SCOPES.map(option => (
2074
+ <button
2075
+ key={option}
2076
+ type="button"
2077
+ aria-pressed={scope === option}
2078
+ disabled={option === 'project' && !projectAvailable}
2079
+ className={scope === option ? `${css.scopeBtn} ${css.scopeBtnActive}` : css.scopeBtn}
2080
+ onClick={() => setScope(option)}
2081
+ >{scopeLabel[option]}</button>
2082
+ ))}
2083
+ </div>
2084
+ <span className={css.scopeHint}>
2085
+ {scope === 'global' ? t('scopeGlobalHint')
2086
+ : projectAvailable ? settings.repoRoot
2087
+ : t('scopeNoRepo')}
2088
+ </span>
2089
+ </div>
2090
+
2091
+ <div className={css.themeGroup}>
2092
+ <span className={css.themeLabel}>{t('themeBackground')}</span>
2093
+ <div
2094
+ className={entry.image.length > 0 ? css.bgPreview : `${css.bgPreview} ${css.bgEmpty}`}
2095
+ style={entry.image.length > 0 ? { backgroundImage: `url("${entry.image}")` } : undefined}
2096
+ >{entry.image.length > 0 ? null : t('bgNone')}</div>
2097
+ <div className={css.themeRowSplit}>
2098
+ <button type="button" className={css.miniBtn} onClick={() => imageFileRef.current?.click()}>{t('bgChoose')}</button>
2099
+ {entry.image.length > 0
2100
+ ? <button type="button" className={css.miniBtn} onClick={() => write({ image: '' })}>{t('bgClear')}</button>
2101
+ : null}
2102
+ </div>
2103
+ <input
2104
+ ref={imageFileRef}
2105
+ type="file"
2106
+ accept="image/*"
2107
+ hidden
2108
+ onChange={event => {
2109
+ const file = event.target.files?.[0]
2110
+ // Clearing the input is what lets the same file be picked twice
2111
+ // after a failure; a change event fires only on a NEW value.
2112
+ event.target.value = ''
2113
+ if (file !== undefined) void takeImage(file)
2114
+ }}
2115
+ />
2116
+ {entry.image.length > 0 ? (
2117
+ <>
2118
+ <label className={css.sliderRow}>
2119
+ {t('bgBlur')}
2120
+ <input
2121
+ type="range" min={0} max={STYLE_BLUR_MAX} step={1} value={entry.blur}
2122
+ onChange={event => write({ blur: Number(event.target.value) }, false)}
2123
+ onPointerUp={() => write({})}
2124
+ onKeyUp={() => write({})}
2125
+ />
2126
+ <span className={css.sliderValue}>{entry.blur}px</span>
2127
+ </label>
2128
+ <label className={css.sliderRow}>
2129
+ {t('bgVeil')}
2130
+ <input
2131
+ type="range" min={0} max={100} step={1} value={entry.veil}
2132
+ onChange={event => write({ veil: Number(event.target.value) }, false)}
2133
+ onPointerUp={() => write({})}
2134
+ onKeyUp={() => write({})}
2135
+ />
2136
+ <span className={css.sliderValue}>{entry.veil}%</span>
2137
+ </label>
2138
+ </>
2139
+ ) : null}
2140
+ </div>
2141
+
2142
+ <div className={css.themeGroup}>
2143
+ <span className={css.themeLabel}>{t('themeCss')}</span>
2144
+ <textarea
2145
+ className={css.cssArea}
2146
+ spellCheck={false}
2147
+ placeholder={t('cssPlaceholder')}
2148
+ value={cssText}
2149
+ onChange={event => setDraft(event.target.value)}
2150
+ />
2151
+ <div className={css.themeRowSplit}>
2152
+ <button type="button" className={css.miniBtn} onClick={() => cssFileRef.current?.click()}>{t('cssImport')}</button>
2153
+ <button
2154
+ type="button"
2155
+ className={`${css.miniBtn} ${css.miniBtnPrimary}`}
2156
+ disabled={draft === null}
2157
+ onClick={() => { write({ css: cssText }); setDraft(null) }}
2158
+ >{t('cssApply')}</button>
2159
+ </div>
2160
+ <input
2161
+ ref={cssFileRef}
2162
+ type="file"
2163
+ accept=".css,text/css"
2164
+ hidden
2165
+ onChange={event => {
2166
+ const file = event.target.files?.[0]
2167
+ event.target.value = ''
2168
+ if (file !== undefined) void file.text().then(text => setDraft(text))
2169
+ }}
2170
+ />
2171
+ {draft === null ? null : <span className={css.themeDirty}>{t('cssUnapplied')}</span>}
2172
+ </div>
2173
+
2174
+ {note.length > 0 ? <span className={css.themeNote}>{note}</span> : null}
2175
+ </div>
2176
+ </div>
2177
+ ) : null}
2178
+ </div>
2179
+ )
2180
+ }
2181
+
2182
+ /**
2183
+ * Close a popover on the two gestures every user already expects: a click
2184
+ * outside it, and Escape. Both arrive on `document` rather than on the
2185
+ * popover's own subtree, so neither can be a handler on the element.
2186
+ * @param open - whether the popover is showing; nothing is bound while closed.
2187
+ * @param setOpen - the state setter, stable, so the effect binds once per open.
2188
+ * @returns the ref to put on the element that counts as "inside".
2189
+ */
2190
+ function useDismissable(open: boolean, setOpen: Dispatch<SetStateAction<boolean>>): Ref<HTMLDivElement> {
2191
+ const rootRef = useRef<HTMLDivElement>(null)
2192
+ useEffect(() => {
2193
+ if (!open) return
2194
+ const onDown = (event: MouseEvent): void => {
2195
+ if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) setOpen(false)
2196
+ }
2197
+ const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') setOpen(false) }
2198
+ // Bound on the next tick: the click that opened the popover is still
2199
+ // travelling, and would otherwise close it again immediately.
2200
+ const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0)
2201
+ document.addEventListener('keydown', onKey)
2202
+ return () => {
2203
+ window.clearTimeout(id)
2204
+ document.removeEventListener('mousedown', onDown)
2205
+ document.removeEventListener('keydown', onKey)
2206
+ }
2207
+ }, [open, setOpen])
2208
+ return rootRef
2209
+ }
2210
+
2211
+ /**
2212
+ * Ref picker built for a repository with hundreds of branches.
2213
+ *
2214
+ * A chip row cannot do this job — it grows without bound and gives every branch
2215
+ * the same weight — and a native select is no better once the list is long
2216
+ * enough to scroll past what anyone will read. This is the control git tooling
2217
+ * converges on instead: one button showing the current ref, opening a filter box
2218
+ * over a scrolling list.
2219
+ *
2220
+ * Two things make it useful before a character is typed. Branches arrive
2221
+ * most-recently-committed first, so the handful actually being worked on are at
2222
+ * the top; and those that have a worktree are grouped above the rest, because a
2223
+ * checked-out branch is the likeliest thing to want. Enter takes the first
2224
+ * match, so a distinctive substring plus Enter reaches any branch in the list.
2225
+ */
2226
+ function RefPicker({ t, label, value, branches, worktreeBranches, truncated, onPick }: {
2227
+ t: Translate
2228
+ label: string
2229
+ value: string
2230
+ branches: readonly string[]
2231
+ /** Branches that have a worktree — grouped first and marked. */
2232
+ worktreeBranches: readonly string[]
2233
+ /** Whether the host cut the branch list short. */
2234
+ truncated: boolean
2235
+ onPick: (ref: string) => void
2236
+ }): ReactNode {
2237
+ const [open, setOpen] = useState(false)
2238
+ const [query, setQuery] = useState('')
2239
+ const rootRef = useDismissable(open, setOpen)
2240
+
2241
+ const needle = query.trim().toLowerCase()
2242
+ const matched = needle.length === 0 ? branches : branches.filter(ref => ref.toLowerCase().includes(needle))
2243
+ const checkedOut = matched.filter(ref => worktreeBranches.includes(ref))
2244
+ const rest = matched.filter(ref => !worktreeBranches.includes(ref))
2245
+ const first = checkedOut[0] ?? rest[0]
2246
+
2247
+ const choose = (ref: string): void => {
2248
+ onPick(ref)
2249
+ setOpen(false)
2250
+ setQuery('')
2251
+ }
2252
+
2253
+ const row = (ref: string, inWorktree: boolean): ReactNode => (
2254
+ <button
2255
+ key={ref}
2256
+ type="button"
2257
+ role="option"
2258
+ aria-selected={ref === value}
2259
+ className={ref === value ? `${css.refRow} ${css.refRowActive}` : css.refRow}
2260
+ title={ref}
2261
+ onClick={() => choose(ref)}
2262
+ >
2263
+ {inWorktree ? <WorktreeGlyph /> : <span className={css.refRowSpacer} />}
2264
+ <Elided text={ref} className={css.refRowName} />
2265
+ </button>
2266
+ )
2267
+
2268
+ return (
2269
+ <div className={css.refPicker} ref={rootRef}>
2270
+ <span className={css.refLabel}>{label}</span>
2271
+ <button
2272
+ type="button"
2273
+ className={css.refButton}
2274
+ aria-expanded={open}
2275
+ title={value.length > 0 ? value : undefined}
2276
+ onClick={() => setOpen(isOpen => !isOpen)}
2277
+ >
2278
+ <Elided text={value.length > 0 ? value : '—'} className={css.refValue} />
2279
+ <span className={css.refCaret}>▾</span>
2280
+ </button>
2281
+ {open ? (
2282
+ <div className={css.refPop}>
2283
+ <input
2284
+ className={css.refSearch}
2285
+ autoFocus
2286
+ value={query}
2287
+ placeholder={t('refSearch')}
2288
+ onChange={event => setQuery(event.target.value)}
2289
+ onKeyDown={event => { if (event.key === 'Enter' && first !== undefined) choose(first) }}
2290
+ />
2291
+ <div className={css.refList} role="listbox" aria-label={label}>
2292
+ {checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refWorktrees')}</div> : null}
2293
+ {checkedOut.map(ref => row(ref, true))}
2294
+ {checkedOut.length > 0 && rest.length > 0 ? <div className={css.refGroup}>{t('refBranches')}</div> : null}
2295
+ {rest.map(ref => row(ref, false))}
2296
+ {matched.length === 0 ? <div className={css.refEmpty}>{t('refNone')}</div> : null}
2297
+ </div>
2298
+ <div className={css.refFoot}>
2299
+ {t('refCount', { shown: matched.length, total: branches.length })}
2300
+ {truncated ? ` · ${t('refTruncated')}` : ''}
2301
+ </div>
2302
+ </div>
2303
+ ) : null}
2304
+ </div>
2305
+ )
2306
+ }
2307
+
2308
+ /**
2309
+ * Compare tab controls: the two refs, in reading order.
2310
+ *
2311
+ * Both sides list the same refs — comparing a branch against itself is possible
2312
+ * to express and simply reports nothing, which is clearer than hiding it.
2313
+ */
2314
+ function CompareBar({ t, branches, worktreeBranches, truncated, baseRef, headRef, onBaseRef, onHeadRef }: {
2315
+ t: Translate
2316
+ branches: readonly string[]
2317
+ worktreeBranches: readonly string[]
2318
+ truncated: boolean
2319
+ baseRef: string
2320
+ headRef: string
2321
+ onBaseRef: (ref: string) => void
2322
+ onHeadRef: (ref: string) => void
2323
+ }): ReactNode {
2324
+ if (branches.length === 0) return <div className={css.compareBar}>{t('noBranches')}</div>
2325
+ return (
2326
+ <div className={css.compareBar}>
2327
+ <RefPicker
2328
+ t={t} label={t('compareBase')} value={baseRef}
2329
+ branches={branches} worktreeBranches={worktreeBranches} truncated={truncated}
2330
+ onPick={onBaseRef}
2331
+ />
2332
+ <span className={css.compareArrow}>→</span>
2333
+ <RefPicker
2334
+ t={t} label={t('compareHead')} value={headRef}
2335
+ branches={branches} worktreeBranches={worktreeBranches} truncated={truncated}
2336
+ onPick={onHeadRef}
2337
+ />
2338
+ </div>
2339
+ )
2340
+ }
2341
+
2342
+ /* ---------- write operations ---------- */
2343
+
2344
+ /**
2345
+ * Failures whose sentence is the whole story. Everything else shows git's own
2346
+ * text underneath, because the classification is a hint about what to do next
2347
+ * and the raw message is the evidence for it — when the hint is `unknown` it is
2348
+ * the only thing left that helps at all.
2349
+ *
2350
+ * These two are excluded because their detail is never informative and is often
2351
+ * actively misleading: git says nothing useful about an empty index, so what
2352
+ * lands in stderr is whatever a hook wrapper happened to print. A user reading
2353
+ * "nothing staged" followed by a lefthook config warning learns only that
2354
+ * something else is broken, which is not true.
2355
+ */
2356
+ const SELF_EXPLANATORY: ReadonlySet<GitOpFailure> = new Set(['nothing-to-commit', 'no-upstream'])
2357
+
2358
+ /** What to tell the user about a finished operation. */
2359
+ function opMessage(t: Translate, op: GitOpName, result: GitOpResult): string {
2360
+ if (result.ok) return t(`op.ok.${op}`)
2361
+ const failure = result.failure ?? 'unknown'
2362
+ const reason = t(`op.fail.${failure}`)
2363
+ if (SELF_EXPLANATORY.has(failure)) return reason
2364
+ const detail = (result.error ?? '').trim()
2365
+ return detail.length > 0 ? `${reason}\n${detail}` : reason
2366
+ }
2367
+
2368
+ /**
2369
+ * Glyphs for the three network actions, on Primer's 16px grid.
2370
+ *
2371
+ * They sit BESIDE the labels rather than replacing them. The complaint that
2372
+ * started this was that Fetch/Pull/Push do not say what they do — icon-only
2373
+ * would answer it by removing the half that is unambiguous. What an icon adds
2374
+ * is recognition at a glance: down is work arriving, up is work leaving, and
2375
+ * the ring is the one that only reads a remote without changing anything here.
2376
+ */
2377
+ const SYNC_GLYPH = {
2378
+ // Circular arrows: VS Code's and IDEA's shared sign for "refresh what I know
2379
+ // about the remote". Nothing in the working tree moves.
2380
+ fetch: 'M8 2.5a5.5 5.5 0 0 0-4.9 3 .75.75 0 0 1-1.34-.68A7 7 0 0 1 13.5 5.2V3.75a.75.75 0 0 1 1.5 0v3.5a.75.75 0 0 1-.75.75h-3.5a.75.75 0 0 1 0-1.5h1.86A5.5 5.5 0 0 0 8 2.5Zm-6.25 6a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75Zm.75 1.5h3.5a.75.75 0 0 1 0 1.5H4.14A5.5 5.5 0 0 0 12.9 10.5a.75.75 0 0 1 1.34.68A7 7 0 0 1 2.5 10.8v-.05a.75.75 0 0 1 0-.75Z',
2381
+ // Down into a floor line: commits arriving from the remote onto this branch.
2382
+ pull: 'M8 1.75a.75.75 0 0 1 .75.75v6.44l2.22-2.22a.75.75 0 1 1 1.06 1.06l-3.5 3.5a.75.75 0 0 1-1.06 0l-3.5-3.5a.75.75 0 0 1 1.06-1.06l2.22 2.22V2.5A.75.75 0 0 1 8 1.75ZM2.75 12.5h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z',
2383
+ // Up off a floor line: the same arrow mirrored, because the pair only reads
2384
+ // as a direction if it is the same arrow.
2385
+ push: 'M7.47 1.97a.75.75 0 0 1 1.06 0l3.5 3.5a.75.75 0 0 1-1.06 1.06L8.75 4.31v6.44a.75.75 0 0 1-1.5 0V4.31L5.03 6.53a.75.75 0 0 1-1.06-1.06l3.5-3.5ZM2.75 12.5h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z',
2386
+ } as const
2387
+
2388
+ function SyncGlyph({ of }: { of: keyof typeof SYNC_GLYPH }): ReactNode {
2389
+ return (
2390
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
2391
+ <path d={SYNC_GLYPH[of]} />
2392
+ </svg>
2393
+ )
2394
+ }
2395
+
2396
+ const PULL_MODES = ['ff-only', 'rebase', 'merge'] as const
2397
+ type PullMode = typeof PULL_MODES[number]
2398
+
2399
+ /** Each strategy's label key, so the trigger and the menu cannot disagree. */
2400
+ const PULL_MODE_KEY: Record<PullMode, WorkbenchKey> = {
2401
+ 'ff-only': 'pullFf',
2402
+ rebase: 'pullRebase',
2403
+ merge: 'pullMerge',
2404
+ }
2405
+
2406
+ /**
2407
+ * Pull strategy, in the drawer's own menu idiom.
2408
+ *
2409
+ * This was a native `<select>`, justified as "a three-way choice used rarely".
2410
+ * The cost was not the frequency: a native popup paints in the OS palette, so
2411
+ * it was the one control in the drawer that ignored `data-gs-theme` — system
2412
+ * blue over Solarized, square corners in a row of pills. Reusing the ref
2413
+ * picker's button and popover makes it the same idiom as the drawer's other
2414
+ * menu rather than a second one.
2415
+ */
2416
+ function SyncModePicker({ t, value, disabled, quiet, onPick }: {
2417
+ t: Translate
2418
+ value: PullMode
2419
+ disabled: boolean
2420
+ /** Disabled only by an operation too young to report: refuse, but do not dim. */
2421
+ quiet: boolean
2422
+ onPick: (mode: PullMode) => void
2423
+ }): ReactNode {
2424
+ const [open, setOpen] = useState(false)
2425
+ const rootRef = useDismissable(open, setOpen)
2426
+
2427
+ return (
2428
+ <div className={css.refPicker} ref={rootRef}>
2429
+ <button
2430
+ type="button"
2431
+ className={css.refButton}
2432
+ aria-expanded={open}
2433
+ aria-label={t('pullModeLabel')}
2434
+ disabled={disabled}
2435
+ data-quiet={quiet ? '' : undefined}
2436
+ onClick={() => setOpen(isOpen => !isOpen)}
2437
+ >
2438
+ <span className={`${css.elide} ${css.refValue}`}><span className={css.elideTail}>{t(PULL_MODE_KEY[value])}</span></span>
2439
+ <span className={css.refCaret}>▾</span>
2440
+ </button>
2441
+ {open ? (
2442
+ <div className={`${css.refPop} ${css.menuPop}`} role="listbox" aria-label={t('pullModeLabel')}>
2443
+ {PULL_MODES.map(pullMode => (
2444
+ <button
2445
+ key={pullMode}
2446
+ type="button"
2447
+ role="option"
2448
+ aria-selected={pullMode === value}
2449
+ className={pullMode === value ? `${css.refRow} ${css.refRowActive}` : css.refRow}
2450
+ onClick={() => { onPick(pullMode); setOpen(false) }}
2451
+ >{t(PULL_MODE_KEY[pullMode])}</button>
2452
+ ))}
2453
+ </div>
2454
+ ) : null}
2455
+ </div>
2456
+ )
2457
+ }
2458
+
2459
+ /**
2460
+ * Whether an in-flight operation has run long enough to be worth showing.
2461
+ *
2462
+ * True only after `active` has held for `delay`, and then for at least `hold`
2463
+ * however quickly it ends — so a fast operation never paints, and a slow one
2464
+ * never blinks. See `op-feedback.ts` for why the appearance is paced and the
2465
+ * guard is not.
2466
+ */
2467
+ function useSustained(active: boolean, delay = BUSY_DELAY_MS, hold = BUSY_HOLD_MS): boolean {
2468
+ const [shown, setShown] = useState(false)
2469
+ const shownAt = useRef(0)
2470
+ useEffect(() => {
2471
+ if (active === shown) return undefined
2472
+ const wait = active ? delay : holdRemaining(shownAt.current, Date.now(), hold)
2473
+ const id = setTimeout(() => {
2474
+ if (active) shownAt.current = Date.now()
2475
+ setShown(active)
2476
+ }, wait)
2477
+ return () => { clearTimeout(id) }
2478
+ }, [active, shown, delay, hold])
2479
+ return shown
2480
+ }
2481
+
2482
+ /**
2483
+ * Fetch / pull / push, with the divergence they act on.
2484
+ *
2485
+ * Hidden entirely when the repository has no remote: three buttons that can only
2486
+ * fail are worse than no buttons. Pull carries its own strategy picker rather
2487
+ * than reading `pull.rebase`, so the button's label is what actually runs.
2488
+ *
2489
+ * The three used to be one grey pill each, distinguished by their word and a
2490
+ * 13px glyph — indistinguishable at a glance because they carried the same
2491
+ * amount of information, which was none. The counts have moved off the
2492
+ * divergence pills and INTO the two buttons that act on them, so the control
2493
+ * that can do something about the drift is also the one that reports it. Fetch
2494
+ * stays quiet in every state: it writes nothing, so it never has news.
2495
+ */
2496
+ function SyncBar({ t, sync, busy, onOp }: {
2497
+ t: Translate
2498
+ sync: SyncStatus
2499
+ busy: GitOpName | null
2500
+ onOp: (op: GitOpName, payload?: GitOpPayload) => void
2501
+ }): ReactNode {
2502
+ const [mode, setMode] = useState<PullMode>('ff-only')
2503
+ const running = busy !== null
2504
+ const noUpstream = sync.upstream === null
2505
+ // Every tick stages through git, so this bar was fading out and back on each
2506
+ // one. The buttons still refuse the click from the first frame; only saying
2507
+ // so waits until there is something worth saying.
2508
+ const sustained = useSustained(running)
2509
+ const quiet = quietlyDisabled(running, sustained, false)
2510
+
2511
+ /** Push is the branch's first — the one case where it is the whole point of
2512
+ * the bar, so it is the one case that gets the solid fill. */
2513
+ const pushClass = noUpstream ? `${css.btn} ${css.btnPrimary}`
2514
+ : sync.ahead > 0 ? `${css.btn} ${css.btnAhead}`
2515
+ : css.btn
2516
+
2517
+ return (
2518
+ <div className={css.syncBar} role="group" aria-label={t('syncLabel')}>
2519
+ <span className={css.syncUpstream} title={sync.upstream ?? undefined}>
2520
+ {noUpstream ? t('noUpstream') : sync.upstream}
2521
+ </span>
2522
+ {sync.behind === 0 && sync.ahead === 0 && !noUpstream
2523
+ ? <span className={css.syncLevel}>{t('upToDate')}</span>
2524
+ : null}
2525
+
2526
+ <span className={css.syncSpacer} />
2527
+
2528
+ <button
2529
+ type="button" className={css.btn} disabled={running} data-quiet={quiet ? '' : undefined}
2530
+ onClick={() => onOp('fetch')}
2531
+ ><SyncGlyph of="fetch" />{busy === 'fetch' ? t('opRunning') : t('fetch')}</button>
2532
+
2533
+ {/* The strategy is Pull's own argument, so it is welded to Pull. Loose
2534
+ between Fetch and Pull it read as a third peer action. */}
2535
+ <span className={css.pullGroup}>
2536
+ <SyncModePicker t={t} value={mode} disabled={running} quiet={quiet} onPick={setMode} />
2537
+ <button
2538
+ type="button"
2539
+ className={sync.behind > 0 ? `${css.btn} ${css.btnBehind}` : css.btn}
2540
+ disabled={running || noUpstream}
2541
+ // No upstream is a reason of Pull's own, so that dim stays put.
2542
+ data-quiet={quietlyDisabled(running, sustained, noUpstream) ? '' : undefined}
2543
+ title={noUpstream ? t('noUpstreamHint') : undefined}
2544
+ onClick={() => onOp('pull', { mode })}
2545
+ >
2546
+ <SyncGlyph of="pull" />
2547
+ {busy === 'pull' ? t('opRunning') : t('pull')}
2548
+ {sync.behind > 0 ? <span className={css.btnCount}>{sync.behind}</span> : null}
2549
+ </button>
2550
+ </span>
2551
+
2552
+ <button
2553
+ type="button" className={pushClass} disabled={running} data-quiet={quiet ? '' : undefined}
2554
+ // The first push of a branch has no upstream yet — that is the case
2555
+ // `--set-upstream` exists for, so it must not be disabled here.
2556
+ title={noUpstream ? t('pushSetUpstream') : undefined}
2557
+ onClick={() => onOp('push')}
2558
+ >
2559
+ <SyncGlyph of="push" />
2560
+ {busy === 'push' ? t('opRunning') : noUpstream ? t('publish') : t('push')}
2561
+ {sync.ahead > 0 && !noUpstream ? <span className={css.btnCount}>{sync.ahead}</span> : null}
2562
+ </button>
2563
+ </div>
2564
+ )
2565
+ }
2566
+
2567
+ /**
2568
+ * The commit box: a message, and what it would commit.
2569
+ *
2570
+ * Commit is disabled with nothing staged rather than quietly falling back to
2571
+ * committing the whole worktree. The drawer shows a staging area, and a button
2572
+ * that ignores it would make that display a lie.
2573
+ */
2574
+ function CommitBox({ t, files, busy, onOp, message, onMessage, amend, onAmend }: {
2575
+ t: Translate
2576
+ files: readonly GitFile[]
2577
+ busy: GitOpName | null
2578
+ onOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
2579
+ /** Lifted to the panel: this box unmounts on a tab switch, the draft must not. */
2580
+ message: string
2581
+ onMessage: (next: string) => void
2582
+ amend: boolean
2583
+ onAmend: (next: boolean) => void
2584
+ }): ReactNode {
2585
+ const setMessage = onMessage
2586
+ const setAmend = onAmend
2587
+ const stagedCount = files.filter(file => file.staged === true).length
2588
+ const running = busy !== null
2589
+ // Amending re-uses the previous commit, so it is the one case where an empty
2590
+ // index is still a legitimate commit (a message-only reword).
2591
+ const needsStaged = stagedCount === 0 && !amend
2592
+ const needsMessage = message.trim().length === 0
2593
+ const canCommit = !needsMessage && !needsStaged && !running
2594
+ // A disabled button that does not say why reads as broken; the staging half of
2595
+ // that is stated permanently by the lead line above, so only the message case
2596
+ // needs the title.
2597
+ const blocked = needsMessage ? t('commitNeedMessage') : undefined
2598
+
2599
+ const commit = (): void => {
2600
+ if (!canCommit) return
2601
+ void onOp('commit', { message, amend }).then(result => {
2602
+ // Keep the message on failure: it is the user's text, and retyping a
2603
+ // commit message because the index was empty is a bad way to learn that.
2604
+ if (result.ok) { setMessage(''); setAmend(false) }
2605
+ })
2606
+ }
2607
+
2608
+ return (
2609
+ <div className={css.commitBox}>
2610
+ {/* The one instruction the tick model needs, stated once where the action
2611
+ lives. A blocker that appears only when the index is empty reads as an
2612
+ error and arrives after the confusion it explains. */}
2613
+ <p className={css.commitLead} data-gs-part="commit-lead">{t('commitLead')}</p>
2614
+ <textarea
2615
+ className={css.commitMessage}
2616
+ value={message}
2617
+ rows={2}
2618
+ placeholder={t('commitPlaceholder')}
2619
+ aria-label={t('commitPlaceholder')}
2620
+ disabled={running}
2621
+ onChange={event => setMessage(event.target.value)}
2622
+ onKeyDown={event => {
2623
+ // Ctrl/Cmd+Enter commits, the shortcut every git client shares. Plain
2624
+ // Enter stays a newline: a commit body is normal and losing it to a
2625
+ // stray keystroke is not recoverable from the UI.
2626
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); commit() }
2627
+ }}
2628
+ />
2629
+ <div className={css.commitRow}>
2630
+ <label className={css.commitAmend}>
2631
+ <input
2632
+ type="checkbox" checked={amend} disabled={running}
2633
+ onChange={event => setAmend(event.target.checked)}
2634
+ />
2635
+ {t('amend')}
2636
+ </label>
2637
+ <span className={css.commitStaged}>{t('stagedCount', { count: stagedCount })}</span>
2638
+ <button
2639
+ type="button"
2640
+ className={css.commitBtn}
2641
+ disabled={!canCommit}
2642
+ title={running ? undefined : blocked}
2643
+ onClick={commit}
2644
+ >{busy === 'commit' ? t('opRunning') : t('commit')}</button>
2645
+ </div>
2646
+ </div>
2647
+ )
2648
+ }
2649
+
2650
+ /** Subject plus body, the text `git log` would print for `%B` without the trailing newline. */
2651
+ function commitMessageText(commit: GitCommit): string {
2652
+ const body = commit.body ?? ''
2653
+ return body.length > 0 ? `${commit.subject}\n\n${body}` : commit.subject
2654
+ }
2655
+
2656
+ function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNode {
2657
+ const [copied, setCopied] = useState(false)
2658
+ useEffect(() => {
2659
+ if (!copied) return
2660
+ const id = window.setTimeout(() => setCopied(false), 1400)
2661
+ return () => { window.clearTimeout(id) }
2662
+ }, [copied])
2663
+ return (
2664
+ <button
2665
+ type="button"
2666
+ className={css.commitCopy}
2667
+ onMouseDown={event => event.preventDefault()}
2668
+ onClick={event => {
2669
+ event.stopPropagation()
2670
+ void navigator.clipboard.writeText(text).then(() => setCopied(true), () => setCopied(false))
2671
+ }}
2672
+ >{copied ? t('copiedCommit') : t('copyCommit')}</button>
2673
+ )
2674
+ }
2675
+
2676
+ /**
2677
+ * One row in the history list. The subject stays one truncated line so the list
2678
+ * stays scannable; hovering opens a card with the full message, including a
2679
+ * multi-line body, which can be copied without selecting the commit.
2680
+ */
2681
+ /* ---------- commit graph ---------- */
2682
+
2683
+ /** Row height the graph and the list agree on. The lines only join up if every
2684
+ * row is exactly as tall as the segment drawn for it. */
2685
+ const GRAPH_ROW_H = 48
2686
+ /** Horizontal distance between lanes. */
2687
+ const GRAPH_LANE_W = 14
2688
+ /** Ref chips shown inline before the subject; the rest collapse into a "+N". */
2689
+ const COMMIT_REF_CHIPS = 2
2690
+ /** Lanes past this are not drawn. A repository can braid arbitrarily wide, and
2691
+ * the diff is worth more than the twelfth simultaneous branch. */
2692
+ const GRAPH_MAX_LANES = 6
2693
+
2694
+ const laneX = (lane: number): number => lane * GRAPH_LANE_W + GRAPH_LANE_W / 2
2695
+
2696
+ /**
2697
+ * One row's slice of the commit graph.
2698
+ *
2699
+ * Drawn as an SVG of exactly {@link GRAPH_ROW_H} pixels, so consecutive rows
2700
+ * butt together and a lane reads as one unbroken line down the list. The dot
2701
+ * sits at the vertical centre; edges leave the top edge, the dot, or the bottom
2702
+ * edge, and a cubic with its control points at the quarter heights gives the
2703
+ * S-curve every git client draws for a branch or a merge.
2704
+ */
2705
+ function GraphCell({ row, width, active }: { row: GraphRow; width: number; active: boolean }): ReactNode {
2706
+ const lanes = Math.min(width, GRAPH_MAX_LANES)
2707
+ const w = lanes * GRAPH_LANE_W
2708
+ const mid = GRAPH_ROW_H / 2
2709
+ const visible = (lane: number): boolean => lane < GRAPH_MAX_LANES
2710
+ const stroke = (lane: number): string => `var(--gs-graph-${lane % 6})`
2711
+
2712
+ const paths: ReactNode[] = []
2713
+ for (const lane of row.through) {
2714
+ if (!visible(lane)) continue
2715
+ paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${GRAPH_ROW_H}`} stroke={stroke(lane)} />)
2716
+ }
2717
+ for (const lane of row.into) {
2718
+ if (!visible(lane) || !visible(row.lane)) continue
2719
+ paths.push(lane === row.lane
2720
+ ? <path key={`i${lane}`} d={`M ${laneX(lane)} 0 V ${mid}`} stroke={stroke(lane)} />
2721
+ : (
2722
+ <path
2723
+ key={`i${lane}`}
2724
+ d={`M ${laneX(lane)} 0 C ${laneX(lane)} ${mid / 2}, ${laneX(row.lane)} ${mid / 2}, ${laneX(row.lane)} ${mid}`}
2725
+ stroke={stroke(lane)}
2726
+ />
2727
+ ))
2728
+ }
2729
+ for (const lane of row.outOf) {
2730
+ if (!visible(lane) || !visible(row.lane)) continue
2731
+ paths.push(lane === row.lane
2732
+ ? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${GRAPH_ROW_H}`} stroke={stroke(lane)} />
2733
+ : (
2734
+ <path
2735
+ key={`o${lane}`}
2736
+ d={`M ${laneX(row.lane)} ${mid} C ${laneX(row.lane)} ${mid + mid / 2}, ${laneX(lane)} ${mid + mid / 2}, ${laneX(lane)} ${GRAPH_ROW_H}`}
2737
+ stroke={stroke(lane)}
2738
+ />
2739
+ ))
2740
+ }
2741
+
2742
+ return (
2743
+ <svg
2744
+ className={css.graphCell}
2745
+ width={w}
2746
+ height={GRAPH_ROW_H}
2747
+ viewBox={`0 0 ${w} ${GRAPH_ROW_H}`}
2748
+ aria-hidden="true"
2749
+ focusable="false"
2750
+ >
2751
+ <g fill="none" strokeWidth="1.6" strokeLinecap="round">{paths}</g>
2752
+ {visible(row.lane) ? (
2753
+ <circle
2754
+ cx={laneX(row.lane)}
2755
+ cy={mid}
2756
+ r={row.isMerge ? 4.5 : 3.5}
2757
+ // A merge is hollow, the way every git client distinguishes it: it is
2758
+ // a joining of lines rather than a change of its own.
2759
+ fill={row.isMerge ? 'var(--gs-panel)' : stroke(row.lane)}
2760
+ stroke={stroke(row.lane)}
2761
+ strokeWidth={row.isMerge ? 2 : active ? 3 : 0}
2762
+ />
2763
+ ) : null}
2764
+ </svg>
2765
+ )
2766
+ }
2767
+
2768
+ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
2769
+ t: Translate
2770
+ commit: GitCommit
2771
+ active: boolean
2772
+ onSelect: (hash: string) => void
2773
+ /** This commit's lane geometry; absent while the graph is still empty. */
2774
+ graphRow?: GraphRow
2775
+ graphWidth: number
2776
+ }): ReactNode {
2777
+ const rowRef = useRef<HTMLButtonElement>(null)
2778
+ const [open, setOpen] = useState(false)
2779
+ const [box, setBox] = useState<{ top: number; left: number; maxHeight: number } | null>(null)
2780
+ const enterTimer = useRef(0)
2781
+ const leaveTimer = useRef(0)
2782
+ const body = commit.body ?? ''
2783
+
2784
+ const cancel = (): void => {
2785
+ window.clearTimeout(enterTimer.current)
2786
+ window.clearTimeout(leaveTimer.current)
2787
+ }
2788
+ const show = (): void => {
2789
+ cancel()
2790
+ enterTimer.current = window.setTimeout(() => setOpen(true), 360)
2791
+ }
2792
+ const hide = (): void => {
2793
+ cancel()
2794
+ leaveTimer.current = window.setTimeout(() => setOpen(false), 160)
2795
+ }
2796
+
2797
+ useEffect(() => () => { cancel() }, [])
2798
+
2799
+ useEffect(() => {
2800
+ if (!open) { setBox(null); return }
2801
+ const row = rowRef.current
2802
+ if (row === null) return
2803
+ const rect = row.getBoundingClientRect()
2804
+ const width = 380
2805
+ const left = Math.min(rect.right + 10, window.innerWidth - width - 12)
2806
+ const top = Math.max(12, Math.min(rect.top, window.innerHeight - 220))
2807
+ setBox({ top, left: Math.max(12, left), maxHeight: window.innerHeight - top - 16 })
2808
+ }, [open])
2809
+
2810
+ const host = rowRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body)
2811
+
2812
+ const refs = commit.refs ?? []
2813
+
2814
+ return (
2815
+ <>
2816
+ {/* The graph is a SIBLING of the row button, spanning the line's full
2817
+ height with no margin of its own — that is what lets a lane run
2818
+ unbroken from one row into the next while the button itself keeps its
2819
+ inset and its rounded corners. */}
2820
+ <div className={css.commitLine}>
2821
+ {graphRow !== undefined
2822
+ ? <GraphCell row={graphRow} width={graphWidth} active={active} />
2823
+ : null}
2824
+ <button
2825
+ ref={rowRef}
2826
+ type="button"
2827
+ role="option"
2828
+ aria-selected={active}
2829
+ className={active ? `${css.commit} ${css.commitActive}` : css.commit}
2830
+ onClick={() => onSelect(commit.hash)}
2831
+ onMouseEnter={show}
2832
+ onMouseLeave={hide}
2833
+ >
2834
+ <span className={css.commitTop}>
2835
+ <code className={css.commitHash}>{commit.hash}</code>
2836
+ <span className={css.commitWhen}>{commit.when}</span>
2837
+ </span>
2838
+ <span className={css.commitSubjectRow}>
2839
+ {/* Capped at two. A release commit can carry six refs, and the
2840
+ subject is what the row is actually for — the rest are counted
2841
+ and named in the title rather than crowding it out. */}
2842
+ {refs.slice(0, COMMIT_REF_CHIPS).map(ref => (
2843
+ <span key={ref} className={css.commitRef} title={ref}>{ref}</span>
2844
+ ))}
2845
+ {refs.length > COMMIT_REF_CHIPS ? (
2846
+ <span className={css.commitRefMore} title={refs.slice(COMMIT_REF_CHIPS).join('\n')}>
2847
+ +{refs.length - COMMIT_REF_CHIPS}
2848
+ </span>
2849
+ ) : null}
2850
+ <span className={css.commitSubject}>{commit.subject}</span>
2851
+ {body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
2852
+ </span>
2853
+ </button>
2854
+ </div>
2855
+ {open && box !== null && host !== null ? createPortal(
2856
+ <div
2857
+ className={css.commitPop}
2858
+ style={{ top: box.top, left: box.left, maxHeight: box.maxHeight }}
2859
+ onMouseEnter={() => { cancel(); setOpen(true) }}
2860
+ onMouseLeave={hide}
2861
+ onClick={event => event.stopPropagation()}
2862
+ >
2863
+ <div className={css.commitPopTop}>
2864
+ <code className={css.commitHash}>{commit.hash}</code>
2865
+ <span className={css.commitWhen}>{commit.when}</span>
2866
+ <CopyCommitButton t={t} text={commitMessageText(commit)} />
2867
+ </div>
2868
+ <div className={css.commitPopSubject}>{commit.subject}</div>
2869
+ {body.length > 0 ? <pre className={css.commitPopBody}>{body}</pre> : null}
2870
+ </div>,
2871
+ host,
2872
+ ) : null}
2873
+ </>
2874
+ )
2875
+ }
2876
+
2877
+ /**
2878
+ * The commit log as its own full-height pane.
2879
+ *
2880
+ * It sits BESIDE the file tree rather than stacked above it, which is what
2881
+ * GitHub Desktop, the JetBrains git log and GitKraken all do: a commit list and
2882
+ * the selected commit's files are peer panes, each with its own scrollbar. The
2883
+ * earlier stacked layout had to be collapsible because two scrolling lists were
2884
+ * sharing one narrow column — a control that hid the thing you were reading and
2885
+ * that nobody could be expected to discover. Side by side, there is nothing to
2886
+ * collapse and nothing to explain.
2887
+ *
2888
+ * Pages load by scrolling. A button at the end of a growing list is the worst
2889
+ * of both worlds — it retreats every time it is used, and it asks the reader to
2890
+ * confirm an intention that scrolling toward the end already stated. A sentinel
2891
+ * below the last row requests the next page as it comes into view, which is
2892
+ * what GitHub and GitLens do. The observer is rebuilt whenever the list grows,
2893
+ * so a page too short to fill the pane immediately triggers the next one.
2894
+ */
2895
+ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore }: {
2896
+ /** The pane element, which the divider beside it measures from. Not named
2897
+ * `ref`: React reserves that on a function component, so it would be stripped
2898
+ * from props and never reach this element. */
2899
+ paneRef: Ref<HTMLDivElement>
2900
+ /** Dragged width, when the divider has been used. */
2901
+ style: CSSProperties | undefined
2902
+ t: Translate
2903
+ /** First page in flight — the pane says "loading", not "no history", which
2904
+ * would be a claim about the repository the data has not made. */
2905
+ loading: boolean
2906
+ commits: readonly GitCommit[]
2907
+ active: string | null
2908
+ onSelect: (hash: string) => void
2909
+ hasMore: boolean
2910
+ loadingMore: boolean
2911
+ onLoadMore: () => void
2912
+ }): ReactNode {
2913
+ const scrollRef = useRef<HTMLDivElement>(null)
2914
+ const sentinelRef = useRef<HTMLDivElement>(null)
2915
+ // Recomputed only when a page lands. The layout is a single pass over the
2916
+ // loaded prefix, and every row's geometry depends on the rows above it, so
2917
+ // there is nothing finer to memoise than the whole list.
2918
+ const graph = useMemo(
2919
+ () => layoutGraph(commits.map(commit => ({ hash: commit.hash, parents: commit.parents ?? [] }))),
2920
+ [commits],
2921
+ )
2922
+
2923
+ useEffect(() => {
2924
+ const root = scrollRef.current
2925
+ const sentinel = sentinelRef.current
2926
+ if (root === null || sentinel === null || !hasMore || loadingMore) return
2927
+ const observer = new IntersectionObserver(
2928
+ entries => { if (entries.some(entry => entry.isIntersecting)) onLoadMore() },
2929
+ // Start the fetch before the sentinel is actually reached, so the next
2930
+ // page is usually there by the time the reader arrives.
2931
+ { root, rootMargin: '300px' },
2932
+ )
2933
+ observer.observe(sentinel)
2934
+ return () => { observer.disconnect() }
2935
+ }, [hasMore, loadingMore, commits.length, onLoadMore])
2936
+
2937
+ return (
2938
+ <div ref={paneRef} className={css.commitsPane} style={style} data-gs-part="commits">
2939
+ {/* No count: the only number available is how many pages have been loaded,
2940
+ which is not how many commits exist. A number that cannot be right is
2941
+ worse than none. */}
2942
+ <div className={css.paneHead}>
2943
+ <span className={css.paneTitle}>{t('historyLabel')}</span>
2944
+ </div>
2945
+ {commits.length === 0 ? (
2946
+ <div className={css.empty}>{loading ? t('loading') : t('noCommits')}</div>
2947
+ ) : (
2948
+ <div className={css.commits} role="listbox" aria-label={t('historyLabel')} ref={scrollRef}>
2949
+ {commits.map((commit, index) => (
2950
+ <CommitRow
2951
+ key={commit.hash}
2952
+ t={t}
2953
+ commit={commit}
2954
+ active={commit.hash === active}
2955
+ onSelect={onSelect}
2956
+ graphRow={graph.rows[index]}
2957
+ graphWidth={graph.width}
2958
+ />
2959
+ ))}
2960
+ <div ref={sentinelRef} className={css.commitsSentinel} />
2961
+ <div className={css.commitsFoot}>
2962
+ {loadingMore ? t('loading') : hasMore ? '' : t('historyEnd')}
2963
+ </div>
2964
+ </div>
2965
+ )}
2966
+ </div>
2967
+ )
2968
+ }
2969
+
2970
+ /* ---------- file tree ---------- */
2971
+
2972
+ /** Horizontal step per nesting level. */
2973
+ const TREE_INDENT = 14
2974
+ /** The gutter every row starts at. Equals `--gs-gutter-pane`, so depth-0 ticks
2975
+ * line up with the toolbar's own content — and everything interactive stays
2976
+ * clear of the 10px resizer the drawer paints over its left edge. */
2977
+ const TREE_BASE_INDENT = 12
2978
+ /** Chevron width plus its gap. A file row adds this so its status badge starts at
2979
+ * the directory NAME's column rather than under the directory's chevron. */
2980
+ const TREE_LEAF_OFFSET = 16
2981
+ /** Where a level's indent guide sits: inside the chevron, so it points at the
2982
+ * rows it groups. */
2983
+ const TREE_RAIL_OFFSET = 12
2984
+ /** The tick's own width. Must match `.checkBox` — the row's content starts after
2985
+ * it, and the indent guides are positioned from it. */
2986
+ const TREE_CHECK_W = 22
2987
+ /** Custom property the stylesheet reads to place one level's indent guide. */
2988
+ const RAIL_VAR = '--gs-rail'
2989
+
2990
+ interface DirNode {
2991
+ readonly name: string
2992
+ readonly path: string
2993
+ readonly dirs: Map<string, DirNode>
2994
+ readonly files: GitFile[]
2995
+ fileCount: number
2996
+ added: number
2997
+ deleted: number
2998
+ /** Every descendant's tick, rolled up. Computed once with the other totals. */
2999
+ check: CheckState
3000
+ }
3001
+
3002
+ function buildTree(files: readonly GitFile[]): DirNode {
3003
+ const root: DirNode = { name: '', path: '', dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
3004
+ for (const file of files) {
3005
+ let node = root
3006
+ const parts = file.path.split('/')
3007
+ for (let i = 0; i < parts.length - 1; i += 1) {
3008
+ const name = parts[i]
3009
+ let child = node.dirs.get(name)
3010
+ if (child === undefined) {
3011
+ child = { name, path: parts.slice(0, i + 1).join('/'), dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
3012
+ node.dirs.set(name, child)
3013
+ }
3014
+ node = child
3015
+ }
3016
+ node.files.push(file)
3017
+ }
3018
+ const aggregate = (node: DirNode): void => {
3019
+ node.fileCount = node.files.length
3020
+ node.added = node.files.reduce((sum, f) => sum + f.addedLines, 0)
3021
+ node.deleted = node.files.reduce((sum, f) => sum + f.deletedLines, 0)
3022
+ const ticks: CheckState[] = node.files.map(fileCheckState)
3023
+ for (const child of node.dirs.values()) {
3024
+ aggregate(child)
3025
+ node.fileCount += child.fileCount
3026
+ node.added += child.added
3027
+ node.deleted += child.deleted
3028
+ ticks.push(child.check)
3029
+ }
3030
+ node.check = rollUp(ticks)
3031
+ }
3032
+ aggregate(root)
3033
+ return compactChains(root)
3034
+ }
3035
+
3036
+ /**
3037
+ * Merge every directory that holds nothing but one subdirectory into that child.
3038
+ *
3039
+ * `docs/superpowers/specs/design.md` otherwise costs three rows and three indent
3040
+ * levels to reach one file, and none of those three rows carries a choice — each
3041
+ * has exactly one way down. Merging them into a single `docs/superpowers/specs`
3042
+ * row is what VS Code calls compact folders, and it makes indentation depth mean
3043
+ * "where the tree branches" rather than "how long the path is".
3044
+ *
3045
+ * The merged node keeps the DEEPEST path, so it stays the one the collapse set
3046
+ * and the reveal-the-active-file walk already address.
3047
+ * @param node - directory whose descendants are compacted.
3048
+ * @returns the node with compacted children.
3049
+ */
3050
+ function compactChains(node: DirNode): DirNode {
3051
+ const dirs = new Map<string, DirNode>()
3052
+ for (const child of node.dirs.values()) {
3053
+ let merged = compactChains(child)
3054
+ while (merged.files.length === 0 && merged.dirs.size === 1) {
3055
+ const only = merged.dirs.values().next().value as DirNode
3056
+ merged = { ...only, name: `${merged.name}/${only.name}` }
3057
+ }
3058
+ dirs.set(merged.name, merged)
3059
+ }
3060
+ return { ...node, dirs }
3061
+ }
3062
+
3063
+ interface FileTreeProps {
3064
+ t: Translate
3065
+ /** Whether the view has nothing to show yet — already resolved by the caller
3066
+ * via {@link showsPending}, NOT the raw in-flight flag. This pane used to
3067
+ * re-derive it from `loading && files.length === 0`, and that second copy of
3068
+ * the rule is precisely what the header then got wrong. */
3069
+ loading?: boolean
3070
+ /** What the list holds, prepended to the count — the working-tree view says
3071
+ * which worktree it is reading; commit views are already named by history. */
3072
+ lead?: string
3073
+ files: readonly GitFile[]
3074
+ active: string | null
3075
+ onSelect: (path: string) => void
3076
+ /** Undefined until the user interacts: then it shows defaults. Lifted to the
3077
+ * panel so background polls (new `files` identity) and drawer close/reopen
3078
+ * never reset the user's expansion choices. */
3079
+ collapsed: Set<string> | undefined
3080
+ onCollapsedChange: (next: Set<string>) => void
3081
+ /** Add or remove files from the commit set. Undefined outside the working-tree
3082
+ * view, where what a commit contains was decided long ago. */
3083
+ onCheck?: (files: readonly GitFile[], state: CheckState) => void
3084
+ /** Rendered under the tree in the working-tree view only. */
3085
+ footer?: ReactNode
3086
+ }
3087
+
3088
+ function FileTree({ t, loading, lead, files, active, onSelect, collapsed, onCollapsedChange, onCheck, footer }: FileTreeProps): ReactNode {
3089
+ const tree = useMemo(() => buildTree(files), [files])
3090
+ /** Default: a dir collapses when it holds more than 12 files anywhere below it. */
3091
+ const effective = collapsed ?? defaultCollapsed(tree)
3092
+
3093
+ // Reveal the active file by expanding its ancestor chain — ONLY when the
3094
+ // selection itself changes. Listening to `collapsed` here would instantly
3095
+ // revert manual folds of any directory containing the active file.
3096
+ useEffect(() => {
3097
+ if (active === null) return
3098
+ const parts = active.split('/')
3099
+ let touched = false
3100
+ const next = new Set(collapsed ?? defaultCollapsed(tree))
3101
+ for (let i = 1; i < parts.length; i += 1) {
3102
+ const dir = parts.slice(0, i).join('/')
3103
+ if (next.delete(dir)) touched = true
3104
+ }
3105
+ if (touched) onCollapsedChange(next)
3106
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reveal is a selection-change event, not an invariant over `collapsed`
3107
+ }, [active])
3108
+
3109
+ const setAll = (open: boolean): void => {
3110
+ onCollapsedChange(open ? new Set() : allDirs(tree))
3111
+ }
3112
+
3113
+ const toggleOne = (path: string): void => {
3114
+ const next = new Set(effective)
3115
+ if (next.has(path)) next.delete(path)
3116
+ else next.add(path)
3117
+ onCollapsedChange(next)
3118
+ }
3119
+
3120
+ const stageLabels = { stage: t('stage'), unstage: t('unstage') }
3121
+
3122
+ return (
3123
+ <div className={css.treeWrap}>
3124
+ <div className={css.treeTools}>
3125
+ {/* The root tick replaces the Stage all / Unstage all pair: it says the
3126
+ same two things in the column the rows already read down, and gives
3127
+ the toolbar back the room those two buttons needed. The toolbar's own
3128
+ pane gutter is its indent, so it lines up with the depth-0 rows. */}
3129
+ <div className={css.treeLead}>
3130
+ {onCheck !== undefined ? (
3131
+ <CheckBox
3132
+ state={tree.check}
3133
+ label={tree.check === 'on' ? t('unstageAll') : t('stageAll')}
3134
+ indent={0}
3135
+ onToggle={() => onCheck(files, tree.check)}
3136
+ />
3137
+ ) : null}
3138
+ <span className={css.treeLabel}>
3139
+ {loading === true
3140
+ ? t('loading')
3141
+ : `${lead !== undefined ? `${lead} · ` : ''}${t('files', { count: files.length })}`}
3142
+ </span>
3143
+ </div>
3144
+ <div className={css.treeActions} data-gs-part="tree-actions">
3145
+ {/* Icon-only, with the label on `title`/`aria-label`: the glyph is the
3146
+ same one the rows carry, so each button previews its own result. */}
3147
+ <button
3148
+ type="button" className={css.treeIcon} data-gs-part="expand-all"
3149
+ title={t('expandAll')} aria-label={t('expandAll')}
3150
+ onClick={() => setAll(true)}
3151
+ ><span className={`${css.treeIconGlyph} ${css.treeIconDown}`}>▸</span></button>
3152
+ <button
3153
+ type="button" className={css.treeIcon} data-gs-part="collapse-all"
3154
+ title={t('collapseAll')} aria-label={t('collapseAll')}
3155
+ onClick={() => setAll(false)}
3156
+ ><span className={css.treeIconGlyph}>▸</span></button>
3157
+ </div>
3158
+ </div>
3159
+ {loading === true ? (
3160
+ <div className={css.treeEmpty} data-gs-part="tree-loading">{t('loading')}</div>
3161
+ ) : (
3162
+ <ul className={css.tree}>
3163
+ <TreeChildren
3164
+ node={tree} depth={0} active={active} collapsed={effective}
3165
+ onToggle={toggleOne} onSelect={onSelect} onCheck={onCheck} stageLabels={stageLabels}
3166
+ />
3167
+ </ul>
3168
+ )}
3169
+ {footer}
3170
+ </div>
3171
+ )
3172
+ }
3173
+
3174
+ function defaultCollapsed(root: DirNode): Set<string> {
3175
+ const out = new Set<string>()
3176
+ const walk = (node: DirNode): void => {
3177
+ for (const child of node.dirs.values()) {
3178
+ if (child.fileCount > 12) out.add(child.path)
3179
+ walk(child)
3180
+ }
3181
+ }
3182
+ walk(root)
3183
+ return out
3184
+ }
3185
+
3186
+ function allDirs(node: DirNode): Set<string> {
3187
+ const out = new Set<string>()
3188
+ const walk = (n: DirNode): void => {
3189
+ for (const child of n.dirs.values()) { out.add(child.path); walk(child) }
3190
+ }
3191
+ walk(node)
3192
+ return out
3193
+ }
3194
+
3195
+ /**
3196
+ * One tick. A sibling of the row it belongs to rather than a child of it: a
3197
+ * button inside a button is invalid HTML, and the two clicks mean different
3198
+ * things — this one changes the commit set, the row opens the diff.
3199
+ *
3200
+ * The tick carries its row's own indent and stands at the node it includes,
3201
+ * IDEA-style, rather than in a column pinned to the pane edge. A pinned column
3202
+ * reads at a glance, but it detaches each tick from its node — and its first
3203
+ * 10px sat underneath the drawer's edge resizer, so a click on the left half of
3204
+ * a depth-0 tick dragged the drawer instead of staging anything.
3205
+ */
3206
+ function CheckBox({ state, label, indent, onToggle }: {
3207
+ state: CheckState
3208
+ label: string
3209
+ /** The row's left edge, carried here so the tick stands at its own node. */
3210
+ indent: number
3211
+ onToggle: () => void
3212
+ }): ReactNode {
3213
+ const mark = state === 'on' ? css.checkMarkOn : state === 'partial' ? css.checkMarkPartial : ''
3214
+ return (
3215
+ <button
3216
+ type="button"
3217
+ role="checkbox"
3218
+ aria-checked={state === 'partial' ? 'mixed' : state === 'on'}
3219
+ className={css.checkBox}
3220
+ style={{ marginLeft: indent }}
3221
+ title={label}
3222
+ aria-label={label}
3223
+ onClick={onToggle}
3224
+ >
3225
+ <span className={`${css.checkMark} ${mark}`} aria-hidden="true">
3226
+ {state === 'on' ? '✓' : state === 'partial' ? '–' : ''}
3227
+ </span>
3228
+ </button>
3229
+ )
3230
+ }
3231
+
3232
+ /** Every file at or under a node, for a tick that acts on a whole directory. */
3233
+ function filesUnder(node: DirNode): GitFile[] {
3234
+ const out = [...node.files]
3235
+ for (const child of node.dirs.values()) out.push(...filesUnder(child))
3236
+ return out
3237
+ }
3238
+
3239
+ interface TreeChildrenProps {
3240
+ node: DirNode
3241
+ depth: number
3242
+ active: string | null
3243
+ collapsed: Set<string>
3244
+ onToggle: (path: string) => void
3245
+ onSelect: (path: string) => void
3246
+ /** Add or remove files from the commit set. Undefined outside the working-tree
3247
+ * view, where what a commit contains was decided long ago. */
3248
+ onCheck?: (files: readonly GitFile[], state: CheckState) => void
3249
+ /** Pre-translated, so the row does not have to carry `t` for two strings. */
3250
+ stageLabels: { stage: string; unstage: string }
3251
+ }
3252
+
3253
+ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCheck, stageLabels }: TreeChildrenProps): ReactNode {
3254
+ const dirNodes = [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
3255
+ const fileNodes = [...node.files].sort((a, b) => basePart(a.path).localeCompare(basePart(b.path)))
3256
+ const checkColumn = onCheck !== undefined ? TREE_CHECK_W : 0
3257
+ // With ticks, the tick carries this indent and the button starts at 0; without
3258
+ // them (history, compare) the button carries it, as it always did.
3259
+ const indent = TREE_BASE_INDENT + depth * TREE_INDENT
3260
+ return (
3261
+ <>
3262
+ {dirNodes.map(dir => {
3263
+ const open = !collapsed.has(dir.path)
3264
+ const containsActive = active !== null && active.startsWith(`${dir.path}/`)
3265
+ return (
3266
+ <li key={dir.path} className={css.treeDirLi}>
3267
+ <div className={css.treeRow}>
3268
+ {onCheck !== undefined ? (
3269
+ <CheckBox
3270
+ state={dir.check}
3271
+ label={dir.check === 'on' ? stageLabels.unstage : stageLabels.stage}
3272
+ indent={indent}
3273
+ onToggle={() => onCheck(filesUnder(dir), dir.check)}
3274
+ />
3275
+ ) : null}
3276
+ <button
3277
+ type="button"
3278
+ className={`${css.treeDir} ${containsActive ? css.treeDirActive : ''}`}
3279
+ style={{ paddingLeft: onCheck !== undefined ? 0 : indent }}
3280
+ onClick={() => onToggle(dir.path)}
3281
+ title={dir.path}
3282
+ >
3283
+ <span className={`${css.chevron} ${open ? css.chevronOpen : ''}`}>▸</span>
3284
+ <span className={css.treeDirName}>{dir.name}</span>
3285
+ <span className={css.treeDirCount}>{dir.fileCount}</span>
3286
+ <span className={css.treeDirCounts}>
3287
+ {dir.added > 0 ? <span className={css.fileCountAdd}>+{dir.added}</span> : null}
3288
+ {dir.deleted > 0 ? <span className={css.fileCountDel}>−{dir.deleted}</span> : null}
3289
+ </span>
3290
+ </button>
3291
+ </div>
3292
+ {open ? (
3293
+ <ul
3294
+ className={css.treeSub}
3295
+ // The rail hangs off the parent's chevron, which sits after the
3296
+ // row's tick — so the tick's width is part of the offset.
3297
+ style={{ [RAIL_VAR]: `${checkColumn + TREE_BASE_INDENT + depth * TREE_INDENT + TREE_RAIL_OFFSET}px` } as CSSProperties}
3298
+ >
3299
+ <TreeChildren node={dir} depth={depth + 1} active={active} collapsed={collapsed} onToggle={onToggle} onSelect={onSelect} onCheck={onCheck} stageLabels={stageLabels} />
3300
+ </ul>
3301
+ ) : null}
3302
+ </li>
3303
+ )
3304
+ })}
3305
+ {fileNodes.map(file => {
3306
+ const check = fileCheckState(file)
3307
+ return (
3308
+ <li key={file.path} className={css.fileLi}>
3309
+ {onCheck !== undefined ? (
3310
+ <CheckBox
3311
+ state={check}
3312
+ label={check === 'on' ? stageLabels.unstage : stageLabels.stage}
3313
+ indent={indent}
3314
+ onToggle={() => onCheck([file], check)}
3315
+ />
3316
+ ) : null}
3317
+ <button
3318
+ type="button"
3319
+ className={active === file.path ? `${css.file} ${css.fileActive}` : css.file}
3320
+ style={{ paddingLeft: (onCheck !== undefined ? 0 : indent) + TREE_LEAF_OFFSET }}
3321
+ onClick={() => onSelect(file.path)}
3322
+ title={file.previousPath !== undefined ? `${file.previousPath} → ${file.path}` : file.path}
3323
+ >
3324
+ <span className={`${css.fileStatus} ${STATUS_BADGE[file.status]}`}>{statusGlyph(file.status)}</span>
3325
+ <span className={css.filePath}>{basePart(file.path)}</span>
3326
+ {file.binary ? <span className={css.fileBinary}>BIN</span> : (
3327
+ <span className={css.fileCounts}>
3328
+ <span className={css.fileCountAdd}>{file.addedLines > 0 ? `+${file.addedLines}` : ''}</span>{' '}
3329
+ <span className={css.fileCountDel}>{file.deletedLines > 0 ? `−${file.deletedLines}` : ''}</span>
3330
+ </span>
3331
+ )}
3332
+ </button>
3333
+ </li>
3334
+ )
3335
+ })}
3336
+ </>
3337
+ )
3338
+ }
3339
+
3340
+ /* ---------- diff rendering: rows, word-level ranges, syntax pass ---------- */
3341
+
3342
+ /** Render one file's unified-diff segment with word-level highlights and Shiki. */
3343
+ function DiffView({ segment, path, palette }: { segment: string; path: string; palette: string }): ReactNode {
3344
+ const lang = shikiLangOf(path)
3345
+ const shikiTheme = shikiThemeOf(palette)
3346
+ const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
3347
+ const rowsWithWords = useMemo(() => attachWordRanges(parseRows(segment)), [segment])
3348
+ const sides = useMemo(() => gutterSides(rowsWithWords), [rowsWithWords])
3349
+ const syntax = useMemo(
3350
+ () => highlightForRows(rowsWithWords, lang, shikiTheme),
3351
+ [rowsWithWords, lang, shikiTheme, grammarGen],
3352
+ )
3353
+ return (
3354
+ <pre className={css.diffPre}>
3355
+ {rowsWithWords.map((row, i) => (
3356
+ <div key={i} className={`${css.line} ${rowClass(row.kind)}`}>
3357
+ {sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
3358
+ {sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
3359
+ <span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
3360
+ {row.kind === 'add' ? '+' : row.kind === 'del' ? '−' : ''}
3361
+ </span>
3362
+ <span className={css.code}>{renderCode(row, syntax[i] ?? [])}</span>
3363
+ </div>
3364
+ ))}
3365
+ </pre>
3366
+ )
3367
+ }
3368
+
3369
+ function rowClass(kind: Row['kind']): string {
3370
+ switch (kind) {
3371
+ case 'add': return css.lineAdd
3372
+ case 'del': return css.lineDel
3373
+ case 'hunk': return css.lineHunk
3374
+ default: return css.lineContext
3375
+ }
3376
+ }
3377
+
3378
+ function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactNode {
3379
+ if (row.kind === 'hunk') return row.text
3380
+ const painted = overlayRanges(tokens.length > 0 ? tokens : [{ text: row.text }], row.ranges ?? [])
3381
+ if (painted.length === 1 && painted[0]!.color === undefined && !painted[0]!.mark) return row.text
3382
+ return painted.map((tok, i) => (
3383
+ <span
3384
+ key={i}
3385
+ className={tok.mark ? (row.kind === 'add' ? css.wordAdd : css.wordDel) : undefined}
3386
+ style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
3387
+ >{tok.text}</span>
3388
+ ))
3389
+ }
3390
+
3391
+ /* ---------- shared helpers ---------- */
3392
+
3393
+ /** Split a combined `git diff` into path -> its segment text. */
3394
+ function splitDiff(diff: string): Map<string, string> {
3395
+ const out = new Map<string, string>()
3396
+ if (diff.length === 0) return out
3397
+ for (const part of diff.split(/(?=^diff --git )/m)) {
3398
+ if (part.length === 0) continue
3399
+ const path = extractDiffPath(part)
3400
+ if (path.length > 0) out.set(path, part)
3401
+ }
3402
+ return out
3403
+ }
3404
+
3405
+ function extractDiffPath(part: string): string {
3406
+ const firstLine = part.split('\n')[0] ?? ''
3407
+ const match = /\sb\/(.+)$/.exec(firstLine)
3408
+ if (match !== null) return match[1]
3409
+ const rename = /^rename to (.+)$/m.exec(part)
3410
+ if (rename !== null) return rename[1]
3411
+ const del = /\ba\/(.+)$/.exec(firstLine)
3412
+ return del !== null ? del[1] : ''
3413
+ }
3414
+
3415
+ /**
3416
+ * A branch name, or the stand-in when there is none.
3417
+ *
3418
+ * This used to also cut the name to 21 characters and append an ellipsis, which
3419
+ * is how `feature/nested/deep/some-fix` reached the header as
3420
+ * `feature/nested/deep/s…` — the truncation was in JS, so it happened at the
3421
+ * same 21 characters whether the drawer was 400px or maximised, and it cut off
3422
+ * the only end that says which branch this is. Width is the stylesheet's
3423
+ * question; {@link Elided} answers it, from the correct end, only when there is
3424
+ * genuinely not enough room.
3425
+ *
3426
+ * @param branch - branch name, empty when the repo has none yet.
3427
+ * @param empty - already-translated stand-in for the empty case.
3428
+ */
3429
+ function branchLabel(branch: string, empty: string): string {
3430
+ return branch.length === 0 ? empty : branch
3431
+ }
3432
+
3433
+ function statusGlyph(status: GitFileStatus): string {
3434
+ switch (status) {
3435
+ case 'added': return 'A'
3436
+ case 'untracked': return 'U'
3437
+ case 'modified': return 'M'
3438
+ case 'renamed': return 'R'
3439
+ case 'deleted': return 'D'
3440
+ }
3441
+ }
3442
+
3443
+ function basePart(path: string): string {
3444
+ const cut = path.lastIndexOf('/')
3445
+ return cut >= 0 ? path.slice(cut + 1) : path
3446
+ }