@young1lin/dsh-ui-gitworkbench 0.1.11 → 0.1.13

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.
Files changed (36) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/CHANGELOG_EN.md +28 -0
  4. package/README.md +100 -50
  5. package/README_EN.md +2 -1
  6. package/lib/client.js +6902 -6256
  7. package/package.json +1 -1
  8. package/src/client/ChangesFileTree.tsx +550 -0
  9. package/src/client/ChromeGlyph.tsx +27 -0
  10. package/src/client/CodeEditor.tsx +129 -3
  11. package/src/client/CommitHistory.tsx +1001 -0
  12. package/src/client/DiffViews.tsx +1070 -0
  13. package/src/client/GitWorkbenchPanel.module.css +15 -2513
  14. package/src/client/GitWorkbenchPanel.tsx +37 -3959
  15. package/src/client/PaneDivider.tsx +74 -0
  16. package/src/client/WorkbenchControls.tsx +1047 -0
  17. package/src/client/WorktreeGlyph.tsx +24 -0
  18. package/src/client/cm-search-theme.ts +250 -0
  19. package/src/client/diff-nav.ts +59 -0
  20. package/src/client/git-workbench-types.ts +252 -0
  21. package/src/client/locales.ts +14 -8
  22. package/src/client/row-window.ts +23 -0
  23. package/src/client/search-count.ts +125 -0
  24. package/src/client/side-rows.ts +66 -0
  25. package/src/client/styles/changes.css +505 -0
  26. package/src/client/styles/controls.css +236 -0
  27. package/src/client/styles/environment.css +89 -0
  28. package/src/client/styles/files.css +113 -0
  29. package/src/client/styles/history-filters.css +400 -0
  30. package/src/client/styles/history.css +276 -0
  31. package/src/client/styles/image.css +67 -0
  32. package/src/client/styles/operations.css +179 -0
  33. package/src/client/styles/shell.css +431 -0
  34. package/src/client/styles/themes.css +224 -0
  35. package/src/client/use-change-nav.ts +6 -5
  36. package/src/client/use-row-window.ts +55 -0
@@ -0,0 +1,1070 @@
1
+ import {
2
+ Fragment, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore,
3
+ type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode,
4
+ } from 'react'
5
+
6
+ import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
7
+ import { parsePatch } from '../patch-model.ts'
8
+ import { alignRows, allBlockLines, allBlockTally, blockActionsDisabled, blockCount, blockEdge, blockIsWholeFile, blockLines, blockTally, currentActionBlock, needsFirstBlockClearance, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
9
+ import { anchorFor, blockNearestTo, blockTopsFromRows, blockTopsFromSideRows, countBlocks, scrollTopFor, stepBlockIndex, unifiedBlocks } from './diff-nav.ts'
10
+ import { DIFF_GRID_PAD_TOP, DIFF_ROW_H } from './row-window.ts'
11
+ import { useChangeNav } from './use-change-nav.ts'
12
+ import {
13
+ applySaveOk, applySides, armEdit, armRefusal, DISARMED, editableSides, isDirty, markConflict, reloadSides, resetSides,
14
+ type EditState, type WriteResult,
15
+ } from './side-edit.ts'
16
+ import { PaneDivider } from './PaneDivider.tsx'
17
+ import { CodeEditor, type PaintFn } from './CodeEditor.tsx'
18
+ import { detectIndent } from './indent.ts'
19
+ import { grammarLoadCount, highlightForRowsWindow, highlightRange, highlightWindow, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
20
+ import { useRowWindow } from './use-row-window.ts'
21
+ import type { BlockAsk, BlockMode, FileSides, GitOpResult, SideLayer, Translate } from './git-workbench-types.ts'
22
+ import css from './GitWorkbenchPanel.module.css'
23
+
24
+ const SPLIT_MIN = 0.15
25
+ const SPLIT_MAX = 0.85
26
+ const BLOCK_BAR_CLEARANCE = 21
27
+
28
+ /**
29
+ * Change-to-change navigation, as two chevrons.
30
+ *
31
+ * Bootstrap Icons again, at the same 16 viewBox — a pair of arrows is what
32
+ * every editor spells this with, and the words would be longer than the
33
+ * controls beside them.
34
+ */
35
+ const NAV_GLYPH = {
36
+ prev: 'M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z',
37
+ next: 'M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z',
38
+ } as const
39
+
40
+ function NavGlyph({ of }: { of: keyof typeof NAV_GLYPH }): ReactNode {
41
+ return (
42
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
43
+ <path d={NAV_GLYPH[of]} />
44
+ </svg>
45
+ )
46
+ }
47
+
48
+ /* ---------- diff rendering: rows, word-level ranges, syntax pass ---------- */
49
+
50
+ /**
51
+ * Render one file's unified-diff segment with word-level highlights and Shiki.
52
+ *
53
+ * This is what History and Compare show, and — unlike the side-by-side pane —
54
+ * it has no row model carrying block ids, because nothing here acts on a block:
55
+ * a commit's contents were decided long ago, so there is no staging and no
56
+ * roll-back. The walk still needs them, so the runs are read off the row kinds
57
+ * by `unifiedBlocks` and marked on the rows that scroll.
58
+ *
59
+ * The scroller is this component's own rather than the pane's. A bar that
60
+ * scrolls away is not a control, and the pane scrolls in BOTH directions —
61
+ * `sticky` fixes the vertical half and nothing fixes the horizontal one, since
62
+ * a block child of a scroller is only ever as wide as the scrollport. A header
63
+ * outside the scrolled box has neither problem.
64
+ */
65
+ export function DiffView({ segment, path, palette, t }: {
66
+ segment: string
67
+ path: string
68
+ palette: string
69
+ t: Translate
70
+ }): ReactNode {
71
+ const lang = shikiLangOf(path)
72
+ const shikiTheme = shikiThemeOf(palette)
73
+ const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
74
+ const rowsWithWords = useMemo(() => attachWordRanges(parseRows(segment)), [segment])
75
+ const sides = useMemo(() => gutterSides(rowsWithWords), [rowsWithWords])
76
+ const scrollRef = useRef<HTMLDivElement>(null)
77
+ // Windowed for the same reason the side-by-side pane is: a unified diff of a
78
+ // long file put every row in the DOM and re-lexed every one of them, so
79
+ // opening one froze the pane in exactly the same way.
80
+ const win = useRowWindow(scrollRef, rowsWithWords.length, path)
81
+ const syntax = useMemo(
82
+ () => highlightForRowsWindow(rowsWithWords, lang, shikiTheme, win.start, win.end),
83
+ [rowsWithWords, lang, shikiTheme, win.start, win.end, grammarGen],
84
+ )
85
+ const blocks = useMemo(() => unifiedBlocks(rowsWithWords.map(row => row.kind)), [rowsWithWords])
86
+ const changes = useMemo(() => countBlocks(blocks), [blocks])
87
+ // Derived rather than measured, because a windowed pane has no element for
88
+ // the block being walked to.
89
+ const blocksForNav = useRef<readonly number[]>(blocks)
90
+ blocksForNav.current = blocks
91
+ const { goToChange } = useChangeNav(
92
+ scrollRef,
93
+ useCallback(() => blockTopsFromRows(blocksForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
94
+ )
95
+ // Read by the key listener below, which is attached once. `goToChange` only
96
+ // ever touches refs, but pinning it here says so rather than relying on it.
97
+ const walk = useRef(goToChange)
98
+ walk.current = goToChange
99
+
100
+ // F7 / Shift+F7, the spelling IDEA's diff viewer taught, on the same element
101
+ // that scrolls — so the key and the buttons cannot disagree about which pane
102
+ // they move. `tabIndex` is what makes it able to receive the key at all:
103
+ // diff text is not focusable, and a click on it would otherwise leave focus
104
+ // on the document body.
105
+ useEffect(() => {
106
+ const scroller = scrollRef.current
107
+ if (scroller === null) return
108
+ const onKey = (event: KeyboardEvent): void => {
109
+ if (event.key !== 'F7') return
110
+ event.preventDefault()
111
+ walk.current(event.shiftKey ? -1 : 1)
112
+ }
113
+ scroller.addEventListener('keydown', onKey)
114
+ return () => { scroller.removeEventListener('keydown', onKey) }
115
+ }, [])
116
+
117
+ return (
118
+ <div className={css.diffWrap}>
119
+ {changes > 0 ? (
120
+ <div className={css.diffNav}>
121
+ <button
122
+ type="button"
123
+ className={css.blockBtn}
124
+ title={t('prevChangeHint')}
125
+ aria-label={t('prevChange')}
126
+ onClick={() => { goToChange(-1) }}
127
+ ><NavGlyph of="prev" /></button>
128
+ <button
129
+ type="button"
130
+ className={css.blockBtn}
131
+ title={t('nextChangeHint')}
132
+ aria-label={t('nextChange')}
133
+ onClick={() => { goToChange(1) }}
134
+ ><NavGlyph of="next" /></button>
135
+ <span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
136
+ </div>
137
+ ) : null}
138
+ <div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
139
+ <pre className={css.diffPre}>
140
+ {win.padTop > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padTop}px` }} aria-hidden="true" /> : null}
141
+ {rowsWithWords.slice(win.start, win.end).map((row, k) => {
142
+ const i = win.start + k
143
+ return (
144
+ <div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
145
+ {sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
146
+ {sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
147
+ <span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
148
+ {row.kind === 'add' ? '+' : row.kind === 'del' ? '−' : ''}
149
+ </span>
150
+ <span className={css.code}>{renderCode(row, syntax[i] ?? [])}</span>
151
+ </div>
152
+ )
153
+ })}
154
+ {win.padBottom > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padBottom}px` }} aria-hidden="true" /> : null}
155
+ </pre>
156
+ </div>
157
+ </div>
158
+ )
159
+ }
160
+
161
+ function rowClass(kind: Row['kind']): string {
162
+ switch (kind) {
163
+ case 'add': return css.lineAdd
164
+ case 'del': return css.lineDel
165
+ case 'hunk': return css.lineHunk
166
+ default: return css.lineContext
167
+ }
168
+ }
169
+
170
+ function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactNode {
171
+ if (row.kind === 'hunk') return row.text
172
+ const painted = overlayRanges(tokens.length > 0 ? tokens : [{ text: row.text }], row.ranges ?? [])
173
+ if (painted.length === 1 && painted[0]!.color === undefined && !painted[0]!.mark) return row.text
174
+ return painted.map((tok, i) => (
175
+ <span
176
+ key={i}
177
+ className={tok.mark ? (row.kind === 'add' ? css.wordAdd : css.wordDel) : undefined}
178
+ style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
179
+ >{tok.text}</span>
180
+ ))
181
+ }
182
+
183
+ /* ---------- side-by-side diff rendering (working tree only) ---------- */
184
+
185
+ /**
186
+ * The working tree's per-file diff as IDEA shows it: one tab per layer of the
187
+ * index, two columns with the whole file, aligned row by row.
188
+ *
189
+ * The rows come from `side-rows.ts` over the layer's full-context diff, so the
190
+ * alignment is read off the diff rather than computed. A change block — a
191
+ * maximal run of changed rows — carries its own actions: hovering any of its
192
+ * cells outlines the whole block and floats its buttons (stage + roll back on
193
+ * the unstaged tab, unstage on the staged one). The click carries the block's
194
+ * hunk-line indices and the rendered diff's sha, so the host can prove the
195
+ * file has not changed since the pane drew it.
196
+ *
197
+ * The unstaged tab's right column is also EDITABLE (the staged one is not, by
198
+ * design: editing the index would mean writing a blob with no file behind it).
199
+ * Editing arms explicitly — never per keystroke — and the buffer's whole life
200
+ * against the file and the poll is `side-edit.ts`'s to decide: a refresh over
201
+ * a dirty buffer keeps the buffer, a file that moved underneath raises the
202
+ * reload-or-overwrite banner, and the one save path carries the sha the buffer
203
+ * is based on so the host can refuse a stale write. While editing, the layout
204
+ * trades the diff's hole-aligned grid for a dense editor column (same
205
+ * metrics, same gutter rhythm): one grid cannot stay diff-aligned AND hold a
206
+ * dense buffer whenever deletions outrun additions, and re-diffing per
207
+ * keystroke is exactly the editor-library work the first cut declines.
208
+ *
209
+ * `tooLarge` and `binary` fall back to the unified view the pane already had
210
+ * (history and compare keep it unconditionally), with a notice — a silently
211
+ * different view reads as a broken one, not a guarded one.
212
+ */
213
+ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
214
+ t: Translate
215
+ path: string
216
+ palette: string
217
+ statsPath: string | undefined
218
+ fetchSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
219
+ /** Save the editor buffer; the host refuses a stale sha and nothing is written. */
220
+ writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
221
+ /** Names the view the fetch belongs to, as `viewKey` does for the diff cache. */
222
+ scopeKey: string
223
+ /** Refresh generation: a new one means the tree was re-read, so refetch. */
224
+ gen: number
225
+ /** The drawer's polled view of this file's HEAD-diff; a CHANGE in it means
226
+ * the drawer noticed the file move, so the pane refetches even between
227
+ * refresh generations — this is how the poll reaches a dirty buffer. */
228
+ fallbackSegment: string
229
+ fallbackLoading: boolean
230
+ /** Run one block action; a discard routes to the drawer's confirmation. */
231
+ onBlockAction: (mode: BlockMode, ask: BlockAsk) => Promise<GitOpResult>
232
+ /** After a successful save: refresh the tree and the pane together. */
233
+ onSaved: () => void
234
+ /** Reports the buffer's dirty flag outward: the drawer guards every
235
+ * gesture that would drop the buffer (file selection, close, main tab)
236
+ * on it, so it must live where those gestures are handled. */
237
+ onDirtyChange: (dirty: boolean) => void
238
+ }): ReactNode {
239
+ const [layer, setLayer] = useState<SideLayer>('unstaged')
240
+ // How much of the pane the left column gets. Lives here rather than in the
241
+ // drawer so it is one setting for the pane, and survives a file switch —
242
+ // the reader sized the columns for how they read, not for one file.
243
+ const [split, setSplit] = useState(0.5)
244
+ const colsRef = useRef<HTMLDivElement>(null)
245
+ /** The pane's one vertical scroller — what "next change" moves. */
246
+ const scrollRef = useRef<HTMLDivElement>(null)
247
+ /** The block currently addressed by the fixed editor toolbar. Navigation and
248
+ * a direct click both update it; a refreshed diff is normalized by
249
+ * currentActionBlock before any Git action may use it. */
250
+ const [blockSelection, setBlockSelection] = useState({ key: '', block: 0 })
251
+ /** Side-pane navigation follows an explicit current hunk: the fixed action
252
+ * buttons and the counter must target the same block even after wheel
253
+ * scrolling. Read mode uses aligned-row geometry; Edit uses dense right-side
254
+ * line geometry. Both are memoized below and read only on a navigation key. */
255
+ const goToChange = (direction: 1 | -1): void => {
256
+ const current = blockSelection.key === rowWindowKey ? blockSelection.block : 0
257
+ const block = stepBlockIndex(totalBlocks, current, direction)
258
+ if (block === null) return
259
+ const tops = layer === 'unstaged' && edit.armed ? editorBlockTops : alignedBlockTops
260
+ const target = tops[block]
261
+ if (target !== undefined && scrollRef.current !== null) scrollRef.current.scrollTop = scrollTopFor(target.top)
262
+ setBlockSelection({ key: rowWindowKey, block })
263
+ }
264
+
265
+ const [sides, setSides] = useState<FileSides | null>(null)
266
+ // Set when the RPC itself failed — most plausibly a host half older than
267
+ // this client (the two halves reload on different cycles). The unified view
268
+ // still renders, so an old host costs the new pane, not the diff.
269
+ const [failed, setFailed] = useState(false)
270
+ // The block under the pointer, or null over context rows and gutters. Hover
271
+ // names the BLOCK, not the cell: the outline and the buttons belong to a
272
+ // whole run of rows, and a per-cell affordance would scatter them.
273
+ const [hotBlock, setHotBlock] = useState<number | null>(null)
274
+ // The block whose stage/unstage call is in flight, disabling its buttons.
275
+ const [pendingBlock, setPendingBlock] = useState<number | null>(null)
276
+ // The editable right column's state and its one save path. `saving` disables
277
+ // the controls for the call's duration; `saveFailed` carries a non-stale
278
+ // failure's sentence (the stale case is `edit.conflict`'s banner instead).
279
+ const [edit, setEdit] = useState<EditState>(DISARMED)
280
+ const [saving, setSaving] = useState(false)
281
+ const [saveFailed, setSaveFailed] = useState<{ title: string; detail: string } | null>(null)
282
+ // The layer a dirty-buffer tab switch is waiting on the reader to confirm.
283
+ const [pendingLayer, setPendingLayer] = useState<SideLayer | null>(null)
284
+ // Internal fetch generation: a stale save or the banner's reload refetch
285
+ // without waiting for the drawer's next refresh.
286
+ const [refetch, setRefetch] = useState(0)
287
+ // Which edit session the fetched payload belongs to, and what a landing
288
+ // payload may do with the buffer. The ref is read inside the fetch callback
289
+ // (which closes over a render that may be several states old by the time the
290
+ // answer arrives), and the adopt mode is consumed once by the next run.
291
+ const idRef = useRef('')
292
+ const adoptRef = useRef<'auto' | 'reload'>('auto')
293
+ const editRef = useRef(edit)
294
+ editRef.current = edit
295
+
296
+ // Switching tabs refetches: the two layers are different diffs of the same
297
+ // file, and neither is a transform of the other client-side. A change in the
298
+ // drawer's polled segment for this file refetches too — the poll's way of
299
+ // saying the file moved — which is what lets a change under a DIRTY buffer
300
+ // raise the banner within one poll interval instead of at the next refresh.
301
+ //
302
+ // Only a NEW file/layer/scope may blank the pane and disarm the editor; a
303
+ // refetch of the same identity keeps the current payload on screen until the
304
+ // answer lands, because blanking it would unmount the editor mid-keystroke.
305
+ useEffect(() => {
306
+ const id = `${scopeKey}\x1f${path}\x1f${layer}`
307
+ const identityChanged = idRef.current !== id
308
+ if (identityChanged) idRef.current = id
309
+ const adopt = identityChanged ? 'reset' : adoptRef.current
310
+ adoptRef.current = 'auto'
311
+ const ctrl = new AbortController()
312
+ let alive = true
313
+ if (identityChanged) {
314
+ setSides(null)
315
+ setSaveFailed(null)
316
+ setPendingLayer(null)
317
+ }
318
+ if (identityChanged || !editRef.current.armed) setFailed(false)
319
+ fetchSides(statsPath, path, layer, ctrl.signal)
320
+ .then(value => {
321
+ if (!alive) return
322
+ // While armed, a failed background refetch keeps the pane as it is:
323
+ // dropping the editor over a transient RPC failure would cost the
324
+ // buffer's DOM (focus, IME composition) for no reader benefit.
325
+ if (value === null) {
326
+ if (!editRef.current.armed) setFailed(true)
327
+ return
328
+ }
329
+ setSides(value)
330
+ setEdit(prev => adopt === 'reset' ? resetSides(prev, value)
331
+ : adopt === 'reload' ? reloadSides(prev, value)
332
+ : applySides(prev, value))
333
+ })
334
+ .catch(() => { if (alive && !editRef.current.armed) setFailed(true) })
335
+ return () => { alive = false; ctrl.abort() }
336
+ }, [fetchSides, statsPath, path, layer, scopeKey, gen, fallbackSegment, refetch])
337
+
338
+ const lang = shikiLangOf(path)
339
+ const shikiTheme = shikiThemeOf(palette)
340
+ const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
341
+ const rows = useMemo(() => {
342
+ if (sides === null || sides.diff.length === 0) return []
343
+ const file = parsePatch(sides.diff)
344
+ // A diff with no hunk (mode-only change, or text patch-model cannot parse)
345
+ // has no rows to align; the no-change treatment below is the honest view.
346
+ return file === null ? [] : alignRows(file)
347
+ }, [sides])
348
+ /**
349
+ * How many separate places this file changed — the count the nav walks.
350
+ * Rows change when a diff is fetched, not when the editor buffer changes, so
351
+ * caching here keeps the document-wide scan off the keystroke path.
352
+ */
353
+ const totalBlocks = useMemo(() => blockCount(rows), [rows])
354
+ // A toolbar rises above its block's first row. Horizontally scrolling columns
355
+ // clip overflow on both axes, so a file whose first row changed must reserve
356
+ // that space INSIDE each column. This depends on row shape, not hover, so the
357
+ // pointer cannot trigger a layout jump.
358
+ const blockBarClearance = needsFirstBlockClearance(rows) ? BLOCK_BAR_CLEARANCE : 0
359
+ const navOffset = DIFF_GRID_PAD_TOP + blockBarClearance
360
+ const alignedBlockTops = useMemo(
361
+ () => blockTopsFromRows(rows.map(row => row.block), DIFF_ROW_H, navOffset),
362
+ [rows, navOffset],
363
+ )
364
+ const editorBlockTops = useMemo(
365
+ () => blockTopsFromSideRows(rows, 'right', DIFF_ROW_H, navOffset),
366
+ [rows, navOffset],
367
+ )
368
+ // Highlight each column as one file — a row is not a program, and lexing
369
+ // fragments is what made the unified view paint keywords as plain text.
370
+ //
371
+ // These are UNDEFINED until a lazy grammar loads, and stay undefined for a
372
+ // file whose extension has no grammar at all (`go.mod`, `Dockerfile`), so
373
+ // every read below is optional-chained. `renderSideCode` already takes
374
+ // `undefined` and renders the plain text for it; what crashes is indexing
375
+ // the array itself, and `strict` is off in tsconfig, so the compiler will
376
+ // not say so.
377
+ // Only the rows the reader can see reach the DOM, and only they are re-lexed
378
+ // line by line. Declared here because both the render and the highlighting
379
+ // below are bounded by it.
380
+ const rowWindowKey = `${scopeKey}\x1f${path}\x1f${layer}\x1f${sides?.diffSha ?? ''}`
381
+ const win = useRowWindow(scrollRef, rows.length, rowWindowKey)
382
+ //
383
+ // Two passes with two lifetimes. The whole-file pass runs once per file and
384
+ // is what knows about block comments and template literals; the per-line
385
+ // re-lex — one Shiki call each, and the reason a 4,000-line file froze the
386
+ // pane for 2.8 seconds — runs only over the rows in the window, and so again
387
+ // whenever the reader scrolls.
388
+ const leftLines = useMemo(() => rows.map(row => row.left === null ? '' : row.left.text), [rows])
389
+ const rightLines = useMemo(() => rows.map(row => row.right === null ? '' : row.right.text), [rows])
390
+ const leftSyntax = useMemo(
391
+ () => highlightWindow(leftLines, lang, shikiTheme, win.start, win.end),
392
+ [leftLines, lang, shikiTheme, win.start, win.end, grammarGen],
393
+ )
394
+ const rightSyntax = useMemo(
395
+ () => highlightWindow(rightLines, lang, shikiTheme, win.start, win.end),
396
+ [rightLines, lang, shikiTheme, win.start, win.end, grammarGen],
397
+ )
398
+
399
+ /** The editor half of the pane, present only on the unstaged layer. */
400
+ const editable = layer === 'unstaged' && edit.armed
401
+ const dirty = isDirty(edit)
402
+ // Whether this payload may enter the editor at all: text carrying \r would
403
+ // be normalised to \n by the textarea the moment it landed, and the next
404
+ // save would rewrite every line ending in the file. The gate lives in
405
+ // `side-edit.ts` with the rest of the buffer's rules.
406
+ const armable = sides !== null && editableSides(sides)
407
+ // Which sentence the withheld editor gets: CRLF and a non-UTF-8 encoding are
408
+ // different problems, and one message for both leaves the reader guessing
409
+ // whether converting line endings would help.
410
+ const refusal = sides === null ? null : armRefusal(sides)
411
+
412
+
413
+ // The drawer guards every gesture that would drop the buffer — selecting
414
+ // another file, closing, switching the main tab — so it needs the flag as
415
+ // it changes, not at click time from a stale render. Reported on the FLAG
416
+ // (not the buffer) so it fires on the transitions that matter; the cleanup
417
+ // clears it when this pane unmounts, so no orphaned flag prompts later.
418
+ useEffect(() => {
419
+ onDirtyChange(dirty)
420
+ return () => { onDirtyChange(false) }
421
+ }, [dirty, onDirtyChange])
422
+ // The buffer's lines and their highlight, for the editor's underlay: the
423
+ // visible text under the transparent textarea, which is what keeps syntax
424
+ // coloring and the caret on the same grid while typing.
425
+ const bufferLines = useMemo(() => edit.buffer.split('\n'), [edit.buffer])
426
+ // What Tab inserts, learned from the file rather than configured. Keyed on
427
+ // the BASE text, not the buffer: re-detecting mid-edit would let a couple of
428
+ // freshly typed lines redefine the unit under the reader's hands.
429
+ const indentOfBuffer = useMemo(() => detectIndent(edit.baseText), [edit.baseText])
430
+ // The index side as one text, which is what the editor tints against while
431
+ // the reader types. It is the diff's own left column joined back up — every
432
+ // row of a full-context diff carries a left cell unless the line is an
433
+ // addition, which by definition is not on that side.
434
+ const indexText = useMemo(() => {
435
+ const left = rows.filter(row => row.left !== null).map(row => row.left!.text)
436
+ return left.length === 0 ? '' : left.join('\n') + '\n'
437
+ }, [rows])
438
+ // The editor's buffer is a whole file, so it takes the file pass rather than
439
+ // the diff's per-line re-lex — but only over the lines it is showing. The
440
+ // editor asks for a range as it scrolls, and `token-cache.ts` remembers what
441
+ // came back; a pass over the whole buffer was 1,637ms at 1,837 lines, paid
442
+ // again every time the reader stopped typing.
443
+ const editPaint = useMemo<PaintFn | null>(() => {
444
+ if (lang === undefined) return null
445
+ const key = 'buffer:' + statsPath + ':' + path
446
+ return (lines, from, to) => highlightRange(key, lines, lang, shikiTheme, from, to)
447
+ // `grammarGen` changes nothing computed here; the new identity is what
448
+ // makes the editor repaint once a lazy grammar has landed.
449
+ }, [lang, shikiTheme, statsPath, path, grammarGen])
450
+ // The left column while editing renders dense — one row per INDEX line, no
451
+ // holes — because the right column is now the dense buffer; a hole-aligned
452
+ // left beside a dense right is the alignment the diff view owes, not the
453
+ // editor. Each entry keeps its index into `rows` for its syntax tokens.
454
+ const leftRows = useMemo(() => rows.map((row, i) => ({ row, i })).filter(entry => entry.row.left !== null), [rows])
455
+
456
+ // Only the rows the reader can see reach the DOM. Two windows because the
457
+ // two columns render two different row lists while the editor is armed: the
458
+ // right side is a buffer, and the left side is then the index side DENSE,
459
+ // one row per index line rather than one per aligned row.
460
+ const leftWin = useRowWindow(scrollRef, leftRows.length, rowWindowKey)
461
+
462
+ // Arming drops the caret straight into the buffer: the click that armed the
463
+ // editor said "I want to type here", and a second click to focus is a tax.
464
+
465
+ /** Arm the editor from the payload on screen; the unstaged tab, and only
466
+ * for a payload `editableSides` accepts — armEdit itself refuses the rest,
467
+ * so even a stray call cannot put CRLF text into the buffer. */
468
+ const arm = (block?: number): void => {
469
+ if (sides === null || layer !== 'unstaged' || !editableSides(sides)) return
470
+ const viewport = scrollRef.current === null ? 0 : anchorFor(scrollRef.current.scrollTop)
471
+ setBlockSelection({ key: rowWindowKey, block: block ?? blockNearestTo(alignedBlockTops, viewport)?.block ?? 0 })
472
+ setEdit(prev => armEdit(prev, sides))
473
+ }
474
+
475
+ /**
476
+ * The one save path, shared by the Save button, Ctrl/Cmd+S and the banner's
477
+ * overwrite action — they differ only in WHICH sha the host is asked to
478
+ * check: the buffer's basis for a save, the file as it stands NOW for an
479
+ * explicit overwrite of a concurrent writer's version.
480
+ *
481
+ * On success the basis moves to the sha the host read back and the drawer
482
+ * refreshes (tree and pane together). On `stale` the banner goes up and the
483
+ * pane refetches WITHOUT touching the buffer, so the banner's reload and
484
+ * overwrite actions read the file's true current state. Everything else is
485
+ * a failed save with a sentence.
486
+ */
487
+ const runSave = async (expectedSha: string): Promise<void> => {
488
+ if (sides === null || !dirty || saving) return
489
+ const savedText = edit.buffer
490
+ // The edit session this save belongs to. A slow RPC can outlive a file or
491
+ // layer switch, and applying THIS save's outcome to the NEXT file's edit
492
+ // state would re-base that buffer onto text it never held — so every
493
+ // pane-local effect below is gated on the session still being current.
494
+ // The tree refresh on success is not: the file on disk did move.
495
+ const session = idRef.current
496
+ setSaving(true)
497
+ try {
498
+ const result = await writeChecked(statsPath, path, savedText, expectedSha, new AbortController().signal)
499
+ const stillHere = idRef.current === session
500
+ if (result === null) {
501
+ if (stillHere) setSaveFailed({ title: t('saveUnavailable'), detail: '' })
502
+ } else if (result.ok) {
503
+ if (stillHere) {
504
+ setSaveFailed(null)
505
+ setEdit(prev => applySaveOk(prev, savedText, result.sha ?? ''))
506
+ }
507
+ onSaved()
508
+ } else if (result.failure === 'stale') {
509
+ if (stillHere) {
510
+ setEdit(prev => markConflict(prev))
511
+ setRefetch(n => n + 1)
512
+ }
513
+ } else {
514
+ if (stillHere) setSaveFailed({ title: t('saveFailed'), detail: (result.error ?? '').trim() })
515
+ }
516
+ } finally {
517
+ setSaving(false)
518
+ }
519
+ }
520
+
521
+ /**
522
+ * The banner's two answers to a file that moved underneath. Overwrite may
523
+ * only run once the post-refusal refetch has landed (the fresh targetSha is
524
+ * what the host checks the overwrite against); until then the button waits,
525
+ * because re-sending the refused sha would just refuse again.
526
+ */
527
+ const canOverwrite = dirty && edit.conflict && sides !== null && sides.targetSha !== edit.baseSha
528
+ const overwrite = (): Promise<void> => sides === null ? Promise.resolve() : runSave(sides.targetSha)
529
+ /** Reload: the reader chose the file over the buffer; drop the edits. */
530
+ const reload = (): void => {
531
+ setSaveFailed(null)
532
+ adoptRef.current = 'reload'
533
+ setRefetch(n => n + 1)
534
+ }
535
+ /** Revert the buffer to its basis, in place; the conflict flag stands. */
536
+ const revert = (): void => {
537
+ setSaveFailed(null)
538
+ setEdit(prev => ({ ...prev, buffer: prev.baseText }))
539
+ }
540
+
541
+ /**
542
+ * A layer tab is one click away from dropping the buffer: with unsaved
543
+ * edits the click asks first, and only the dialog's answer switches.
544
+ */
545
+ const switchLayer = (next: SideLayer): void => {
546
+ if (next === layer) return
547
+ if (dirty) {
548
+ setPendingLayer(next)
549
+ return
550
+ }
551
+ setLayer(next)
552
+ }
553
+ const confirmLeave = (): void => {
554
+ const next = pendingLayer
555
+ setPendingLayer(null)
556
+ if (next !== null) setLayer(next)
557
+ }
558
+
559
+ /** Ctrl/Cmd+S inside the pane: the editor's other save affordance. */
560
+ const onPaneKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
561
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
562
+ event.preventDefault()
563
+ if (dirty && !saving) void runSave(edit.baseSha)
564
+ }
565
+ // F7 and Shift+F7, the spelling IDEA's diff viewer taught. Chosen over
566
+ // Alt+Arrow because CodeMirror's default keymap binds those to moving a
567
+ // line, and the armed editor lives inside this same pane.
568
+ if (event.key === 'F7') {
569
+ event.preventDefault()
570
+ goToChange(event.shiftKey ? -1 : 1)
571
+ }
572
+ }
573
+
574
+ /**
575
+ * One bubbling hover listener turns the cell under the pointer into its
576
+ * block id: every changed row's code cells carry `data-block`, so `closest`
577
+ * reads the block off whatever the pointer is over — no handler per cell,
578
+ * and a pointer over context or a gutter simply clears the hot block.
579
+ */
580
+ /**
581
+ * The divider: a ratio, not a pixel width, so the columns keep their
582
+ * proportion when the drawer itself is resized.
583
+ *
584
+ * Clamped well short of either edge — a column dragged to nothing looks
585
+ * like a broken pane, and there is no affordance to drag it back out of.
586
+ */
587
+ const onSplitDrag = (clientX: number): void => {
588
+ const box = colsRef.current?.getBoundingClientRect()
589
+ if (box === undefined || box.width === 0) return
590
+ const ratio = (clientX - box.left) / box.width
591
+ setSplit(Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, ratio)))
592
+ }
593
+
594
+ const onBodyHover = (event: ReactMouseEvent<HTMLDivElement>): void => {
595
+ const hit = (event.target as Element).closest('[data-block]')
596
+ const id = hit === null ? null : Number(hit.getAttribute('data-block'))
597
+ setHotBlock(prev => (prev === id ? prev : id))
598
+ }
599
+
600
+ const onBodySelect = (event: ReactMouseEvent<HTMLDivElement>): void => {
601
+ const hit = (event.target as Element).closest('[data-block]')
602
+ if (hit === null) return
603
+ const block = Number(hit.getAttribute('data-block'))
604
+ if (Number.isInteger(block) && block >= 0) setBlockSelection({ key: rowWindowKey, block })
605
+ }
606
+
607
+ /**
608
+ * Run one block action with the coordinates of the diff on screen.
609
+ *
610
+ * Discard never acts from the click — the drawer opens the confirmation,
611
+ * and the confirmed call carries this same snapshot, so a file that moved
612
+ * underneath the dialog is refused host-side rather than re-derived from
613
+ * whatever the poll has fetched since. Stage and unstage run now; the
614
+ * clicked block's buttons stay disabled until the answer lands, and the
615
+ * drawer's op lock refuses any other block click meanwhile.
616
+ */
617
+ const runBlock = async (mode: BlockMode, block: number): Promise<void> => {
618
+ if (sides === null) return
619
+ const ask: BlockAsk = {
620
+ path, layer, diffSha: sides.diffSha,
621
+ lines: blockLines(rows, block),
622
+ ...blockTally(rows, block),
623
+ wholeFile: blockIsWholeFile(rows, block),
624
+ }
625
+ if (mode === 'discard') {
626
+ void onBlockAction(mode, ask)
627
+ return
628
+ }
629
+ setPendingBlock(block)
630
+ try {
631
+ await onBlockAction(mode, ask)
632
+ } finally {
633
+ setPendingBlock(null)
634
+ }
635
+ }
636
+
637
+ /** Unstage the complete staged layer with the same stale-sha checked patch
638
+ * path as a hunk action. The scan happens only on this explicit click. */
639
+ const runAllBlocks = async (mode: 'unstage'): Promise<void> => {
640
+ if (sides === null) return
641
+ const ask: BlockAsk = {
642
+ path, layer, diffSha: sides.diffSha,
643
+ lines: allBlockLines(rows),
644
+ ...allBlockTally(rows),
645
+ wholeFile: false,
646
+ }
647
+ setPendingBlock(-1)
648
+ try {
649
+ await onBlockAction(mode, ask)
650
+ } finally {
651
+ setPendingBlock(null)
652
+ }
653
+ }
654
+
655
+ /** The pane the drawer had before this view existed, notice included. */
656
+ const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
657
+ ? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} />
658
+ : <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
659
+
660
+ if (failed) return unifiedFallback()
661
+ if (sides === null) return <div className={css.empty}>{t('loadingDiff')}</div>
662
+ if (sides.binary) return <div className={css.empty}>{t('binaryFile')}</div>
663
+ if (sides.tooLarge) {
664
+ return (
665
+ <>
666
+ <div className={css.sideNotice}>{t('diffTooLarge')}</div>
667
+ {unifiedFallback()}
668
+ </>
669
+ )
670
+ }
671
+ // The tabs are the pane's, not one layer's: an empty diff here (a fully
672
+ // staged file's unstaged side, a file with nothing staged) is one click from
673
+ // the other layer, and the Edit button still arms — the working tree is the
674
+ // edit target even when every change in it is already staged. So the
675
+ // no-change treatment below is a state of the BODY (`sideBodyState`), never
676
+ // an early return for the pane: returning here is what used to blank the
677
+ // tabs for exactly these files.
678
+ const bodyState = sideBodyState(rows, editable)
679
+ // The fixed toolbar exists for both layers: unstaged offers Stage/Revert,
680
+ // staged offers Unstage. Edit changes geometry and disabled state, not the
681
+ // existence of the escape route.
682
+ const changes = bodyState.kind === 'empty' ? 0 : totalBlocks
683
+ const selectedBlock = blockSelection.key === rowWindowKey ? blockSelection.block : 0
684
+ const currentBlock = currentActionBlock(totalBlocks, bodyState.kind !== 'empty', selectedBlock)
685
+ // The hovered block's first row hosts the action bar; a del-only block has
686
+ // no right cell, so its bar rides the left one instead. In the editor
687
+ // layout the left column is dense, so the bar rides its first left row.
688
+ const hotFirst = hotBlock === null ? -1 : rows.findIndex(row => row.block === hotBlock)
689
+ const hotFirstLeft = hotBlock === null ? -1 : leftRows.findIndex(entry => entry.row.block === hotBlock)
690
+ // Dirty buffer, disabled block actions: a patch computed from the loaded
691
+ // diff would land on top of edits the patch knows nothing about.
692
+ const barDisabled = blockActionsDisabled(dirty, pendingBlock)
693
+ const blockButtons = (block: number): ReactNode => layer === 'staged' ? (
694
+ <button
695
+ type="button"
696
+ className={css.blockBtn}
697
+ disabled={barDisabled}
698
+ title={dirty ? t('blockActionsDirty') : undefined}
699
+ onClick={() => { void runBlock('unstage', block) }}
700
+ >{t('blockUnstage')}</button>
701
+ ) : (
702
+ <>
703
+ <button
704
+ type="button"
705
+ className={css.blockBtn}
706
+ disabled={barDisabled}
707
+ title={dirty ? t('blockActionsDirty') : undefined}
708
+ onClick={() => { void runBlock('stage', block) }}
709
+ >{t('blockStage')}</button>
710
+ <button
711
+ type="button"
712
+ className={`${css.blockBtn} ${css.blockBtnDanger}`}
713
+ disabled={barDisabled}
714
+ title={dirty ? t('blockActionsDirty') : undefined}
715
+ onClick={() => { void runBlock('discard', block) }}
716
+ >{t('blockDiscard')}</button>
717
+ </>
718
+ )
719
+ const blockBar = (block: number): ReactNode => (
720
+ <span className={css.blockBar} style={{ top: -BLOCK_BAR_CLEARANCE }}>
721
+ {blockButtons(block)}
722
+ </span>
723
+ )
724
+ /** Clicking the working-tree column is the arm gesture readers will try
725
+ * first — unless the click was really a text selection, or landed on a
726
+ * block button, in which case it keeps its own meaning. */
727
+ const armFromCell = (event: ReactMouseEvent<HTMLSpanElement>): void => {
728
+ if (edit.armed) return
729
+ if ((event.target as Element).closest('button') !== null) return
730
+ const selection = window.getSelection()
731
+ if (selection !== null && !selection.isCollapsed) return
732
+ const block = Number(event.currentTarget.dataset.block)
733
+ arm(Number.isInteger(block) && block >= 0 ? block : undefined)
734
+ }
735
+ return (
736
+ /* `tabIndex={-1}` is what makes F7 reachable. The handler below is on
737
+ this element, so it only sees keys whose target is inside it — and
738
+ clicking diff text, which is not focusable, otherwise leaves focus on
739
+ the document body and the key never arrives. A negative index keeps the
740
+ pane out of the tab order while letting a click land focus here. */
741
+ <div className={css.sidePane} tabIndex={-1} onKeyDown={onPaneKeyDown}>
742
+ <div className={css.sideTabs}>
743
+ <button
744
+ type="button"
745
+ aria-pressed={layer === 'unstaged'}
746
+ className={layer === 'unstaged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
747
+ onClick={() => switchLayer('unstaged')}
748
+ >{t('tabUnstaged')}</button>
749
+ <button
750
+ type="button"
751
+ aria-pressed={layer === 'staged'}
752
+ className={layer === 'staged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
753
+ onClick={() => switchLayer('staged')}
754
+ >{t('tabStaged')}</button>
755
+ {/* A file whose whole delta is one line is unfindable by scrolling:
756
+ the tint only shows once you are already looking at it. The row
757
+ model knows where every change is, so these two say so. The count
758
+ is the other half of the answer — "there is one place to look" is
759
+ what stops the hunt. */}
760
+ {changes > 0 ? (
761
+ <span className={css.sideNav}>
762
+ <button
763
+ type="button"
764
+ className={css.blockBtn}
765
+ title={t('prevChangeHint')}
766
+ aria-label={t('prevChange')}
767
+ onClick={() => { goToChange(-1) }}
768
+ ><NavGlyph of="prev" /></button>
769
+ <button
770
+ type="button"
771
+ className={css.blockBtn}
772
+ title={t('nextChangeHint')}
773
+ aria-label={t('nextChange')}
774
+ onClick={() => { goToChange(1) }}
775
+ ><NavGlyph of="next" /></button>
776
+ <span className={css.sideNavCount}>{currentBlock === null
777
+ ? t('changeCount', { n: changes })
778
+ : t('changePosition', { current: currentBlock + 1, total: changes })}</span>
779
+ </span>
780
+ ) : null}
781
+ {currentBlock !== null ? (
782
+ <span className={css.sideCurrentBlockActions}>
783
+ {layer !== 'staged' || totalBlocks > 1 ? blockButtons(currentBlock) : null}
784
+ {layer === 'staged' ? (
785
+ <button
786
+ type="button"
787
+ className={css.blockBtn}
788
+ disabled={barDisabled}
789
+ onClick={() => { void runAllBlocks('unstage') }}
790
+ >{t('fileUnstage')}</button>
791
+ ) : null}
792
+ </span>
793
+ ) : null}
794
+ {/* Editing arms explicitly and saves explicitly — the two halves of
795
+ "never per keystroke". Save enables only while dirty; Revert drops
796
+ the buffer back onto its basis without touching the file. A
797
+ payload the CRLF gate refuses offers no Edit button at all — the
798
+ notice below says why rather than leaving a button that does
799
+ nothing. */}
800
+ {layer === 'unstaged' ? (
801
+ <span className={`${css.sideActions}${currentBlock !== null ? ` ${css.sideActionsAdjacent}` : ''}`}>
802
+ {edit.armed ? (
803
+ <>
804
+ <button
805
+ type="button"
806
+ className={`${css.blockBtn}${dirty ? ` ${css.sideSaveReady}` : ''}`}
807
+ disabled={!dirty || saving}
808
+ onClick={() => { void runSave(edit.baseSha) }}
809
+ >{t('fileSave')}</button>
810
+ <button
811
+ type="button"
812
+ className={css.blockBtn}
813
+ disabled={!dirty || saving}
814
+ onClick={revert}
815
+ >{t('fileRevert')}</button>
816
+ </>
817
+ ) : armable ? (
818
+ <button type="button" className={css.blockBtn} onClick={() => arm()}>{t('editFile')}</button>
819
+ ) : null}
820
+ </span>
821
+ ) : null}
822
+ </div>
823
+ {dirty ? <div className={css.sideNotice}>{t('editingNotice')}</div> : null}
824
+ {layer === 'unstaged' && refusal !== null && !edit.armed ? <div className={css.sideNotice}>{t(refusal === 'encoding' ? 'encodingNotice' : 'crlfNotice')}</div> : null}
825
+ {/* §4's row: the file moved underneath a dirty buffer — by the poll's
826
+ notice or by a refused save — and the reader chooses which version
827
+ survives. Overwrite waits for the refetch the refusal triggered, so
828
+ it is checked against the file as it truly stands. */}
829
+ {dirty && edit.conflict ? (
830
+ <div className={css.sideBanner} role="alert">
831
+ <span className={css.sideBannerTitle}>{t('staleTitle')}</span>
832
+ <span>{t('staleBody')}</span>
833
+ <span className={css.sideBannerActs}>
834
+ <button type="button" className={`${css.blockBtn} ${css.blockBtnDanger}`} disabled={saving} onClick={reload}>{t('staleReload')}</button>
835
+ <button type="button" className={css.blockBtn} disabled={!canOverwrite || saving} onClick={() => { void overwrite() }}>{t('staleOverwrite')}</button>
836
+ </span>
837
+ </div>
838
+ ) : null}
839
+ {saveFailed !== null ? (
840
+ <div className={css.sideBanner} role="alert">
841
+ <span className={css.sideBannerTitle}>{saveFailed.title}</span>
842
+ {saveFailed.detail.length > 0 ? <span>{saveFailed.detail}</span> : null}
843
+ <span className={css.sideBannerActs}>
844
+ <button type="button" className={css.blockBtn} disabled={!dirty || saving} onClick={() => { void runSave(edit.baseSha) }}>{t('saveRetry')}</button>
845
+ </span>
846
+ </div>
847
+ ) : null}
848
+ {/* Two columns that scroll sideways independently, with a divider the
849
+ reader can drag. One grid spanning both sides could not do this: its
850
+ tracks are sized by the widest line in the file, so a drag moved
851
+ nothing on exactly the wide files where the space matters. Vertical
852
+ alignment survives the split because both columns render one row per
853
+ aligned row at the same line height — the diff decides the rows, the
854
+ layout only decides how much width each side gets. */}
855
+ <div ref={scrollRef} className={css.sideScroll}>
856
+ {bodyState.kind === 'empty' ? (
857
+ <div className={css.empty}>{t('noTextDiff')}</div>
858
+ ) : (
859
+ <div
860
+ ref={colsRef}
861
+ className={css.sideCols}
862
+ onMouseOver={onBodyHover}
863
+ onMouseDown={onBodySelect}
864
+ onMouseLeave={() => { setHotBlock(null) }}
865
+ >
866
+ <div className={css.sideCol} style={{ flexBasis: `${split * 100}%`, paddingTop: blockBarClearance }}>
867
+ <div className={css.sideColGrid}>
868
+ {bodyState.kind === 'editor' ? (
869
+ /* While armed the left column renders the index side DENSE —
870
+ one row per index line, no diff holes — because the right
871
+ column is a buffer whose line count diverges from the diff
872
+ the moment a keystroke lands. */
873
+ <>
874
+ <RowSpacer height={leftWin.padTop} />
875
+ {leftRows.slice(leftWin.start, leftWin.end).map((entry, kk) => {
876
+ const k = leftWin.start + kk
877
+ const { row, i } = entry
878
+ const hot = hotBlock !== null && row.block === hotBlock
879
+ const current = row.block >= 0 && row.block === currentBlock
880
+ const hotClass = blockHotClass(rows, i, 'left', current)
881
+ return (
882
+ <Fragment key={`l${i}`}>
883
+ <span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left!.line}</span>
884
+ <span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.left !== null && row.block >= 0 ? row.block : undefined}>
885
+ {renderSideCode(row.left, leftSyntax?.[i])}
886
+ {hot && k === hotFirstLeft ? blockBar(row.block) : null}
887
+ </span>
888
+ </Fragment>
889
+ )
890
+ })}
891
+ <RowSpacer height={leftWin.padBottom} />
892
+ </>
893
+ ) : (
894
+ <>
895
+ <RowSpacer height={win.padTop} />
896
+ {rows.slice(win.start, win.end).map((row, k) => {
897
+ const i = win.start + k
898
+ const hot = hotBlock !== null && row.block === hotBlock
899
+ const current = row.block >= 0 && row.block === currentBlock
900
+ const hotClass = blockHotClass(rows, i, 'left', current)
901
+ // The block's action bar rides in this column only for a row
902
+ // with no right-hand side — a pure deletion, where the right
903
+ // column has no cell to hang it on.
904
+ const bar = hot && i === hotFirst && row.right === null ? blockBar(row.block) : null
905
+ return (
906
+ <Fragment key={i}>
907
+ <span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left === null ? '' : row.left.line}</span>
908
+ <span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.left !== null && row.block >= 0 ? row.block : undefined}>
909
+ {renderSideCode(row.left, leftSyntax?.[i])}
910
+ {bar}
911
+ </span>
912
+ </Fragment>
913
+ )
914
+ })}
915
+ <RowSpacer height={win.padBottom} />
916
+ </>
917
+ )}
918
+ </div>
919
+ </div>
920
+ <PaneDivider label={t('resizeSides')} onDrag={onSplitDrag} />
921
+ <div className={`${css.sideCol} ${css.sideColRight}`} style={{ paddingTop: blockBarClearance }}>
922
+ {bodyState.kind === 'editor' ? (
923
+ <CodeEditor
924
+ value={edit.buffer}
925
+ original={indexText}
926
+ onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
927
+ paint={editPaint}
928
+ indent={indentOfBuffer}
929
+ ariaLabel={path}
930
+ onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
931
+ />
932
+ ) : (
933
+ <div className={css.sideColGrid}>
934
+ <RowSpacer height={win.padTop} />
935
+ {rows.slice(win.start, win.end).map((row, k) => {
936
+ const i = win.start + k
937
+ const hot = hotBlock !== null && row.block === hotBlock
938
+ const current = row.block >= 0 && row.block === currentBlock
939
+ const hotClass = blockHotClass(rows, i, 'right', current)
940
+ const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
941
+ return (
942
+ <Fragment key={i}>
943
+ <span className={`${sideNumClass(row, 'right')}${hotClass}`}>{row.right === null ? '' : row.right.line}</span>
944
+ <span
945
+ className={`${css.sideCode} ${sideCodeClass(row, 'right')}${hotClass}${layer === 'unstaged' && armable ? ` ${css.sideArmable}` : ''}`}
946
+ data-block={row.right !== null && row.block >= 0 ? row.block : undefined}
947
+ onClick={layer === 'unstaged' && armable ? armFromCell : undefined}
948
+ >
949
+ {renderSideCode(row.right, rightSyntax?.[i])}
950
+ {bar}
951
+ </span>
952
+ </Fragment>
953
+ )
954
+ })}
955
+ <RowSpacer height={win.padBottom} />
956
+ </div>
957
+ )}
958
+ </div>
959
+ </div>
960
+ )}
961
+ </div>
962
+ {pendingLayer !== null ? (
963
+ <LeaveEditsConfirm t={t} path={path} onCancel={() => { setPendingLayer(null) }} onConfirm={confirmLeave} />
964
+ ) : null}
965
+ </div>
966
+ )
967
+ }
968
+
969
+ /**
970
+ * The unsaved-edits guard, rendered at both sites that defer a gesture on the
971
+ * buffer's answer: the pane's layer-tab switch, and the drawer level (file
972
+ * selection, main tab, source switch, close) for every gesture that would
973
+ * drop the buffer. Same reason as the roll-back confirmation — the click it
974
+ * answers to is one gesture away from losing work. Cancel holds the initial
975
+ * focus and Escape closes, because the default answer to losing edits is no.
976
+ */
977
+ export function LeaveEditsConfirm({ t, path, onCancel, onConfirm }: {
978
+ t: Translate
979
+ path: string
980
+ onCancel: () => void
981
+ onConfirm: () => void
982
+ }): ReactNode {
983
+ const stayRef = useRef<HTMLButtonElement>(null)
984
+ useEffect(() => { stayRef.current?.focus() }, [])
985
+ useEffect(() => {
986
+ // Capture phase, like the roll-back dialog: while a question about edits
987
+ // is open, Escape answers it and nothing else — consumed here, before it
988
+ // can reach the page's other Escape handlers (an open picker's dismiss,
989
+ // the commit box's undo).
990
+ const onKey = (event: KeyboardEvent): void => {
991
+ if (event.key !== 'Escape') return
992
+ event.stopPropagation()
993
+ onCancel()
994
+ }
995
+ window.addEventListener('keydown', onKey, true)
996
+ return () => { window.removeEventListener('keydown', onKey, true) }
997
+ }, [onCancel])
998
+ return (
999
+ <div className={css.confirmScrim} onClick={onCancel}>
1000
+ <div
1001
+ className={css.confirmBox}
1002
+ role="alertdialog"
1003
+ aria-modal="true"
1004
+ aria-label={t('unsavedTitle')}
1005
+ onClick={event => event.stopPropagation()}
1006
+ >
1007
+ <div className={css.confirmTitle}>{t('unsavedTitle')}</div>
1008
+ <div className={css.confirmBody}>{t('unsavedBody', { path })}</div>
1009
+ <div className={css.confirmActions}>
1010
+ <button ref={stayRef} type="button" className={css.btn} onClick={onCancel}>{t('unsavedStay')}</button>
1011
+ <button type="button" className={`${css.btn} ${css.btnDanger}`} onClick={onConfirm}>{t('unsavedLeave')}</button>
1012
+ </div>
1013
+ </div>
1014
+ </div>
1015
+ )
1016
+ }
1017
+
1018
+ /** Classes that paint only a block's OUTER perimeter. Internal rows carry the
1019
+ * vertical edges but no top/bottom line, avoiding the blue ladder a large
1020
+ * addition block used to draw. Absent side-cells return no class at all. */
1021
+ function blockHotClass(rows: readonly SideRow[], index: number, side: 'left' | 'right', hot: boolean): string {
1022
+ if (!hot) return ''
1023
+ const edge = blockEdge(rows, index, side)
1024
+ if (edge === null) return ''
1025
+ const first = edge === 'first' || edge === 'single' ? ` ${css.sideBlockHotFirst}` : ''
1026
+ const last = edge === 'last' || edge === 'single' ? ` ${css.sideBlockHotLast}` : ''
1027
+ return ` ${css.sideBlockHot}${first}${last}`
1028
+ }
1029
+
1030
+ /** Line-number cell class: a PRESENT cell of a changed row carries its side's
1031
+ * tint into the gutter; an absent one stays blank, the way a split diff shows
1032
+ * a one-sided change with an empty opposite pane rather than a tinted void. */
1033
+ function sideNumClass(row: SideRow, side: 'left' | 'right'): string {
1034
+ const cell = side === 'left' ? row.left : row.right
1035
+ if (cell === null || row.kind === 'same') return css.sideNum
1036
+ return `${css.sideNum} ${side === 'left' ? css.sideNumDel : css.sideNumAdd}`
1037
+ }
1038
+
1039
+ /** Code cell class: deletions tint left, additions right, context stays quiet. */
1040
+ function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
1041
+ const cell = side === 'left' ? row.left : row.right
1042
+ if (cell === null || row.kind === 'same') return css.sideCodeSame
1043
+ return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
1044
+ }
1045
+
1046
+ /** One cell's Shiki runs, or its plain text when no tokens exist. */
1047
+ function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
1048
+ if (cell === null) return ''
1049
+ if (tokens === undefined || tokens.length === 0) return cell.text
1050
+ if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return cell.text
1051
+ return tokens.map((tok, i) => (
1052
+ <span
1053
+ key={i}
1054
+ style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
1055
+ >{tok.text}</span>
1056
+ ))
1057
+ }
1058
+
1059
+
1060
+
1061
+ /**
1062
+ * The spacer standing in for the rows above or below the window.
1063
+ *
1064
+ * It spans every column of the grid, so a blame gutter does not change it.
1065
+ * @param height - px of rows it stands in for; nothing is rendered for 0.
1066
+ */
1067
+ function RowSpacer({ height }: { height: number }): ReactNode {
1068
+ if (height <= 0) return null
1069
+ return <span className={css.sideSpacer} style={{ height: `${height}px` }} aria-hidden="true" />
1070
+ }