@young1lin/dsh-ui-gitworkbench 0.1.4 → 0.1.6

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 (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/CHANGELOG_EN.md +42 -0
  3. package/lib/apply-blocks.js +159 -0
  4. package/lib/atomic-json.js +23 -5
  5. package/lib/blame.js +83 -0
  6. package/lib/client.js +34811 -11818
  7. package/lib/fs-remove.js +73 -0
  8. package/lib/git-ops.js +25 -0
  9. package/lib/image-sniff.js +197 -0
  10. package/lib/index.js +404 -32
  11. package/lib/patch-model.js +223 -0
  12. package/lib/side-guard.js +55 -0
  13. package/lib/write-checked.js +164 -0
  14. package/package.json +7 -1
  15. package/src/apply-blocks.ts +215 -0
  16. package/src/atomic-json.ts +29 -5
  17. package/src/blame.ts +94 -0
  18. package/src/client/CodeEditor.tsx +317 -0
  19. package/src/client/FileBrowser.tsx +657 -0
  20. package/src/client/GitWorkbenchPanel.module.css +453 -7
  21. package/src/client/GitWorkbenchPanel.tsx +1465 -166
  22. package/src/client/ImageView.tsx +120 -0
  23. package/src/client/blame-gutter.ts +108 -0
  24. package/src/client/blame-view.ts +104 -0
  25. package/src/client/cm-diff.ts +108 -0
  26. package/src/client/cm-tokens.ts +79 -0
  27. package/src/client/diff-nav.ts +198 -0
  28. package/src/client/discard-flow.ts +82 -0
  29. package/src/client/file-icon.ts +190 -0
  30. package/src/client/file-rows.ts +184 -0
  31. package/src/client/files-place.ts +178 -0
  32. package/src/client/glyphs.tsx +86 -0
  33. package/src/client/highlight.ts +25 -0
  34. package/src/client/idle-value.ts +53 -0
  35. package/src/client/image-view.ts +106 -0
  36. package/src/client/indent.ts +74 -0
  37. package/src/client/index.ts +76 -9
  38. package/src/client/locales.ts +171 -4
  39. package/src/client/pane-size.ts +71 -0
  40. package/src/client/side-edit.ts +244 -0
  41. package/src/client/side-rows.ts +258 -0
  42. package/src/client/stable-list.ts +31 -0
  43. package/src/client/use-change-nav.ts +83 -0
  44. package/src/client/worktree-view.ts +11 -1
  45. package/src/fs-remove.ts +76 -0
  46. package/src/git-ops.ts +36 -1
  47. package/src/image-sniff.ts +204 -0
  48. package/src/index.ts +450 -32
  49. package/src/patch-model.ts +267 -0
  50. package/src/side-guard.ts +58 -0
  51. package/src/write-checked.ts +223 -0
@@ -47,7 +47,7 @@
47
47
  * All copy resolves through the app's locale runtime (`t`), so the panel follows
48
48
  * the user's language preference.
49
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'
50
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type Dispatch, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type Ref, type SetStateAction } from 'react'
51
51
  import { createPortal } from 'react-dom'
52
52
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
53
53
  import {
@@ -56,10 +56,27 @@ import {
56
56
  type Appearance, type ColorMode, type StyleEntry, type StyleScope, type StyleSettings, type ThemeFamily,
57
57
  } from './themes.ts'
58
58
  import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
59
+ import { parsePatch } from '../patch-model.ts'
60
+ import { alignRows, blockCount, blockIsWholeFile, blockLines, blockTally, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
61
+ import { countBlocks, unifiedBlocks } from './diff-nav.ts'
62
+ import { clampPane, neighbourWidth } from './pane-size.ts'
63
+ import { useChangeNav } from './use-change-nav.ts'
64
+ import {
65
+ applySaveOk, applySides, armEdit, armRefusal, DISARMED, editableSides, gateLeave, isDirty,
66
+ LEAVE_GUARD_CLEAR, leaveAnswered, leaveAsked, markConflict, paneDirtyReport, reloadSides, resetSides,
67
+ type EditState, type LeaveGuard, type WriteResult,
68
+ } from './side-edit.ts'
69
+ import { FileBrowser } from './FileBrowser.tsx'
70
+ import { decodePlaces, encodePlaces, placeAt, withPlace, type FilesPlace, type FilesPlaces } from './files-place.ts'
71
+ import { HIGHLIGHT_IDLE_MS, HIGHLIGHT_LINE_CAP, useIdleValue } from './idle-value.ts'
72
+ import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
73
+ import { detectIndent } from './indent.ts'
74
+ import { CodeEditor } from './CodeEditor.tsx'
59
75
  import { layoutGraph, type GraphRow } from './commit-graph.ts'
60
76
  import { formatCommitDate } from './commit-filter.ts'
61
77
  import { chipsFromFilter, emptyQueryFilter, parseLogQuery, removeChip, serializeLogQuery } from './log-filter-query.ts'
62
78
  import { buildDirTree, searchPaths, type DirEntry } from './dir-tree.ts'
79
+ import { nextAfterPlan, type DiscardAnswer, type DiscardPreview } from './discard-flow.ts'
63
80
  import { filterFiles } from './file-filter.ts'
64
81
  import { addPath, buildIndex, checkedState, isCovered, removePath } from './path-select.ts'
65
82
  import { inCalRange, localTodayIso, monthGrid, weekdayLabels } from './calendar.ts'
@@ -70,8 +87,8 @@ import {
70
87
  fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
71
88
  type CheckState, type Tick, type TickAction,
72
89
  } from './stage-tree.ts'
73
- import { grammarLoadCount, highlightForRows, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
74
- import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
90
+ import { grammarLoadCount, highlightFile, highlightForRows, highlightWholeFile, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
91
+ import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
75
92
  import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
76
93
  import type { WorkbenchKey } from './locales.ts'
77
94
  import css from './GitWorkbenchPanel.module.css'
@@ -185,10 +202,12 @@ export interface SyncStatus {
185
202
  readonly hasRemote: boolean
186
203
  }
187
204
 
188
- /** Why a write operation failed, in terms the drawer can explain. */
205
+ /** Why a write operation failed, in terms the drawer can explain. `stale` is
206
+ * a sha the host re-derived and refused; `invalid` an argument combination
207
+ * the host rejected before running anything. */
189
208
  export type GitOpFailure =
190
209
  | 'auth' | 'network' | 'no-upstream' | 'diverged' | 'conflict'
191
- | 'nothing-to-commit' | 'dirty' | 'unknown'
210
+ | 'nothing-to-commit' | 'dirty' | 'stale' | 'invalid' | 'unknown'
192
211
 
193
212
  export interface GitOpResult {
194
213
  readonly ok: boolean
@@ -199,39 +218,145 @@ export interface GitOpResult {
199
218
  }
200
219
 
201
220
  /** The host endpoints under `gitWorkbench/` that change something. */
202
- export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile'
221
+ export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile' | 'applyBlocks'
203
222
 
204
223
  /** Extra arguments an operation needs beyond the worktree path. */
205
224
  export interface GitOpPayload {
206
225
  readonly paths?: readonly string[]
207
226
  readonly message?: string
208
227
  readonly amend?: boolean
209
- readonly mode?: 'ff-only' | 'rebase' | 'merge'
210
- /** `discardFile` only, and deliberately singular: the one irreversible thing
211
- * the drawer does takes one file per call, so a mistaken click costs one
212
- * file. */
228
+ /** `pull` picks how to integrate; `applyBlocks` which block mutation. One
229
+ * field serves both because the payload is a flat bag keyed by op — the
230
+ * host narrows and validates it per endpoint. */
231
+ readonly mode?: 'ff-only' | 'rebase' | 'merge' | BlockMode
232
+ /** `discardFile` and `applyBlocks`, and deliberately singular: the one
233
+ * irreversible thing the drawer does takes one file per call, so a mistaken
234
+ * click costs one file. */
213
235
  readonly path?: string
214
236
  /** `discardFile` only: the effect the confirmation stated. The host refuses
215
237
  * if the file changed underneath the dialog and now means something else. */
216
238
  readonly expectedEffect?: string
239
+ /** `applyBlocks` only: the layer whose diff the `diffSha` is over, and the
240
+ * block's hunk-line indices (`side-rows.blockLines`). The host re-fetches
241
+ * that layer's diff and refuses unless the sha still matches. */
242
+ readonly layer?: SideLayer
243
+ readonly diffSha?: string
244
+ readonly lines?: readonly number[]
217
245
  }
218
246
 
219
- /** What the host says rolling one file back would do. */
220
- export interface DiscardPreview {
221
- /** Absent when git reports nothing to roll back for that path. */
222
- readonly effect?: 'restore' | 'delete' | 'recover' | 'unrename'
223
- readonly irreversible?: boolean
224
- readonly previousPath?: string
247
+ export type { DiscardAnswer, DiscardNext, DiscardPreview } from './discard-flow.ts'
248
+ export type { WriteResult } from './side-edit.ts'
249
+
250
+ /** Which side of the index a side-by-side pane shows: `unstaged` is
251
+ * index→worktree (the editable side), `staged` is HEAD→index (read-only). */
252
+ export type SideLayer = 'unstaged' | 'staged'
253
+
254
+ /** A block mutation the side pane's buttons request: `stage` and `discard` act
255
+ * on the unstaged layer, `unstage` on the staged one. The host enforces the
256
+ * same matrix. */
257
+ export type BlockMode = 'stage' | 'unstage' | 'discard'
258
+
259
+ /**
260
+ * What one block action acts on, snapshotted from the diff the pane had
261
+ * rendered when the click (or its confirmation) happened.
262
+ *
263
+ * The snapshot is the point: `diffSha` proves the file has not changed since
264
+ * the pane rendered it, and `lines` — the block's hunk-line indices — only
265
+ * mean anything against exactly that diff. A confirmed roll-back carries the
266
+ * ask it opened with, so the answer cannot drift under the dialog.
267
+ */
268
+ export interface BlockAsk {
269
+ readonly path: string
270
+ readonly layer: SideLayer
271
+ readonly diffSha: string
272
+ readonly lines: readonly number[]
273
+ /** The block's line tallies, for the roll-back confirmation's wording. */
274
+ readonly added: number
275
+ readonly deleted: number
276
+ /** Whether the block is the file's entire content — the untracked case,
277
+ * whose roll-back DELETES the file and whose confirmation says so. */
278
+ readonly wholeFile: boolean
279
+ }
280
+
281
+ /**
282
+ * `gitWorkbench/fileSides`: one layer of one file for the side-by-side pane.
283
+ * Mirrors the host's `FileSides` (the client re-declares host shapes rather
284
+ * than importing the host module, which pulls node and the RPC decorators).
285
+ */
286
+ export interface FileSides {
287
+ /** Unified diff at full context; '' when the layer has no change. */
288
+ readonly diff: string
289
+ /** sha1 of `diff`, echoed back by mutations to prove the same snapshot. */
290
+ readonly diffSha: string
291
+ /** Whole right-hand text, the editor's initial buffer. */
292
+ readonly targetText: string
293
+ /** Blob sha of the right-hand side; '' when it does not exist. */
294
+ readonly targetSha: string
295
+ readonly binary: boolean
296
+ /** True when the file is past the size guard; the client shows the old view. */
297
+ readonly tooLarge: boolean
298
+ /** True when the working-tree file is not valid UTF-8; the pane shows the
299
+ * diff but withholds the editor. Optional so an older host half reads as
300
+ * "fine" rather than as a refusal this client cannot explain. */
301
+ readonly lossyEncoding?: boolean
302
+ }
303
+
304
+ /**
305
+ * `gitWorkbench/fileImage`: one working-tree file's bytes, when the host's
306
+ * signature check confirms they are an image a browser can draw.
307
+ *
308
+ * Every field is present in both outcomes — an image and a refusal — because
309
+ * the gateway's payloads carry no `undefined`. `reason` is '' exactly when
310
+ * `ok`, and names the refusal otherwise: 'notImage', 'tooLarge', 'missing'.
311
+ */
312
+ export interface FileImage {
313
+ readonly ok: boolean
314
+ /** MIME type to label the blob with; '' when declined. */
315
+ readonly mime: string
316
+ /** Short label for the caption — 'PNG', 'WebP', 'SVG'; '' when declined. */
317
+ readonly kind: string
318
+ /** The whole file, base64; '' when declined. */
319
+ readonly base64: string
320
+ /** The file's size in bytes, reported either way. */
321
+ readonly bytes: number
322
+ readonly reason: string
323
+ }
324
+
325
+ /** One line's provenance, as `gitWorkbench/blame` reports it. */
326
+ export interface BlameLine {
327
+ /** Full commit sha; all zeros for a line not committed yet. */
328
+ readonly hash: string
329
+ readonly author: string
330
+ /** Author time, unix seconds; 0 when git did not say. */
331
+ readonly time: number
332
+ readonly summary: string
333
+ readonly uncommitted: boolean
334
+ }
335
+
336
+ /** `gitWorkbench/blame`'s answer. `error` is present only on failure. */
337
+ export interface BlameAnswer {
338
+ readonly lines: readonly BlameLine[]
339
+ /** Whether the file was longer than the gutter's cap. */
340
+ readonly truncated: boolean
225
341
  readonly error?: string
226
342
  }
227
343
 
228
344
  /** Translate a key of this plugin's namespace, with optional `{name}` params. */
229
- type Translate = (key: string, params?: Record<string, string | number>) => string
345
+ export type Translate = (key: string, params?: Record<string, string | number>) => string
230
346
 
231
347
  type Props = PropsRuntime<'conversation.session.header.actions'> & {
232
348
  readonly t: Translate
233
349
  readonly fetchStats: (worktreePath: string | undefined, signal: AbortSignal) => Promise<WorkbenchStats | null>
234
350
  readonly fetchFileDiff: (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal) => Promise<string>
351
+ /** One layer of one file for the side-by-side diff pane. */
352
+ readonly fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
353
+ /** Save the editor buffer, checked against the sha it opened with. */
354
+ readonly writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
355
+ readonly fetchBlame: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<BlameAnswer | null>
356
+ /** One file's bytes, when they are an image. Null when the host half is
357
+ * older than this client: the view then falls back to the text answer. */
358
+ readonly fetchFileImage: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<FileImage | null>
359
+ /** Where the symbol at a zero-based protocol position is defined. */
235
360
  readonly fetchWorktreeStatus: (sessionId: string, repoPath: string | undefined, signal: AbortSignal) => Promise<WorktreeStatus | null>
236
361
  /** Binding only, no git — the probe the shut chip can afford to poll. */
237
362
  readonly fetchSessionBinding: (sessionId: string, signal: AbortSignal) => Promise<{ worktreePath: string | null; name: string | null } | null>
@@ -249,7 +374,7 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
249
374
  readonly runGitOp: (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal) => Promise<GitOpResult>
250
375
  /** What rolling this file back WOULD do, read fresh so the confirmation
251
376
  * states the real consequence rather than one derived from a polled row. */
252
- readonly fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardPreview | null>
377
+ readonly fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardAnswer>
253
378
  }
254
379
 
255
380
  /**
@@ -258,7 +383,7 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
258
383
  * modes with a back action, so returning to the working tree is always one click
259
384
  * (the pattern GitHub Desktop, VS Code and the JetBrains git tooling converge on).
260
385
  */
261
- type Tab = 'changes' | 'history' | 'compare'
386
+ type Tab = 'changes' | 'history' | 'compare' | 'files'
262
387
 
263
388
  /** How many further commits one page request loads. */
264
389
  const HISTORY_PAGE = 30
@@ -271,6 +396,11 @@ const MIN_DRAWER_WIDTH = 760
271
396
  const MIN_COMMITS_WIDTH = 190
272
397
  const MIN_TREE_WIDTH = 170
273
398
  const MIN_DIFF_WIDTH = 300
399
+ /** Floors for the History tab's horizontal split. The list keeps enough for a
400
+ * few rows to read as a list rather than as a strip; the half below keeps
401
+ * enough that the tree and the diff are still worth rendering. */
402
+ const MIN_COMMITS_HEIGHT = 90
403
+ const MIN_STACKED_LOWER = 200
274
404
 
275
405
  /** Longest edge a chosen background image is resampled to before storage. Past
276
406
  * this the file grows fast while a blurred backdrop gains nothing. */
@@ -289,14 +419,31 @@ const CUSTOM_STYLE_ID = 'dsh-ui-gitworkbench-custom-css'
289
419
  const STORE_APPEARANCE = 'dsh-ui-gitworkbench:appearance'
290
420
  const STORE_WIDTH = 'dsh-ui-gitworkbench:width'
291
421
  const STORE_PANES = 'dsh-ui-gitworkbench:panes'
422
+ /** Where the reader was in the Files tab, per worktree. View state, so it
423
+ * belongs here beside the layout rather than in the host's per-project store:
424
+ * two people on one repository should not share each other's place. */
425
+ const STORE_FILES = 'dsh-ui-gitworkbench:files'
292
426
 
293
- /** Dragged pane widths in px; null on either side keeps that pane's CSS default. */
427
+ /** Dragged pane sizes in px; null on any of them keeps that pane's CSS default. */
294
428
  interface PaneWidths {
295
429
  readonly commits: number | null
296
430
  readonly tree: number | null
431
+ /**
432
+ * The commit list's HEIGHT, which is what the History tab's divider moves
433
+ * now that the list spans the drawer's full width.
434
+ *
435
+ * Optional because storage is a durable boundary: values written before the
436
+ * tab was stacked have no such field, and rejecting them outright would
437
+ * throw away the column widths the reader had already chosen.
438
+ */
439
+ readonly commitsTall?: number | null
297
440
  }
298
441
 
299
- const DEFAULT_PANES: PaneWidths = { commits: null, tree: null }
442
+ /** The two panes whose WIDTH a divider drags. `commitsTall` is deliberately
443
+ * not one of them — it is a height, with its own floors. */
444
+ type PaneWidthKey = 'commits' | 'tree'
445
+
446
+ const DEFAULT_PANES: PaneWidths = { commits: null, tree: null, commitsTall: null }
300
447
 
301
448
  /**
302
449
  * @param value - value read back from storage.
@@ -304,9 +451,9 @@ const DEFAULT_PANES: PaneWidths = { commits: null, tree: null }
304
451
  */
305
452
  function isPaneWidths(value: unknown): value is PaneWidths {
306
453
  if (typeof value !== 'object' || value === null) return false
307
- const { commits, tree } = value as Partial<PaneWidths>
454
+ const { commits, tree, commitsTall } = value as Partial<PaneWidths>
308
455
  const ok = (v: unknown): boolean => v === null || (typeof v === 'number' && Number.isFinite(v))
309
- return ok(commits) && ok(tree)
456
+ return ok(commits) && ok(tree) && (commitsTall === undefined || ok(commitsTall))
310
457
  }
311
458
 
312
459
  /**
@@ -355,24 +502,26 @@ function writeStored(key: string, value: unknown): void {
355
502
  * crosses a child that stops propagation.
356
503
  * @returns the active flag for styling, and the pointerdown handler to attach.
357
504
  */
358
- function useHorizontalDrag(): {
505
+ function usePaneDrag(axis: 'x' | 'y' = 'x'): {
359
506
  dragging: boolean
360
- start: (event: ReactPointerEvent<HTMLElement>, onDrag: (clientX: number, done: boolean) => void) => void
507
+ start: (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void) => void
361
508
  } {
509
+ const along = (event: { clientX: number; clientY: number }): number =>
510
+ axis === 'y' ? event.clientY : event.clientX
362
511
  const [dragging, setDragging] = useState(false)
363
- const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (clientX: number, done: boolean) => void): void => {
512
+ const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void): void => {
364
513
  event.preventDefault()
365
514
  const handle = event.currentTarget
366
515
  handle.setPointerCapture(event.pointerId)
367
516
  setDragging(true)
368
- const onMove = (move: PointerEvent): void => { onDrag(move.clientX, false) }
517
+ const onMove = (move: PointerEvent): void => { onDrag(along(move), false) }
369
518
  // `pointercancel` ends a drag the browser took over (a touch became a
370
519
  // gesture, the window lost focus). It releases capture itself, so only the
371
520
  // pointerup path releases — and both must detach, or the next drag stacks a
372
521
  // second set of listeners on the same handle.
373
522
  const finish = (end: PointerEvent): void => {
374
523
  if (end.type === 'pointerup') {
375
- onDrag(end.clientX, true)
524
+ onDrag(along(end), true)
376
525
  handle.releasePointerCapture(end.pointerId)
377
526
  }
378
527
  handle.removeEventListener('pointermove', onMove)
@@ -393,22 +542,34 @@ function useHorizontalDrag(): {
393
542
  * @param onDrag - receives the pointer's x and whether the drag just ended.
394
543
  * @returns the divider element.
395
544
  */
396
- function PaneDivider({ label, onDrag }: {
545
+ function PaneDivider({ label, onDrag, axis = 'x' }: {
397
546
  label: string
398
- onDrag: (clientX: number, done: boolean) => void
547
+ /** clientX for an 'x' handle, clientY for a 'y' one. */
548
+ onDrag: (position: number, done: boolean) => void
549
+ /** Which way the handle moves. 'y' is the History tab's stacked split. */
550
+ axis?: 'x' | 'y'
399
551
  }): ReactNode {
400
- const { dragging, start } = useHorizontalDrag()
552
+ const { dragging, start } = usePaneDrag(axis)
553
+ const base = axis === 'y' ? `${css.paneDivider} ${css.paneDividerY}` : css.paneDivider
401
554
  return (
402
555
  <div
403
- className={dragging ? `${css.paneDivider} ${css.paneDividerActive}` : css.paneDivider}
556
+ className={dragging ? `${base} ${css.paneDividerActive}` : base}
404
557
  role="separator"
405
- aria-orientation="vertical"
558
+ // The orientation a separator reports is the axis it SEPARATES along,
559
+ // which is the opposite of the one it slides on.
560
+ aria-orientation={axis === 'y' ? 'horizontal' : 'vertical'}
406
561
  aria-label={label}
407
562
  onPointerDown={event => start(event, onDrag)}
408
563
  />
409
564
  )
410
565
  }
411
566
 
567
+ /** How narrow either side-by-side column may be dragged. A column pulled to
568
+ * nothing reads as a broken pane, and nothing on screen offers to pull it
569
+ * back out. */
570
+ const SPLIT_MIN = 0.15
571
+ const SPLIT_MAX = 0.85
572
+
412
573
  /** Commit change sets kept in the browser before the least recently used is dropped. */
413
574
  const COMMIT_CACHE_CAPACITY = 24
414
575
 
@@ -419,6 +580,19 @@ const EMPTY_STATS: WorkbenchStats = {
419
580
  files: [], diff: '', commits: [],
420
581
  }
421
582
 
583
+ /** How long the Files place must hold still before it is written. */
584
+ const PLACES_WRITE_MS = 500
585
+
586
+ /** One worktree's last-read file list. */
587
+ interface FilesTree {
588
+ readonly paths: readonly string[]
589
+ readonly truncated: boolean
590
+ }
591
+
592
+ /** A worktree nobody has opened the Files tab on yet. One instance, so an
593
+ * unvisited worktree does not re-render the browser on every pass. */
594
+ const EMPTY_TREE: FilesTree = { paths: [], truncated: false }
595
+
422
596
  /** The overlay with nothing on it — one instance, so an empty overlay never
423
597
  * re-renders the tree that receives it. */
424
598
  const EMPTY_TICKS: ReadonlyMap<string, TickAction> = new Map()
@@ -454,7 +628,7 @@ const STATUS_BADGE: Record<GitFileStatus, string> = {
454
628
  renamed: css.stRenamed, deleted: css.stDeleted,
455
629
  }
456
630
 
457
- export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchAuthors, fetchRepoTree, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp, fetchDiscardPlan }: Props) {
631
+ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchAuthors, fetchRepoTree, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp, fetchDiscardPlan }: Props) {
458
632
  const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
459
633
  state?.byId?.[sessionId]?.cwd) as string | undefined
460
634
  /** Whether the session's agent has a turn in flight — the store mirrors it
@@ -473,6 +647,34 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
473
647
  const [gen, setGen] = useState(0)
474
648
  /** Tree expansion state, session-lifetime: survives polls, source switches and drawer close/reopen. */
475
649
  const [collapsed, setCollapsed] = useState<Set<string> | undefined>(undefined)
650
+ // The Files tab's place and its last file list, ONE PER WORKTREE. Held here
651
+ // rather than in the browser because the browser unmounts whenever another
652
+ // tab is looked at: without this, coming back lost the selection and every
653
+ // expanded folder, which is the difference between a tab you return to and
654
+ // one you start over in.
655
+ //
656
+ // Per worktree because a worktree is a different place — different files, at
657
+ // different paths, and the open one may not exist in the next one at all.
658
+ // Keying the cached list too is what stops a switch from rendering the
659
+ // previous worktree's files until the new list arrives.
660
+ const [filesPlaces, setFilesPlaces] = useState<FilesPlaces>(
661
+ () => decodePlaces(readStored<unknown>(STORE_FILES, (value): value is unknown => true, null)),
662
+ )
663
+ const [filesTrees, setFilesTrees] = useState<ReadonlyMap<string, FilesTree>>(() => new Map())
664
+ const rememberPlace = useCallback((key: string, next: FilesPlace): void => {
665
+ setFilesPlaces(prev => withPlace(prev, key, next))
666
+ }, [])
667
+ const rememberTree = useCallback((key: string, next: FilesTree): void => {
668
+ setFilesTrees(prev => {
669
+ const map = new Map(prev)
670
+ map.set(key, next)
671
+ return map
672
+ })
673
+ }, [])
674
+ // Written on a pause, not on the change: expanding a folder and typing in
675
+ // the search box both move the place, and localStorage writes synchronously.
676
+ const settledPlaces = useIdleValue(filesPlaces, PLACES_WRITE_MS)
677
+ useEffect(() => { writeStored(STORE_FILES, encodePlaces(settledPlaces)) }, [settledPlaces])
476
678
  /** Binding + the repository's worktrees, null until the first successful fetch. */
477
679
  const [wtStatus, setWtStatus] = useState<WorktreeStatus | null>(null)
478
680
  /** Worktree the drawer reads, by absolute path. null = follow the session's own
@@ -843,8 +1045,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
843
1045
  setPendingTicks(EMPTY_TICKS)
844
1046
  setStats({
845
1047
  ...EMPTY_STATS,
846
- worktreePath: statsPath,
847
- branch: branchOfWorktree(statsPath, worktreesRef.current),
1048
+ // No source pinned and no session binding yet: the empty path is what
1049
+ // EMPTY_STATS already means by "nowhere", not a missing value.
1050
+ worktreePath: statsPath ?? '',
1051
+ branch: branchOfWorktree(statsPath, worktreesRef.current) ?? '',
848
1052
  })
849
1053
  }, [statsPath])
850
1054
 
@@ -860,8 +1064,8 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
860
1064
  const prevBinding = lastBindingRef.current
861
1065
  lastBindingRef.current = bindingPath
862
1066
  if (prevBinding === bindingPath) return
863
- const prevSource = prevBinding ?? worktreePath
864
- if (sourcePath !== null && prevSource.replace(/\\/g, '/') === sourcePath.replace(/\\/g, '/')) {
1067
+ const prevSource = prevBinding ?? worktreePath ?? null
1068
+ if (sourcePath !== null && prevSource !== null && prevSource.replace(/\\/g, '/') === sourcePath.replace(/\\/g, '/')) {
865
1069
  setSourcePath(null)
866
1070
  }
867
1071
  }, [bindingPath, sourcePath, worktreePath])
@@ -1020,9 +1224,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1020
1224
  // made the drawer shake on every tick — and a tick's outcome is already
1021
1225
  // visible in place, in the box the user just clicked. So: the old banner
1022
1226
  // stays while the op runs (it still describes the last outcome), a
1023
- // successful stage/unstage clears it rather than replacing it, and only
1024
- // heavy operations and failures announce themselves at all.
1025
- if (result.ok && (op === 'stage' || op === 'unstage')) setOpResult(null)
1227
+ // successful stage/unstage tick or block clears it rather than
1228
+ // replacing it (the block visibly leaves its layer on the refetch), and
1229
+ // only heavy operations and failures announce themselves at all.
1230
+ if (result.ok && (op === 'stage' || op === 'unstage' || op === 'applyBlocks')) setOpResult(null)
1026
1231
  else setOpResult({ op, result })
1027
1232
  return result
1028
1233
  } finally {
@@ -1032,6 +1237,19 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1032
1237
  }
1033
1238
  }
1034
1239
 
1240
+ /**
1241
+ * Put a failure the drawer produced itself into the same banner git failures
1242
+ * use.
1243
+ *
1244
+ * Roll-back is the caller: it asks the host what a file's roll-back would do
1245
+ * before it does anything, and that question can fail on its own, with no
1246
+ * `runOp` behind it to report through. Everything else the drawer does is
1247
+ * either a git call or has a visible result of its own.
1248
+ */
1249
+ const reportOpError = (op: GitOpName, error: string): void => {
1250
+ setOpResult({ op, result: { ok: false, failure: 'unknown', error } })
1251
+ }
1252
+
1035
1253
  /** Wait for the git lock, so a queued tick batch waits out a heavy
1036
1254
  * operation instead of being refused by it. */
1037
1255
  const waitNotBusy = async (): Promise<void> => {
@@ -1165,11 +1383,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1165
1383
  * @param measured - the drawer's inner width and the panes' current widths.
1166
1384
  * @param persist - whether to store it; false for intermediate drag frames.
1167
1385
  */
1168
- const applyPane = (which: keyof PaneWidths, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean): void => {
1386
+ const applyPane = (which: PaneWidthKey, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean): void => {
1169
1387
  const min = which === 'commits' ? MIN_COMMITS_WIDTH : MIN_TREE_WIDTH
1170
1388
  const other = which === 'commits' ? measured.tree : measured.commits
1171
- const max = Math.max(min, measured.drawer - other - MIN_DIFF_WIDTH)
1172
- const clamped = Math.min(Math.max(next, min), max)
1389
+ const clamped = clampPane(next, min, measured.drawer, other, MIN_DIFF_WIDTH)
1173
1390
  setPanes(prev => {
1174
1391
  const updated = { ...prev, [which]: clamped }
1175
1392
  if (persist) writeStored(STORE_PANES, updated)
@@ -1177,6 +1394,29 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1177
1394
  })
1178
1395
  }
1179
1396
 
1397
+ /**
1398
+ * Drag the History tab's horizontal split.
1399
+ *
1400
+ * Clamped the same way `applyPane` clamps a width, and for the same reason:
1401
+ * a pane dragged to nothing reads as broken and offers nothing to drag back
1402
+ * out. The outer `Math.max` keeps the range from inverting in a drawer too
1403
+ * short to hold both floors — the list then simply takes its minimum.
1404
+ *
1405
+ * @param next - the height in px the pointer implies.
1406
+ * @param bodyHeight - the space the two stacked halves share.
1407
+ * @param persist - false for every intermediate frame of a drag, so a
1408
+ * synchronous localStorage write never lands mid-resize.
1409
+ */
1410
+ const applyCommitsTall = (next: number, bodyHeight: number, persist: boolean): void => {
1411
+ const max = Math.max(MIN_COMMITS_HEIGHT, bodyHeight - MIN_STACKED_LOWER)
1412
+ const clamped = Math.min(Math.max(next, MIN_COMMITS_HEIGHT), max)
1413
+ setPanes(prev => {
1414
+ const updated = { ...prev, commitsTall: clamped }
1415
+ if (persist) writeStored(STORE_PANES, updated)
1416
+ return updated
1417
+ })
1418
+ }
1419
+
1180
1420
  /**
1181
1421
  * Drag the leading edge. Clamped at both ends — below the minimum the three
1182
1422
  * panes stop fitting, and past the viewport there is nothing to reveal. The
@@ -1293,6 +1533,7 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1293
1533
  onWidth={applyWidth}
1294
1534
  panes={panes}
1295
1535
  onPane={applyPane}
1536
+ onCommitsTall={applyCommitsTall}
1296
1537
  onClose={() => setOpen(false)}
1297
1538
  onRefresh={refresh}
1298
1539
  commitDraft={commitDraft}
@@ -1304,12 +1545,21 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1304
1545
  opResult={opResult}
1305
1546
  runOp={runOp}
1306
1547
  fetchDiscardPlan={fetchDiscardPlan}
1548
+ onOpError={reportOpError}
1307
1549
  pendingTicks={pendingTicks}
1308
1550
  onTick={queueTicks}
1309
1551
  fetchFileDiff={fetchDiffForView}
1552
+ fetchFileSides={fetchFileSides}
1553
+ writeChecked={writeChecked}
1554
+ fetchBlame={fetchBlame}
1555
+ fetchFileImage={fetchFileImage}
1310
1556
  viewKey={viewKey}
1311
1557
  gen={gen}
1312
1558
  collapsed={collapsed}
1559
+ filesPlaces={filesPlaces}
1560
+ onFilesPlace={rememberPlace}
1561
+ filesTrees={filesTrees}
1562
+ onFilesTree={rememberTree}
1313
1563
  onCollapsedChange={setCollapsed}
1314
1564
  />
1315
1565
  ) : null}
@@ -1345,6 +1595,26 @@ function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
1345
1595
  )
1346
1596
  }
1347
1597
 
1598
+ /**
1599
+ * Change-to-change navigation, as two chevrons.
1600
+ *
1601
+ * Bootstrap Icons again, at the same 16 viewBox — a pair of arrows is what
1602
+ * every editor spells this with, and the words would be longer than the
1603
+ * controls beside them.
1604
+ */
1605
+ const NAV_GLYPH = {
1606
+ 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',
1607
+ 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',
1608
+ } as const
1609
+
1610
+ function NavGlyph({ of }: { of: keyof typeof NAV_GLYPH }): ReactNode {
1611
+ return (
1612
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
1613
+ <path d={NAV_GLYPH[of]} />
1614
+ </svg>
1615
+ )
1616
+ }
1617
+
1348
1618
  /**
1349
1619
  * Tree glyph: a root with two working copies hanging off it.
1350
1620
  *
@@ -1474,7 +1744,9 @@ interface DrawerProps {
1474
1744
  onWidth: (next: number, persist: boolean) => void
1475
1745
  /** Dragged pane widths; null on either side keeps that pane's CSS default. */
1476
1746
  panes: PaneWidths
1477
- onPane: (which: keyof PaneWidths, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean) => void
1747
+ onPane: (which: PaneWidthKey, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean) => void
1748
+ /** Drag the History tab's horizontal split: the commit list's height in px. */
1749
+ onCommitsTall: (next: number, bodyHeight: number, persist: boolean) => void
1478
1750
  onClose: () => void
1479
1751
  onRefresh: () => void
1480
1752
  /** Commit draft, lifted so a tab switch cannot discard it. */
@@ -1495,21 +1767,33 @@ interface DrawerProps {
1495
1767
  /** The last write operation's outcome, or null once a new one starts. */
1496
1768
  opResult: { op: GitOpName; result: GitOpResult } | null
1497
1769
  runOp: (op: GitOpName, payload?: GitOpPayload) => Promise<GitOpResult>
1498
- fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardPreview | null>
1770
+ fetchDiscardPlan: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<DiscardAnswer>
1771
+ /** Say why an operation the drawer started did nothing. */
1772
+ onOpError: (op: GitOpName, error: string) => void
1499
1773
  /** Ticks awaiting their git call, keyed by path — overlaid over the file
1500
1774
  * list so the click is on screen before git confirms it. */
1501
1775
  pendingTicks: ReadonlyMap<string, TickAction>
1502
1776
  /** Queue the git calls for a tick batch. */
1503
1777
  onTick: (action: TickAction, paths: readonly string[]) => void
1504
1778
  fetchFileDiff: (path: string, signal: AbortSignal) => Promise<string>
1779
+ /** One layer of one file for the side-by-side pane; the drawer binds the source. */
1780
+ fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
1781
+ /** Save the side pane's editor buffer; the drawer binds the source. */
1782
+ writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
1783
+ fetchBlame: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<BlameAnswer | null>
1784
+ fetchFileImage: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<FileImage | null>
1505
1785
  /** Identifies the view the per-file diff cache belongs to (working tree, or one commit). */
1506
1786
  viewKey: string
1507
1787
  gen: number
1508
1788
  collapsed: Set<string> | undefined
1789
+ filesPlaces: FilesPlaces
1790
+ onFilesPlace: (key: string, next: FilesPlace) => void
1791
+ filesTrees: ReadonlyMap<string, FilesTree>
1792
+ onFilesTree: (key: string, next: FilesTree) => void
1509
1793
  onCollapsedChange: (next: Set<string>) => void
1510
1794
  }
1511
1795
 
1512
- function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, 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, fetchDiscardPlan, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }: DrawerProps): ReactNode {
1796
+ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, 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, onCommitsTall, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, viewKey, gen, collapsed, onCollapsedChange, filesPlaces, onFilesPlace, filesTrees, onFilesTree }: DrawerProps): ReactNode {
1513
1797
  // Empty stand-in while a commit's change set loads, so every hook below keeps a
1514
1798
  // stable shape and the panes simply render nothing.
1515
1799
  const body = shown ?? EMPTY_STATS
@@ -1530,6 +1814,20 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1530
1814
  () => tab === 'history' ? parseLogQuery(historyQuery).paths : NO_PATHS,
1531
1815
  [tab, historyQuery],
1532
1816
  )
1817
+ /** Working-tree files the browser can open on top of what `repoTree` knows:
1818
+ * `git ls-tree HEAD` cannot see an untracked file, and a browser that will
1819
+ * not open the file you just created reads as broken. A deleted file is
1820
+ * left out — opening it would only fail. */
1821
+ /** Which worktree the Files tab is remembering for. */
1822
+ const filesKey = pathKey(statsPath)
1823
+ const rememberPlaceHere = useCallback(
1824
+ (next: FilesPlace) => { onFilesPlace(filesKey, next) }, [onFilesPlace, filesKey])
1825
+ const rememberTreeHere = useCallback(
1826
+ (next: FilesTree) => { onFilesTree(filesKey, next) }, [onFilesTree, filesKey])
1827
+ const browsablePaths = useMemo(
1828
+ () => stats.files.filter(file => file.status !== 'deleted').map(file => file.path),
1829
+ [stats.files],
1830
+ )
1533
1831
  // A selection the current source no longer lists (e.g. after a source or tab
1534
1832
  // switch) falls back to the filtered file, else the first — never a dangling
1535
1833
  // highlight. See `active-file.ts` for the order and the reasoning.
@@ -1566,7 +1864,55 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1566
1864
  // Reset the on-demand cache when the generation (refresh) advances.
1567
1865
  useEffect(() => { setFetched(new Map()) }, [gen])
1568
1866
 
1569
- const selectAndReveal = (path: string): void => onSelect(path)
1867
+ // The side pane's dirty flag, reported upward: every gesture that would
1868
+ // drop the editor's buffer asks before it acts, and the layer tab is only
1869
+ // the rarest of them — clicking another file in the tree is this pane's
1870
+ // PRIMARY navigation. The guard is the little state machine in
1871
+ // side-edit.ts (gateLeave / leaveAsked / leaveAnswered / paneDirtyReport);
1872
+ // the PANE is the dirty flag's only writer, so the drawer never guesses
1873
+ // it — clearing the flag on a confirmed Leave is what let a no-op gesture
1874
+ // (the already-active tab, the already-shown file's row) disarm the guard
1875
+ // for every gesture after it.
1876
+ const [leaveGuard, setLeaveGuard] = useState<LeaveGuard>(LEAVE_GUARD_CLEAR)
1877
+ const [pendingLeave, setPendingLeave] = useState<(() => void) | null>(null)
1878
+ const onSideDirty = useCallback((dirty: boolean): void => {
1879
+ setLeaveGuard(prev => paneDirtyReport(prev, dirty))
1880
+ }, [])
1881
+ const guardLeave = (act: () => void, same: boolean): void => {
1882
+ const gate = gateLeave(leaveGuard, same)
1883
+ if (gate.kind === 'wait') return
1884
+ if (gate.kind === 'ask') {
1885
+ setPendingLeave(() => act)
1886
+ setLeaveGuard(leaveAsked)
1887
+ return
1888
+ }
1889
+ act()
1890
+ }
1891
+ const settleLeaveAsk = (): void => {
1892
+ setPendingLeave(null)
1893
+ setLeaveGuard(leaveAnswered)
1894
+ }
1895
+ const confirmDrawerLeave = (): void => {
1896
+ const act = pendingLeave
1897
+ if (act === null) return
1898
+ // Leave closes the ask and runs the gesture; the flag keeps the pane's
1899
+ // last report. A real navigation's reset reports clean on its own; a
1900
+ // no-op gesture never should have prompted, and its Leave leaves the
1901
+ // guard armed.
1902
+ settleLeaveAsk()
1903
+ act()
1904
+ }
1905
+ const closeDrawer = (): void => guardLeave(onClose, false)
1906
+ const leaveTab = (next: Tab): void => guardLeave(() => onSwitchTab(next), next === tab)
1907
+ // `active`, not `selected`: the pane's identity is the file it SHOWS, and
1908
+ // the preferred-file fallback can leave `selected` naming a file the pane
1909
+ // is not rendering — the row that changes nothing is the shown file's.
1910
+ const selectAndReveal = (path: string): void => guardLeave(() => onSelect(path), path === active)
1911
+ // The source picker swaps the whole worktree under the drawer — the buffer
1912
+ // belongs to a file the new source may not even list. Picking the source
1913
+ // already on screen changes nothing, so it runs unguarded like every other
1914
+ // no-op gesture.
1915
+ const leaveSource = (next: string): void => guardLeave(() => onSwitchSource(next), samePath(next, statsPath))
1570
1916
 
1571
1917
  /**
1572
1918
  * Roll-back, in two steps that are deliberately not one.
@@ -1581,27 +1927,23 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1581
1927
  * `recover` — a deleted file coming back — shows no dialog at all. It loses
1582
1928
  * nothing, and a confirmation in front of a pure gain is how people learn to
1583
1929
  * dismiss confirmations without reading them.
1930
+ *
1931
+ * Every other answer is `nextAfterPlan`'s to classify, and the one it exists
1932
+ * for is failure: a plan that never arrives reports, where it used to leave
1933
+ * the reader looking at a button that did nothing.
1584
1934
  */
1585
1935
  const askDiscard = (file: GitFile): void => {
1586
1936
  setDiscardPending({ file, plan: null })
1587
1937
  void (async () => {
1588
- const preview = await fetchDiscardPlan(statsPath, file.path, new AbortController().signal)
1589
- // Nothing to roll back means the row was stale — the tree lists only
1590
- // changed files, so a file with no change is one git has since seen
1591
- // settled. Refreshing is the honest answer: the row goes away, which is
1592
- // both the feedback and the fix. A banner saying "nothing happened"
1593
- // would leave the row that caused it sitting right there.
1594
- if (preview === null || preview.effect === undefined) {
1595
- setDiscardPending(null)
1596
- onRefresh()
1938
+ const next = nextAfterPlan(await fetchDiscardPlan(statsPath, file.path, new AbortController().signal))
1939
+ if (next.kind === 'confirm') {
1940
+ setDiscardPending({ file, plan: next.plan })
1597
1941
  return
1598
1942
  }
1599
- if (preview.irreversible !== true) {
1600
- setDiscardPending(null)
1601
- void runOp('discardFile', { path: file.path, expectedEffect: preview.effect })
1602
- return
1603
- }
1604
- setDiscardPending({ file, plan: preview })
1943
+ setDiscardPending(null)
1944
+ if (next.kind === 'run') void runOp('discardFile', { path: file.path, expectedEffect: next.effect })
1945
+ else if (next.kind === 'refresh') onRefresh()
1946
+ else onOpError('discardFile', next.error)
1605
1947
  })()
1606
1948
  }
1607
1949
 
@@ -1612,10 +1954,43 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1612
1954
  void runOp('discardFile', { path: pending.file.path, expectedEffect: pending.plan.effect })
1613
1955
  }
1614
1956
 
1957
+ /** The BLOCK roll-back being asked about, snapshotted at click time; null
1958
+ * while none is open. The confirmation states this ask, and the confirmed
1959
+ * call carries it verbatim — so if the file moves underneath the dialog,
1960
+ * the host's diffSha refusal is what stops the apply, not a re-derived
1961
+ * (different) block. */
1962
+ const [blockDiscard, setBlockDiscard] = useState<BlockAsk | null>(null)
1963
+
1964
+ /**
1965
+ * One block action from the side pane, routed the way `askDiscard` routes a
1966
+ * file's: stage and unstage are not destructive and run now, through the same
1967
+ * op machinery as every tick; discard is the irreversible one, so its click
1968
+ * only asks. The chain has no plan RPC to call — the consequence of reverting
1969
+ * THIS block's lines is fully stated by the pane's own rows — and the
1970
+ * "refuse if the answer changed" step is the host's stale check, which fires
1971
+ * on the diffSha the dialog was opened against.
1972
+ */
1973
+ const askBlockAction = (mode: BlockMode, ask: BlockAsk): Promise<GitOpResult> => {
1974
+ if (mode === 'discard') {
1975
+ setBlockDiscard(ask)
1976
+ return Promise.resolve({ ok: true })
1977
+ }
1978
+ return runOp('applyBlocks', { path: ask.path, layer: ask.layer, diffSha: ask.diffSha, lines: ask.lines, mode })
1979
+ }
1980
+
1981
+ const confirmBlockDiscard = (): void => {
1982
+ const ask = blockDiscard
1983
+ if (ask === null) return
1984
+ setBlockDiscard(null)
1985
+ void runOp('applyBlocks', { path: ask.path, layer: ask.layer, diffSha: ask.diffSha, lines: ask.lines, mode: 'discard' })
1986
+ }
1987
+
1615
1988
  const drawerRef = useRef<HTMLDivElement>(null)
1989
+ /** The two stacked halves' shared box, which the History split measures in. */
1990
+ const bodyRef = useRef<HTMLDivElement>(null)
1616
1991
  const commitsRef = useRef<HTMLDivElement>(null)
1617
1992
  const treeRef = useRef<HTMLDivElement>(null)
1618
- const edgeDrag = useHorizontalDrag()
1993
+ const edgeDrag = usePaneDrag()
1619
1994
 
1620
1995
  /**
1621
1996
  * What a pane drag is clamped against: the drawer's inner width and what the
@@ -1623,18 +1998,27 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1623
1998
  * resized since the last render.
1624
1999
  * @returns the three widths in px.
1625
2000
  */
1626
- const measurePanes = (): { drawer: number; commits: number; tree: number } => ({
1627
- drawer: drawerRef.current?.clientWidth ?? window.innerWidth,
1628
- commits: commitsRef.current?.getBoundingClientRect().width ?? 0,
1629
- tree: treeRef.current?.getBoundingClientRect().width ?? 0,
1630
- })
2001
+ const measurePanes = (): { drawer: number; commits: number; tree: number } => {
2002
+ const commits = commitsRef.current?.getBoundingClientRect() ?? null
2003
+ const tree = treeRef.current?.getBoundingClientRect() ?? null
2004
+ return {
2005
+ drawer: drawerRef.current?.clientWidth ?? window.innerWidth,
2006
+ // Each width counts only where the pane is actually IN THE WAY of the
2007
+ // other. Stacked, the commit list spans the drawer above the row rather
2008
+ // than sitting beside the tree, and taking its width as the tree's
2009
+ // neighbour made the ceiling smaller than the floor — the tree stayed at
2010
+ // its minimum whatever the pointer did.
2011
+ commits: neighbourWidth(commits, tree),
2012
+ tree: neighbourWidth(tree, commits),
2013
+ }
2014
+ }
1631
2015
 
1632
2016
  /**
1633
2017
  * @param which - the pane a divider resizes.
1634
2018
  * @param ref - that pane's element, whose left edge the width is measured from.
1635
2019
  * @returns a drag handler for {@link PaneDivider}.
1636
2020
  */
1637
- const paneDrag = (which: keyof PaneWidths, ref: { current: HTMLDivElement | null }) =>
2021
+ const paneDrag = (which: PaneWidthKey, ref: { current: HTMLDivElement | null }) =>
1638
2022
  (clientX: number, done: boolean): void => {
1639
2023
  const left = ref.current?.getBoundingClientRect().left
1640
2024
  if (left === undefined) return
@@ -1654,6 +2038,34 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1654
2038
  const paneStyle = (px: number | null): CSSProperties | undefined =>
1655
2039
  px === null ? undefined : { width: `${px}px`, maxWidth: 'none' }
1656
2040
 
2041
+ /**
2042
+ * The History tab's commit list, sized by height instead. `flex: none` so
2043
+ * the height is the height — a flex child in a column would otherwise
2044
+ * stretch or shrink away from it.
2045
+ *
2046
+ * The floor and the ceiling are NOT applied here. A stored height is a
2047
+ * number of pixels, and the drag that produced it clamped against the body
2048
+ * as it stood at that moment; the window can be made shorter afterwards,
2049
+ * and then a height that was reasonable becomes taller than everything.
2050
+ * The lower half collapses to nothing and the handle is pushed past the
2051
+ * bottom edge — there is no longer anything on screen to drag back, which
2052
+ * is the one failure mode a resizable split must not have. So the clamp
2053
+ * lives in the stylesheet, against `100%` of whatever the body currently
2054
+ * is, from the same two constants the drag clamp uses. The reader's chosen
2055
+ * height is kept, not rewritten: make the window tall again and it returns.
2056
+ */
2057
+ const paneTall = (px: number | null): CSSProperties | undefined =>
2058
+ px === null ? undefined : { height: `${px}px`, flex: 'none' }
2059
+
2060
+ /** The stacked split, measured from the top of the body rather than from the
2061
+ * list: the list's own top is what the drag is moving the bottom of, and
2062
+ * measuring from a moving edge makes the handle drift under the pointer. */
2063
+ const commitsTallDrag = (clientY: number, done: boolean): void => {
2064
+ const box = bodyRef.current?.getBoundingClientRect()
2065
+ if (box === undefined) return
2066
+ onCommitsTall(clientY - box.top, box.height, done)
2067
+ }
2068
+
1657
2069
  // Width and the background's three tunables are inline because both are live
1658
2070
  // user values; the stylesheet only says what reads them. The pane floors are
1659
2071
  // inline for a different reason: they belong to the drag clamp above, and
@@ -1665,6 +2077,8 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1665
2077
  '--gs-min-commits': `${MIN_COMMITS_WIDTH}px`,
1666
2078
  '--gs-min-tree': `${MIN_TREE_WIDTH}px`,
1667
2079
  '--gs-min-diff': `${MIN_DIFF_WIDTH}px`,
2080
+ '--gs-min-commits-tall': `${MIN_COMMITS_HEIGHT}px`,
2081
+ '--gs-min-stacked-lower': `${MIN_STACKED_LOWER}px`,
1668
2082
  } as CSSProperties,
1669
2083
  ...maximized || width === null ? {} : { width: `${width}px` },
1670
2084
  ...background === null ? {} : {
@@ -1679,7 +2093,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1679
2093
  className={maximized ? `${css.overlay} ${css.overlayMax}` : css.overlay}
1680
2094
  data-gs-theme={theme}
1681
2095
  data-gs-part="overlay"
1682
- onClick={onClose}
2096
+ onClick={closeDrawer}
1683
2097
  >
1684
2098
  <div
1685
2099
  ref={drawerRef}
@@ -1711,7 +2125,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1711
2125
  sessionPath={sessionPath}
1712
2126
  statsPath={statsPath}
1713
2127
  fallbackBranch={stats.branch}
1714
- onSwitch={onSwitchSource}
2128
+ onSwitch={leaveSource}
1715
2129
  />
1716
2130
  <Elided text={stats.worktreePath} className={css.headerPathMain} title={stats.worktreePath} />
1717
2131
  {tab === 'changes' && stats.detached ? <span className={css.headerDetached}>detached HEAD</span> : null}
@@ -1770,7 +2184,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1770
2184
  type="button"
1771
2185
  className={`${css.btn} ${css.btnIcon} ${css.btnClose}`}
1772
2186
  aria-label={t('close')} title={t('close')}
1773
- onClick={onClose}
2187
+ onClick={closeDrawer}
1774
2188
  ><ChromeGlyph of="close" /></button>
1775
2189
  </div>
1776
2190
  </div>
@@ -1780,22 +2194,29 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1780
2194
  role="tab"
1781
2195
  aria-selected={tab === 'changes'}
1782
2196
  className={tab === 'changes' ? `${css.tab} ${css.tabActive}` : css.tab}
1783
- onClick={() => onSwitchTab('changes')}
2197
+ onClick={() => leaveTab('changes')}
1784
2198
  >{t('tabChanges')}</button>
1785
2199
  <button
1786
2200
  type="button"
1787
2201
  role="tab"
1788
2202
  aria-selected={tab === 'history'}
1789
2203
  className={tab === 'history' ? `${css.tab} ${css.tabActive}` : css.tab}
1790
- onClick={() => onSwitchTab('history')}
2204
+ onClick={() => leaveTab('history')}
1791
2205
  >{t('tabHistory')}</button>
1792
2206
  <button
1793
2207
  type="button"
1794
2208
  role="tab"
1795
2209
  aria-selected={tab === 'compare'}
1796
2210
  className={tab === 'compare' ? `${css.tab} ${css.tabActive}` : css.tab}
1797
- onClick={() => onSwitchTab('compare')}
2211
+ onClick={() => leaveTab('compare')}
1798
2212
  >{t('tabCompare')}</button>
2213
+ <button
2214
+ type="button"
2215
+ role="tab"
2216
+ aria-selected={tab === 'files'}
2217
+ className={tab === 'files' ? `${css.tab} ${css.tabActive}` : css.tab}
2218
+ onClick={() => leaveTab('files')}
2219
+ >{t('tabFiles')}</button>
1799
2220
  </div>
1800
2221
  {tab === 'compare' ? (
1801
2222
  <CompareBar
@@ -1831,12 +2252,19 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1831
2252
  role="status"
1832
2253
  >{opMessage(t, opResult.op, opResult.result)}</div>
1833
2254
  ) : null}
1834
- <div className={css.body}>
2255
+ {/* History stacks: the commit list across the top, the tree and diff
2256
+ below it. Three columns left every one of them too narrow — the
2257
+ subjects, which are the reason to read a log at all, were the part
2258
+ that gave way — and the drawer's total width is fixed, so dragging
2259
+ a divider could only move the shortage somewhere else. Stacked, the
2260
+ subject gets the whole drawer, and the lower half becomes exactly
2261
+ the Changes tab's layout: one arrangement to learn, not two. */}
2262
+ <div ref={bodyRef} className={css.body} data-stacked={tab === 'history' ? '' : undefined}>
1835
2263
  {tab === 'history' ? (
1836
2264
  <>
1837
2265
  <CommitList
1838
2266
  paneRef={commitsRef}
1839
- style={paneStyle(panes.commits)}
2267
+ style={paneTall(panes.commitsTall ?? null)}
1840
2268
  t={t}
1841
2269
  loading={historyLoading}
1842
2270
  commits={commits}
@@ -1853,9 +2281,35 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1853
2281
  fetchAuthors={fetchAuthors}
1854
2282
  fetchRepoTree={fetchRepoTree}
1855
2283
  />
1856
- <PaneDivider label={t('resizeCommits')} onDrag={paneDrag('commits', commitsRef)} />
2284
+ <PaneDivider axis="y" label={t('resizeCommits')} onDrag={commitsTallDrag} />
1857
2285
  </>
1858
2286
  ) : null}
2287
+ <div className={css.bodyRow}>
2288
+ {tab === 'files' ? (
2289
+ <FileBrowser
2290
+ t={t}
2291
+ palette={theme}
2292
+ statsPath={statsPath}
2293
+ extraPaths={browsablePaths}
2294
+ gen={gen}
2295
+ treeStyle={paneStyle(panes.tree)}
2296
+ treeRef={treeRef}
2297
+ divider={<PaneDivider label={t('resizeTree')} onDrag={paneDrag('tree', treeRef)} />}
2298
+ place={placeAt(filesPlaces, filesKey)}
2299
+ onPlace={rememberPlaceHere}
2300
+ cached={filesTrees.get(filesKey) ?? EMPTY_TREE}
2301
+ onTree={rememberTreeHere}
2302
+ fetchRepoTree={fetchRepoTree}
2303
+ fetchFileSides={fetchFileSides}
2304
+ writeChecked={writeChecked}
2305
+ fetchBlame={fetchBlame}
2306
+ fetchFileImage={fetchFileImage}
2307
+ onSaved={onRefresh}
2308
+ onDirtyChange={onSideDirty}
2309
+ onShowHistory={query => { onHistoryQuery(query); leaveTab('history') }}
2310
+ />
2311
+ ) : (
2312
+ <>
1859
2313
  <div ref={treeRef} className={css.treeCol} style={paneStyle(panes.tree)} data-gs-part="tree">
1860
2314
  <FileTree
1861
2315
  t={t}
@@ -1911,24 +2365,65 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1911
2365
  {(shown === null && tab !== 'changes') || (tab === 'compare' && !comparable) ? null
1912
2366
  : activeFile !== null && activeFile.binary ? (
1913
2367
  <div className={css.empty}>{t('binaryFile')}</div>
2368
+ ) : tab === 'changes' && active !== null ? (
2369
+ <SideBySideView
2370
+ t={t}
2371
+ path={active}
2372
+ palette={theme}
2373
+ statsPath={statsPath}
2374
+ fetchSides={fetchFileSides}
2375
+ writeChecked={writeChecked}
2376
+ scopeKey={viewKey}
2377
+ gen={gen}
2378
+ fallbackSegment={segment}
2379
+ fallbackLoading={loading && segment.length === 0}
2380
+ onBlockAction={askBlockAction}
2381
+ onSaved={onRefresh}
2382
+ onDirtyChange={onSideDirty}
2383
+ />
1914
2384
  ) : loading && segment.length === 0 ? (
1915
2385
  <div className={css.empty}>{t('loadingDiff')}</div>
1916
2386
  ) : segment.length > 0 ? (
1917
- <DiffView segment={segment} path={active ?? ''} palette={theme} />
2387
+ <DiffView segment={segment} path={active ?? ''} palette={theme} t={t} />
1918
2388
  ) : (
1919
2389
  <div className={css.empty}>{t('noTextDiff')}</div>
1920
2390
  )}
1921
2391
  </div>
2392
+ </>
2393
+ )}
2394
+ </div>
1922
2395
  </div>
1923
2396
  {discardPending?.plan != null ? (
1924
2397
  <DiscardConfirm
1925
2398
  t={t}
1926
- file={discardPending.file}
1927
- plan={discardPending.plan}
2399
+ body={discardBodyText(t, discardPending.file, discardPending.plan)}
1928
2400
  onCancel={() => setDiscardPending(null)}
1929
2401
  onConfirm={confirmDiscard}
1930
2402
  />
1931
2403
  ) : null}
2404
+ {blockDiscard !== null ? (
2405
+ <DiscardConfirm
2406
+ t={t}
2407
+ body={blockDiscardBodyText(
2408
+ t,
2409
+ blockDiscard,
2410
+ body.files.find(file => file.path === blockDiscard.path),
2411
+ )}
2412
+ onCancel={() => setBlockDiscard(null)}
2413
+ onConfirm={confirmBlockDiscard}
2414
+ />
2415
+ ) : null}
2416
+ {/* The drawer-level unsaved-edits guard: the file being left is the
2417
+ one the reader was editing, and the deferred gesture — another
2418
+ file, another tab, closing — runs only on the dialog's answer. */}
2419
+ {pendingLeave !== null ? (
2420
+ <LeaveEditsConfirm
2421
+ t={t}
2422
+ path={active ?? ''}
2423
+ onCancel={settleLeaveAsk}
2424
+ onConfirm={confirmDrawerLeave}
2425
+ />
2426
+ ) : null}
1932
2427
  </div>
1933
2428
  </div>
1934
2429
  )
@@ -1937,28 +2432,28 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1937
2432
  /**
1938
2433
  * The one dialog in this drawer, because this is the one act it cannot undo.
1939
2434
  *
1940
- * It never asks a generic "are you sure": the body names the file and states
1941
- * which of the three consequences is about to happen, in the host's own reading
1942
- * of that file taken moments ago. Cancel holds the initial focus and Escape
1943
- * closes, because the default answer to an irreversible question is no.
2435
+ * It never asks a generic "are you sure": the caller hands it a body that names
2436
+ * the file and states which consequence is about to happen the whole-file
2437
+ * roll-back's wording derived from the host's own reading of that file, the
2438
+ * block roll-back's from the pane's rows. Cancel holds the initial focus and
2439
+ * Escape closes, because the default answer to an irreversible question is no.
1944
2440
  *
1945
2441
  * There is deliberately no "don't ask again". This is the only path in the
1946
2442
  * drawer with nothing behind it, and a checkbox whose whole function is to
1947
2443
  * switch off the last guard is a feature that eventually gets clicked.
1948
2444
  */
1949
- function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
2445
+ function DiscardConfirm({ t, body, onCancel, onConfirm }: {
1950
2446
  t: Translate
1951
- file: GitFile
1952
- plan: DiscardPreview
2447
+ body: string
1953
2448
  onCancel: () => void
1954
2449
  onConfirm: () => void
1955
2450
  }): ReactNode {
1956
2451
  const cancelRef = useRef<HTMLButtonElement>(null)
1957
2452
  useEffect(() => { cancelRef.current?.focus() }, [])
1958
2453
  useEffect(() => {
1959
- // Capture phase: the drawer's own Escape handler closes the whole drawer,
1960
- // and answering a question about deleting a file should not also dismiss
1961
- // the thing that asked it.
2454
+ // Capture phase: while this question is open, Escape belongs to it alone
2455
+ // consumed here, before it can reach the page's other Escape handlers
2456
+ // (an open picker's dismiss, the commit box's undo).
1962
2457
  const onKey = (event: KeyboardEvent): void => {
1963
2458
  if (event.key !== 'Escape') return
1964
2459
  event.stopPropagation()
@@ -1968,12 +2463,6 @@ function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
1968
2463
  return () => { window.removeEventListener('keydown', onKey, true) }
1969
2464
  }, [onCancel])
1970
2465
 
1971
- const body = plan.effect === 'delete'
1972
- ? t('discardBodyDelete', { path: file.path })
1973
- : plan.effect === 'unrename'
1974
- ? t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
1975
- : t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
1976
-
1977
2466
  return (
1978
2467
  <div className={css.confirmScrim} onClick={onCancel}>
1979
2468
  <div
@@ -1994,6 +2483,35 @@ function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
1994
2483
  )
1995
2484
  }
1996
2485
 
2486
+ /**
2487
+ * The whole-file roll-back's consequence, in the host's own fresh reading of
2488
+ * the file — the difference between "goes back to its committed content" and
2489
+ * "leaves the disk and cannot come back" is the entire question the dialog
2490
+ * asks, and it is exactly what a stale row gets wrong.
2491
+ */
2492
+ function discardBodyText(t: Translate, file: GitFile, plan: DiscardPreview): string {
2493
+ if (plan.effect === 'delete') return t('discardBodyDelete', { path: file.path })
2494
+ if (plan.effect === 'unrename') return t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
2495
+ return t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
2496
+ }
2497
+
2498
+ /**
2499
+ * The BLOCK roll-back's consequence, in the pane's own rows.
2500
+ *
2501
+ * One case outranks the tally wording: an untracked file's whole content is
2502
+ * the one block, and rolling THAT block back reverse-applies the new-file
2503
+ * patch — which deletes the file from the working tree, not rewrites it. The
2504
+ * file row's status is the gate (a tracked file whose every line changed has
2505
+ * the same block shape and only rewrites), which is why the ask's shape alone
2506
+ * is not enough.
2507
+ */
2508
+ function blockDiscardBodyText(t: Translate, ask: BlockAsk, file: GitFile | undefined): string {
2509
+ if (file !== undefined && file.status === 'untracked' && ask.wholeFile) {
2510
+ return t('blockDiscardBodyDelete', { path: ask.path })
2511
+ }
2512
+ return t('blockDiscardBody', { path: ask.path, added: ask.added, deleted: ask.deleted })
2513
+ }
2514
+
1997
2515
  /**
1998
2516
  * A slash-separated name that gives up its HEAD, never its tail.
1999
2517
  *
@@ -3000,9 +3518,17 @@ function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNod
3000
3518
  */
3001
3519
  /* ---------- commit graph ---------- */
3002
3520
 
3003
- /** Row height the graph and the list agree on. The lines only join up if every
3004
- * row is exactly as tall as the segment drawn for it. */
3005
- const GRAPH_ROW_H = 48
3521
+ /**
3522
+ * Row height the graph and the list agree on. The lines only join up if every
3523
+ * row is exactly as tall as the segment drawn for it, so this number and
3524
+ * `.commitLine`'s `height` are one fact with two homes —
3525
+ * `tests/commit-row-height.test.ts` holds them together.
3526
+ *
3527
+ * 28 since the History tab stacked: the list spans the drawer's full width, a
3528
+ * commit fits on one line, and 48px of row would spend the vertical space the
3529
+ * stacked layout has least of on empty padding.
3530
+ */
3531
+ const GRAPH_ROW_H = 28
3006
3532
  /** Horizontal distance between lanes. */
3007
3533
  const GRAPH_LANE_W = 14
3008
3534
  /** Ref chips shown inline before the subject; the rest collapse into a "+N". */
@@ -3155,11 +3681,12 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3155
3681
  onMouseEnter={show}
3156
3682
  onMouseLeave={hide}
3157
3683
  >
3158
- <span className={css.commitTop}>
3159
- <code className={css.commitHash}>{commit.hash}</code>
3160
- {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
3161
- <span className={css.commitWhen}>{commit.when}</span>
3162
- </span>
3684
+ {/* One line, in git log --oneline's order: a fixed-width hash, then
3685
+ the subject, then who and when pushed to the right. The hash
3686
+ being fixed width is what aligns every subject into a column the
3687
+ eye can run down — leading with the author's name instead would
3688
+ start each subject at a different place. */}
3689
+ <code className={css.commitHash}>{commit.hash}</code>
3163
3690
  <span className={css.commitSubjectRow}>
3164
3691
  {/* Capped at two. A release commit can carry six refs, and the
3165
3692
  subject is what the row is actually for — the rest are counted
@@ -3175,6 +3702,10 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3175
3702
  <span className={css.commitSubject}>{commit.subject}</span>
3176
3703
  {body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
3177
3704
  </span>
3705
+ <span className={css.commitMeta}>
3706
+ {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
3707
+ <span className={css.commitWhen}>{commit.when}</span>
3708
+ </span>
3178
3709
  </button>
3179
3710
  </div>
3180
3711
  {open && box !== null && host !== null ? createPortal(
@@ -3268,58 +3799,6 @@ function FilterCalendar({ year, month, after, before, locale, onPick, onShift }:
3268
3799
  )
3269
3800
  }
3270
3801
 
3271
- /**
3272
- * The two node glyphs, in IntelliJ's New UI icon idiom: a 16px grid, 1px
3273
- * strokes, no fill, rounded joins — outlines, where the old UI shipped filled
3274
- * silhouettes. Hand-drawn here rather than imported, because the bundle purity
3275
- * gate forbids an icon package and the drawer needs exactly these two; they
3276
- * are shapes in that language, not JetBrains' own assets.
3277
- *
3278
- * `strokeWidth` is 1 against a viewBox that renders 1:1 at 16px, so every
3279
- * stroke lands on a whole pixel instead of straddling two.
3280
- *
3281
- * Every place the drawer names a file or a directory uses these: the path
3282
- * picker in the history filter, and the file tree behind all three tabs. The
3283
- * CLASS names keep their `path` prefix — `scripts/verify_history_feature.py`
3284
- * selects the picker's file rows by `label:has([class*="pathFileGlyph"])`.
3285
- */
3286
- function PathDirGlyph(): ReactNode {
3287
- return (
3288
- <svg
3289
- className={css.pathDirGlyph}
3290
- width="16" height="16" viewBox="0 0 16 16"
3291
- fill="none" stroke="currentColor" strokeWidth="1"
3292
- strokeLinejoin="round" strokeLinecap="round"
3293
- aria-hidden="true"
3294
- >
3295
- {/* Body, with the tab stepping up over the left third. The step is a
3296
- full 2px: at 1.3px it read as a rounded rectangle with a nick in it
3297
- rather than a folder. Every straight edge sits on a .5 coordinate so
3298
- a 1px stroke lands on one pixel instead of straddling two. */}
3299
- <path d="M2.5 12.75V4.25A.75.75 0 0 1 3.25 3.5H6l1.6 2h5.15A.75.75 0 0 1 13.5 6.25v6.5a.75.75 0 0 1-.75.75H3.25a.75.75 0 0 1-.75-.75Z" />
3300
- </svg>
3301
- )
3302
- }
3303
-
3304
- function PathFileGlyph(): ReactNode {
3305
- return (
3306
- <svg
3307
- className={css.pathFileGlyph}
3308
- width="16" height="16" viewBox="0 0 16 16"
3309
- fill="none" stroke="currentColor" strokeWidth="1"
3310
- strokeLinejoin="round" strokeLinecap="round"
3311
- aria-hidden="true"
3312
- >
3313
- {/* Sheet, cut back at the top-right for the fold. Narrower and one step
3314
- taller than the folder, sharing its optical band, so the two never
3315
- look like different-sized icons in one column. */}
3316
- <path d="M3.5 12.75V3.25A.75.75 0 0 1 4.25 2.5H9l3.5 3.5v6.75a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75Z" />
3317
- {/* The fold itself — the corner turned back on the sheet. */}
3318
- <path d="M9 2.5v2.75a.75.75 0 0 0 .75.75h2.75" />
3319
- </svg>
3320
- )
3321
- }
3322
-
3323
3802
  /** Files shown per expanded directory. The search box is the way to a file in
3324
3803
  * a crowded directory; the tree shows enough to browse without flooding the
3325
3804
  * list, and says so when it cut the tail. */
@@ -3397,7 +3876,7 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
3397
3876
  <label key={file} className={css.funnelRow} style={{ paddingLeft: (depth + 1) * PATH_INDENT + 4 }}>
3398
3877
  <span className={css.funnelChevron} aria-hidden="true" />
3399
3878
  <TriStateCheckbox state={stateOf(`${dir.path}/${file}`)} ariaLabel={`${dir.path}/${file}`} onChange={() => onTogglePath(`${dir.path}/${file}`)} />
3400
- <PathFileGlyph />
3879
+ <PathFileGlyph path={`${dir.path}/${file}`} />
3401
3880
  <span className={css.funnelName} title={`${dir.path}/${file}`}>{file}</span>
3402
3881
  </label>
3403
3882
  ))}
@@ -3551,6 +4030,12 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3551
4030
  return () => { ctrl.abort() }
3552
4031
  }, [funnelOpen, statsPath, refName, fetchAuthors, fetchRepoTree])
3553
4032
 
4033
+ // Where the popover mounts: the drawer's overlay layer when there is one,
4034
+ // the body otherwise. Same resolution the commit-row popover does, and the
4035
+ // render below skips the portal when neither exists rather than handing
4036
+ // createPortal a null container.
4037
+ const funnelHost = funnelAnchorRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body)
4038
+
3554
4039
  /** Every funnel interaction writes the filter through the box's grammar, so
3555
4040
  * the box, the chips and the fetch can never disagree about the query. */
3556
4041
  const applyFilter = (next: LogFilter): void => { onQueryChange(serializeLogQuery(next)) }
@@ -3646,7 +4131,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3646
4131
  spellCheck={false}
3647
4132
  />
3648
4133
  </div>
3649
- {funnelOpen && funnelBox !== null ? createPortal(
4134
+ {funnelOpen && funnelBox !== null && funnelHost !== null ? createPortal(
3650
4135
  <div
3651
4136
  ref={funnelPanelRef}
3652
4137
  className={css.funnelPop}
@@ -3808,7 +4293,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3808
4293
  {hits.map(hit => (
3809
4294
  <label key={hit.path} className={css.funnelRow}>
3810
4295
  <TriStateCheckbox state={pathState(hit.path)} ariaLabel={hit.path} onChange={() => togglePath(hit.path)} />
3811
- {hit.isFile ? <PathFileGlyph /> : <PathDirGlyph />}
4296
+ {hit.isFile ? <PathFileGlyph path={hit.path} /> : <PathDirGlyph />}
3812
4297
  <span className={css.funnelName} title={hit.path}>{hit.path}</span>
3813
4298
  </label>
3814
4299
  ))}
@@ -3851,7 +4336,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3851
4336
  >{t('filterClearAll')}</button>
3852
4337
  </div>
3853
4338
  </div>,
3854
- funnelAnchorRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body),
4339
+ funnelHost,
3855
4340
  ) : null}
3856
4341
  {chips.length > 0 ? (
3857
4342
  <div className={css.filterChips}>
@@ -4344,7 +4829,7 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
4344
4829
  left-to-right as "what this is, then what happened to it",
4345
4830
  and the badge still lands in an aligned column — `.filePath`
4346
4831
  is the only flexible child. */}
4347
- <PathFileGlyph />
4832
+ <PathFileGlyph path={file.path} />
4348
4833
  <span className={css.filePath}>{basePart(file.path)}</span>
4349
4834
  {file.binary ? <span className={css.fileBinary}>BIN</span> : (
4350
4835
  <span className={css.fileCounts}>
@@ -4374,8 +4859,27 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
4374
4859
 
4375
4860
  /* ---------- diff rendering: rows, word-level ranges, syntax pass ---------- */
4376
4861
 
4377
- /** Render one file's unified-diff segment with word-level highlights and Shiki. */
4378
- function DiffView({ segment, path, palette }: { segment: string; path: string; palette: string }): ReactNode {
4862
+ /**
4863
+ * Render one file's unified-diff segment with word-level highlights and Shiki.
4864
+ *
4865
+ * This is what History and Compare show, and — unlike the side-by-side pane —
4866
+ * it has no row model carrying block ids, because nothing here acts on a block:
4867
+ * a commit's contents were decided long ago, so there is no staging and no
4868
+ * roll-back. The walk still needs them, so the runs are read off the row kinds
4869
+ * by `unifiedBlocks` and marked on the rows that scroll.
4870
+ *
4871
+ * The scroller is this component's own rather than the pane's. A bar that
4872
+ * scrolls away is not a control, and the pane scrolls in BOTH directions —
4873
+ * `sticky` fixes the vertical half and nothing fixes the horizontal one, since
4874
+ * a block child of a scroller is only ever as wide as the scrollport. A header
4875
+ * outside the scrolled box has neither problem.
4876
+ */
4877
+ function DiffView({ segment, path, palette, t }: {
4878
+ segment: string
4879
+ path: string
4880
+ palette: string
4881
+ t: Translate
4882
+ }): ReactNode {
4379
4883
  const lang = shikiLangOf(path)
4380
4884
  const shikiTheme = shikiThemeOf(palette)
4381
4885
  const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
@@ -4385,10 +4889,57 @@ function DiffView({ segment, path, palette }: { segment: string; path: string; p
4385
4889
  () => highlightForRows(rowsWithWords, lang, shikiTheme),
4386
4890
  [rowsWithWords, lang, shikiTheme, grammarGen],
4387
4891
  )
4892
+ const blocks = useMemo(() => unifiedBlocks(rowsWithWords.map(row => row.kind)), [rowsWithWords])
4893
+ const changes = useMemo(() => countBlocks(blocks), [blocks])
4894
+ const scrollRef = useRef<HTMLDivElement>(null)
4895
+ const { goToChange } = useChangeNav(scrollRef)
4896
+ // Read by the key listener below, which is attached once. `goToChange` only
4897
+ // ever touches refs, but pinning it here says so rather than relying on it.
4898
+ const walk = useRef(goToChange)
4899
+ walk.current = goToChange
4900
+
4901
+ // F7 / Shift+F7, the spelling IDEA's diff viewer taught, on the same element
4902
+ // that scrolls — so the key and the buttons cannot disagree about which pane
4903
+ // they move. `tabIndex` is what makes it able to receive the key at all:
4904
+ // diff text is not focusable, and a click on it would otherwise leave focus
4905
+ // on the document body.
4906
+ useEffect(() => {
4907
+ const scroller = scrollRef.current
4908
+ if (scroller === null) return
4909
+ const onKey = (event: KeyboardEvent): void => {
4910
+ if (event.key !== 'F7') return
4911
+ event.preventDefault()
4912
+ walk.current(event.shiftKey ? -1 : 1)
4913
+ }
4914
+ scroller.addEventListener('keydown', onKey)
4915
+ return () => { scroller.removeEventListener('keydown', onKey) }
4916
+ }, [])
4917
+
4388
4918
  return (
4919
+ <div className={css.diffWrap}>
4920
+ {changes > 0 ? (
4921
+ <div className={css.diffNav}>
4922
+ <button
4923
+ type="button"
4924
+ className={css.blockBtn}
4925
+ title={t('prevChangeHint')}
4926
+ aria-label={t('prevChange')}
4927
+ onClick={() => { goToChange(-1) }}
4928
+ ><NavGlyph of="prev" /></button>
4929
+ <button
4930
+ type="button"
4931
+ className={css.blockBtn}
4932
+ title={t('nextChangeHint')}
4933
+ aria-label={t('nextChange')}
4934
+ onClick={() => { goToChange(1) }}
4935
+ ><NavGlyph of="next" /></button>
4936
+ <span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
4937
+ </div>
4938
+ ) : null}
4939
+ <div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
4389
4940
  <pre className={css.diffPre}>
4390
4941
  {rowsWithWords.map((row, i) => (
4391
- <div key={i} className={`${css.line} ${rowClass(row.kind)}`}>
4942
+ <div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
4392
4943
  {sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
4393
4944
  {sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
4394
4945
  <span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
@@ -4398,6 +4949,8 @@ function DiffView({ segment, path, palette }: { segment: string; path: string; p
4398
4949
  </div>
4399
4950
  ))}
4400
4951
  </pre>
4952
+ </div>
4953
+ </div>
4401
4954
  )
4402
4955
  }
4403
4956
 
@@ -4423,6 +4976,752 @@ function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactN
4423
4976
  ))
4424
4977
  }
4425
4978
 
4979
+ /* ---------- side-by-side diff rendering (working tree only) ---------- */
4980
+
4981
+ /**
4982
+ * The working tree's per-file diff as IDEA shows it: one tab per layer of the
4983
+ * index, two columns with the whole file, aligned row by row.
4984
+ *
4985
+ * The rows come from `side-rows.ts` over the layer's full-context diff, so the
4986
+ * alignment is read off the diff rather than computed. A change block — a
4987
+ * maximal run of changed rows — carries its own actions: hovering any of its
4988
+ * cells outlines the whole block and floats its buttons (stage + roll back on
4989
+ * the unstaged tab, unstage on the staged one). The click carries the block's
4990
+ * hunk-line indices and the rendered diff's sha, so the host can prove the
4991
+ * file has not changed since the pane drew it.
4992
+ *
4993
+ * The unstaged tab's right column is also EDITABLE (the staged one is not, by
4994
+ * design: editing the index would mean writing a blob with no file behind it).
4995
+ * Editing arms explicitly — never per keystroke — and the buffer's whole life
4996
+ * against the file and the poll is `side-edit.ts`'s to decide: a refresh over
4997
+ * a dirty buffer keeps the buffer, a file that moved underneath raises the
4998
+ * reload-or-overwrite banner, and the one save path carries the sha the buffer
4999
+ * is based on so the host can refuse a stale write. While editing, the layout
5000
+ * trades the diff's hole-aligned grid for a dense editor column (same
5001
+ * metrics, same gutter rhythm): one grid cannot stay diff-aligned AND hold a
5002
+ * dense buffer whenever deletions outrun additions, and re-diffing per
5003
+ * keystroke is exactly the editor-library work the first cut declines.
5004
+ *
5005
+ * `tooLarge` and `binary` fall back to the unified view the pane already had
5006
+ * (history and compare keep it unconditionally), with a notice — a silently
5007
+ * different view reads as a broken one, not a guarded one.
5008
+ */
5009
+ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
5010
+ t: Translate
5011
+ path: string
5012
+ palette: string
5013
+ statsPath: string | undefined
5014
+ fetchSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
5015
+ /** Save the editor buffer; the host refuses a stale sha and nothing is written. */
5016
+ writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
5017
+ /** Names the view the fetch belongs to, as `viewKey` does for the diff cache. */
5018
+ scopeKey: string
5019
+ /** Refresh generation: a new one means the tree was re-read, so refetch. */
5020
+ gen: number
5021
+ /** The drawer's polled view of this file's HEAD-diff; a CHANGE in it means
5022
+ * the drawer noticed the file move, so the pane refetches even between
5023
+ * refresh generations — this is how the poll reaches a dirty buffer. */
5024
+ fallbackSegment: string
5025
+ fallbackLoading: boolean
5026
+ /** Run one block action; a discard routes to the drawer's confirmation. */
5027
+ onBlockAction: (mode: BlockMode, ask: BlockAsk) => Promise<GitOpResult>
5028
+ /** After a successful save: refresh the tree and the pane together. */
5029
+ onSaved: () => void
5030
+ /** Reports the buffer's dirty flag outward: the drawer guards every
5031
+ * gesture that would drop the buffer (file selection, close, main tab)
5032
+ * on it, so it must live where those gestures are handled. */
5033
+ onDirtyChange: (dirty: boolean) => void
5034
+ }): ReactNode {
5035
+ const [layer, setLayer] = useState<SideLayer>('unstaged')
5036
+ // How much of the pane the left column gets. Lives here rather than in the
5037
+ // drawer so it is one setting for the pane, and survives a file switch —
5038
+ // the reader sized the columns for how they read, not for one file.
5039
+ const [split, setSplit] = useState(0.5)
5040
+ const colsRef = useRef<HTMLDivElement>(null)
5041
+ /** The pane's one vertical scroller — what "next change" moves. */
5042
+ const scrollRef = useRef<HTMLDivElement>(null)
5043
+ const { goToChange } = useChangeNav(scrollRef)
5044
+
5045
+ const [sides, setSides] = useState<FileSides | null>(null)
5046
+ // Set when the RPC itself failed — most plausibly a host half older than
5047
+ // this client (the two halves reload on different cycles). The unified view
5048
+ // still renders, so an old host costs the new pane, not the diff.
5049
+ const [failed, setFailed] = useState(false)
5050
+ // The block under the pointer, or null over context rows and gutters. Hover
5051
+ // names the BLOCK, not the cell: the outline and the buttons belong to a
5052
+ // whole run of rows, and a per-cell affordance would scatter them.
5053
+ const [hotBlock, setHotBlock] = useState<number | null>(null)
5054
+ // The block whose stage/unstage call is in flight, disabling its buttons.
5055
+ const [pendingBlock, setPendingBlock] = useState<number | null>(null)
5056
+ // The editable right column's state and its one save path. `saving` disables
5057
+ // the controls for the call's duration; `saveFailed` carries a non-stale
5058
+ // failure's sentence (the stale case is `edit.conflict`'s banner instead).
5059
+ const [edit, setEdit] = useState<EditState>(DISARMED)
5060
+ const [saving, setSaving] = useState(false)
5061
+ const [saveFailed, setSaveFailed] = useState<{ title: string; detail: string } | null>(null)
5062
+ // The layer a dirty-buffer tab switch is waiting on the reader to confirm.
5063
+ const [pendingLayer, setPendingLayer] = useState<SideLayer | null>(null)
5064
+ // Internal fetch generation: a stale save or the banner's reload refetch
5065
+ // without waiting for the drawer's next refresh.
5066
+ const [refetch, setRefetch] = useState(0)
5067
+ // Which edit session the fetched payload belongs to, and what a landing
5068
+ // payload may do with the buffer. The ref is read inside the fetch callback
5069
+ // (which closes over a render that may be several states old by the time the
5070
+ // answer arrives), and the adopt mode is consumed once by the next run.
5071
+ const idRef = useRef('')
5072
+ const adoptRef = useRef<'auto' | 'reload'>('auto')
5073
+ const editRef = useRef(edit)
5074
+ editRef.current = edit
5075
+
5076
+ // Switching tabs refetches: the two layers are different diffs of the same
5077
+ // file, and neither is a transform of the other client-side. A change in the
5078
+ // drawer's polled segment for this file refetches too — the poll's way of
5079
+ // saying the file moved — which is what lets a change under a DIRTY buffer
5080
+ // raise the banner within one poll interval instead of at the next refresh.
5081
+ //
5082
+ // Only a NEW file/layer/scope may blank the pane and disarm the editor; a
5083
+ // refetch of the same identity keeps the current payload on screen until the
5084
+ // answer lands, because blanking it would unmount the editor mid-keystroke.
5085
+ useEffect(() => {
5086
+ const id = `${scopeKey}\x1f${path}\x1f${layer}`
5087
+ const identityChanged = idRef.current !== id
5088
+ if (identityChanged) idRef.current = id
5089
+ const adopt = identityChanged ? 'reset' : adoptRef.current
5090
+ adoptRef.current = 'auto'
5091
+ const ctrl = new AbortController()
5092
+ let alive = true
5093
+ if (identityChanged) {
5094
+ setSides(null)
5095
+ setSaveFailed(null)
5096
+ setPendingLayer(null)
5097
+ }
5098
+ if (identityChanged || !editRef.current.armed) setFailed(false)
5099
+ fetchSides(statsPath, path, layer, ctrl.signal)
5100
+ .then(value => {
5101
+ if (!alive) return
5102
+ // While armed, a failed background refetch keeps the pane as it is:
5103
+ // dropping the editor over a transient RPC failure would cost the
5104
+ // buffer's DOM (focus, IME composition) for no reader benefit.
5105
+ if (value === null) {
5106
+ if (!editRef.current.armed) setFailed(true)
5107
+ return
5108
+ }
5109
+ setSides(value)
5110
+ setEdit(prev => adopt === 'reset' ? resetSides(prev, value)
5111
+ : adopt === 'reload' ? reloadSides(prev, value)
5112
+ : applySides(prev, value))
5113
+ })
5114
+ .catch(() => { if (alive && !editRef.current.armed) setFailed(true) })
5115
+ return () => { alive = false; ctrl.abort() }
5116
+ }, [fetchSides, statsPath, path, layer, scopeKey, gen, fallbackSegment, refetch])
5117
+
5118
+ const lang = shikiLangOf(path)
5119
+ const shikiTheme = shikiThemeOf(palette)
5120
+ const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
5121
+ const rows = useMemo(() => {
5122
+ if (sides === null || sides.diff.length === 0) return []
5123
+ const file = parsePatch(sides.diff)
5124
+ // A diff with no hunk (mode-only change, or text patch-model cannot parse)
5125
+ // has no rows to align; the no-change treatment below is the honest view.
5126
+ return file === null ? [] : alignRows(file)
5127
+ }, [sides])
5128
+ // Highlight each column as one file — a row is not a program, and lexing
5129
+ // fragments is what made the unified view paint keywords as plain text.
5130
+ //
5131
+ // These are UNDEFINED until a lazy grammar loads, and stay undefined for a
5132
+ // file whose extension has no grammar at all (`go.mod`, `Dockerfile`), so
5133
+ // every read below is optional-chained. `renderSideCode` already takes
5134
+ // `undefined` and renders the plain text for it; what crashes is indexing
5135
+ // the array itself, and `strict` is off in tsconfig, so the compiler will
5136
+ // not say so.
5137
+ const leftSyntax = useMemo(
5138
+ () => highlightFile(rows.map(row => row.left === null ? '' : row.left.text), lang, shikiTheme),
5139
+ [rows, lang, shikiTheme, grammarGen],
5140
+ )
5141
+ const rightSyntax = useMemo(
5142
+ () => highlightFile(rows.map(row => row.right === null ? '' : row.right.text), lang, shikiTheme),
5143
+ [rows, lang, shikiTheme, grammarGen],
5144
+ )
5145
+
5146
+ /** The editor half of the pane, present only on the unstaged layer. */
5147
+ const editable = layer === 'unstaged' && edit.armed
5148
+ const dirty = isDirty(edit)
5149
+ // Whether this payload may enter the editor at all: text carrying \r would
5150
+ // be normalised to \n by the textarea the moment it landed, and the next
5151
+ // save would rewrite every line ending in the file. The gate lives in
5152
+ // `side-edit.ts` with the rest of the buffer's rules.
5153
+ const armable = sides !== null && editableSides(sides)
5154
+ // Which sentence the withheld editor gets: CRLF and a non-UTF-8 encoding are
5155
+ // different problems, and one message for both leaves the reader guessing
5156
+ // whether converting line endings would help.
5157
+ const refusal = sides === null ? null : armRefusal(sides)
5158
+
5159
+
5160
+ // The drawer guards every gesture that would drop the buffer — selecting
5161
+ // another file, closing, switching the main tab — so it needs the flag as
5162
+ // it changes, not at click time from a stale render. Reported on the FLAG
5163
+ // (not the buffer) so it fires on the transitions that matter; the cleanup
5164
+ // clears it when this pane unmounts, so no orphaned flag prompts later.
5165
+ useEffect(() => {
5166
+ onDirtyChange(dirty)
5167
+ return () => { onDirtyChange(false) }
5168
+ }, [dirty, onDirtyChange])
5169
+ // The buffer's lines and their highlight, for the editor's underlay: the
5170
+ // visible text under the transparent textarea, which is what keeps syntax
5171
+ // coloring and the caret on the same grid while typing.
5172
+ const bufferLines = useMemo(() => edit.buffer.split('\n'), [edit.buffer])
5173
+ // What Tab inserts, learned from the file rather than configured. Keyed on
5174
+ // the BASE text, not the buffer: re-detecting mid-edit would let a couple of
5175
+ // freshly typed lines redefine the unit under the reader's hands.
5176
+ const indentOfBuffer = useMemo(() => detectIndent(edit.baseText), [edit.baseText])
5177
+ // The index side as one text, which is what the editor tints against while
5178
+ // the reader types. It is the diff's own left column joined back up — every
5179
+ // row of a full-context diff carries a left cell unless the line is an
5180
+ // addition, which by definition is not on that side.
5181
+ const indexText = useMemo(() => {
5182
+ const left = rows.filter(row => row.left !== null).map(row => row.left!.text)
5183
+ return left.length === 0 ? '' : left.join('\n') + '\n'
5184
+ }, [rows])
5185
+ // The editor's buffer is a whole file, so it takes the whole-file pass —
5186
+ // and it lags the typing, for the same reason the browser's does: a Shiki
5187
+ // pass per keystroke is what makes a large file unusable to edit.
5188
+ const paintedLines = useIdleValue(bufferLines, HIGHLIGHT_IDLE_MS)
5189
+ const editSyntax = useMemo(
5190
+ () => edit.armed && paintedLines.length <= HIGHLIGHT_LINE_CAP
5191
+ ? highlightWholeFile(paintedLines, lang, shikiTheme)
5192
+ : [],
5193
+ [edit.armed, paintedLines, lang, shikiTheme, grammarGen],
5194
+ )
5195
+ // The left column while editing renders dense — one row per INDEX line, no
5196
+ // holes — because the right column is now the dense buffer; a hole-aligned
5197
+ // left beside a dense right is the alignment the diff view owes, not the
5198
+ // editor. Each entry keeps its index into `rows` for its syntax tokens.
5199
+ const leftRows = useMemo(() => rows.map((row, i) => ({ row, i })).filter(entry => entry.row.left !== null), [rows])
5200
+
5201
+ // Arming drops the caret straight into the buffer: the click that armed the
5202
+ // editor said "I want to type here", and a second click to focus is a tax.
5203
+
5204
+ /** Arm the editor from the payload on screen; the unstaged tab, and only
5205
+ * for a payload `editableSides` accepts — armEdit itself refuses the rest,
5206
+ * so even a stray call cannot put CRLF text into the buffer. */
5207
+ const arm = (): void => {
5208
+ if (sides === null || layer !== 'unstaged' || !editableSides(sides)) return
5209
+ setEdit(prev => armEdit(prev, sides))
5210
+ }
5211
+
5212
+ /**
5213
+ * The one save path, shared by the Save button, Ctrl/Cmd+S and the banner's
5214
+ * overwrite action — they differ only in WHICH sha the host is asked to
5215
+ * check: the buffer's basis for a save, the file as it stands NOW for an
5216
+ * explicit overwrite of a concurrent writer's version.
5217
+ *
5218
+ * On success the basis moves to the sha the host read back and the drawer
5219
+ * refreshes (tree and pane together). On `stale` the banner goes up and the
5220
+ * pane refetches WITHOUT touching the buffer, so the banner's reload and
5221
+ * overwrite actions read the file's true current state. Everything else is
5222
+ * a failed save with a sentence.
5223
+ */
5224
+ const runSave = async (expectedSha: string): Promise<void> => {
5225
+ if (sides === null || !dirty || saving) return
5226
+ const savedText = edit.buffer
5227
+ // The edit session this save belongs to. A slow RPC can outlive a file or
5228
+ // layer switch, and applying THIS save's outcome to the NEXT file's edit
5229
+ // state would re-base that buffer onto text it never held — so every
5230
+ // pane-local effect below is gated on the session still being current.
5231
+ // The tree refresh on success is not: the file on disk did move.
5232
+ const session = idRef.current
5233
+ setSaving(true)
5234
+ try {
5235
+ const result = await writeChecked(statsPath, path, savedText, expectedSha, new AbortController().signal)
5236
+ const stillHere = idRef.current === session
5237
+ if (result === null) {
5238
+ if (stillHere) setSaveFailed({ title: t('saveUnavailable'), detail: '' })
5239
+ } else if (result.ok) {
5240
+ if (stillHere) {
5241
+ setSaveFailed(null)
5242
+ setEdit(prev => applySaveOk(prev, savedText, result.sha ?? ''))
5243
+ }
5244
+ onSaved()
5245
+ } else if (result.failure === 'stale') {
5246
+ if (stillHere) {
5247
+ setEdit(prev => markConflict(prev))
5248
+ setRefetch(n => n + 1)
5249
+ }
5250
+ } else {
5251
+ if (stillHere) setSaveFailed({ title: t('saveFailed'), detail: (result.error ?? '').trim() })
5252
+ }
5253
+ } finally {
5254
+ setSaving(false)
5255
+ }
5256
+ }
5257
+
5258
+ /**
5259
+ * The banner's two answers to a file that moved underneath. Overwrite may
5260
+ * only run once the post-refusal refetch has landed (the fresh targetSha is
5261
+ * what the host checks the overwrite against); until then the button waits,
5262
+ * because re-sending the refused sha would just refuse again.
5263
+ */
5264
+ const canOverwrite = dirty && edit.conflict && sides !== null && sides.targetSha !== edit.baseSha
5265
+ const overwrite = (): Promise<void> => sides === null ? Promise.resolve() : runSave(sides.targetSha)
5266
+ /** Reload: the reader chose the file over the buffer; drop the edits. */
5267
+ const reload = (): void => {
5268
+ setSaveFailed(null)
5269
+ adoptRef.current = 'reload'
5270
+ setRefetch(n => n + 1)
5271
+ }
5272
+ /** Revert the buffer to its basis, in place; the conflict flag stands. */
5273
+ const revert = (): void => {
5274
+ setSaveFailed(null)
5275
+ setEdit(prev => ({ ...prev, buffer: prev.baseText }))
5276
+ }
5277
+
5278
+ /**
5279
+ * A layer tab is one click away from dropping the buffer: with unsaved
5280
+ * edits the click asks first, and only the dialog's answer switches.
5281
+ */
5282
+ const switchLayer = (next: SideLayer): void => {
5283
+ if (next === layer) return
5284
+ if (dirty) {
5285
+ setPendingLayer(next)
5286
+ return
5287
+ }
5288
+ setLayer(next)
5289
+ }
5290
+ const confirmLeave = (): void => {
5291
+ const next = pendingLayer
5292
+ setPendingLayer(null)
5293
+ if (next !== null) setLayer(next)
5294
+ }
5295
+
5296
+ /** Ctrl/Cmd+S inside the pane: the editor's other save affordance. */
5297
+ const onPaneKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
5298
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
5299
+ event.preventDefault()
5300
+ if (dirty && !saving) void runSave(edit.baseSha)
5301
+ }
5302
+ // F7 and Shift+F7, the spelling IDEA's diff viewer taught. Chosen over
5303
+ // Alt+Arrow because CodeMirror's default keymap binds those to moving a
5304
+ // line, and the armed editor lives inside this same pane.
5305
+ if (event.key === 'F7') {
5306
+ event.preventDefault()
5307
+ goToChange(event.shiftKey ? -1 : 1)
5308
+ }
5309
+ }
5310
+
5311
+ /**
5312
+ * One bubbling hover listener turns the cell under the pointer into its
5313
+ * block id: every changed row's code cells carry `data-block`, so `closest`
5314
+ * reads the block off whatever the pointer is over — no handler per cell,
5315
+ * and a pointer over context or a gutter simply clears the hot block.
5316
+ */
5317
+ /**
5318
+ * The divider: a ratio, not a pixel width, so the columns keep their
5319
+ * proportion when the drawer itself is resized.
5320
+ *
5321
+ * Clamped well short of either edge — a column dragged to nothing looks
5322
+ * like a broken pane, and there is no affordance to drag it back out of.
5323
+ */
5324
+ const onSplitDrag = (clientX: number): void => {
5325
+ const box = colsRef.current?.getBoundingClientRect()
5326
+ if (box === undefined || box.width === 0) return
5327
+ const ratio = (clientX - box.left) / box.width
5328
+ setSplit(Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, ratio)))
5329
+ }
5330
+
5331
+ const onBodyHover = (event: ReactMouseEvent<HTMLDivElement>): void => {
5332
+ const hit = (event.target as Element).closest('[data-block]')
5333
+ const id = hit === null ? null : Number(hit.getAttribute('data-block'))
5334
+ setHotBlock(prev => (prev === id ? prev : id))
5335
+ }
5336
+
5337
+ /**
5338
+ * Run one block action with the coordinates of the diff on screen.
5339
+ *
5340
+ * Discard never acts from the click — the drawer opens the confirmation,
5341
+ * and the confirmed call carries this same snapshot, so a file that moved
5342
+ * underneath the dialog is refused host-side rather than re-derived from
5343
+ * whatever the poll has fetched since. Stage and unstage run now; the
5344
+ * clicked block's buttons stay disabled until the answer lands, and the
5345
+ * drawer's op lock refuses any other block click meanwhile.
5346
+ */
5347
+ const runBlock = async (mode: BlockMode, block: number): Promise<void> => {
5348
+ if (sides === null) return
5349
+ const ask: BlockAsk = {
5350
+ path, layer, diffSha: sides.diffSha,
5351
+ lines: blockLines(rows, block),
5352
+ ...blockTally(rows, block),
5353
+ wholeFile: blockIsWholeFile(rows, block),
5354
+ }
5355
+ if (mode === 'discard') {
5356
+ void onBlockAction(mode, ask)
5357
+ return
5358
+ }
5359
+ setPendingBlock(block)
5360
+ try {
5361
+ await onBlockAction(mode, ask)
5362
+ } finally {
5363
+ setPendingBlock(null)
5364
+ }
5365
+ }
5366
+
5367
+ /** The pane the drawer had before this view existed, notice included. */
5368
+ const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
5369
+ ? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} />
5370
+ : <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
5371
+
5372
+ if (failed) return unifiedFallback()
5373
+ if (sides === null) return <div className={css.empty}>{t('loadingDiff')}</div>
5374
+ if (sides.binary) return <div className={css.empty}>{t('binaryFile')}</div>
5375
+ if (sides.tooLarge) {
5376
+ return (
5377
+ <>
5378
+ <div className={css.sideNotice}>{t('diffTooLarge')}</div>
5379
+ {unifiedFallback()}
5380
+ </>
5381
+ )
5382
+ }
5383
+ // The tabs are the pane's, not one layer's: an empty diff here (a fully
5384
+ // staged file's unstaged side, a file with nothing staged) is one click from
5385
+ // the other layer, and the Edit button still arms — the working tree is the
5386
+ // edit target even when every change in it is already staged. So the
5387
+ // no-change treatment below is a state of the BODY (`sideBodyState`), never
5388
+ // an early return for the pane: returning here is what used to blank the
5389
+ // tabs for exactly these files.
5390
+ const bodyState = sideBodyState(rows, editable)
5391
+ /**
5392
+ * How many separate places this file changed — the count the nav walks.
5393
+ *
5394
+ * Offered only for the read view. While armed, the left column is the index
5395
+ * side rendered DENSE and the right one is a buffer whose line count has
5396
+ * already diverged from it, so scrolling to a block would put the change at
5397
+ * the top of one column and unrelated code at the top of the other.
5398
+ */
5399
+ const changes = bodyState.kind === 'rows' ? blockCount(rows) : 0
5400
+ // The hovered block's first row hosts the action bar; a del-only block has
5401
+ // no right cell, so its bar rides the left one instead. In the editor
5402
+ // layout the left column is dense, so the bar rides its first left row.
5403
+ const hotFirst = hotBlock === null ? -1 : rows.findIndex(row => row.block === hotBlock)
5404
+ const hotFirstLeft = hotBlock === null ? -1 : leftRows.findIndex(entry => entry.row.block === hotBlock)
5405
+ // Dirty buffer, no block actions: a patch computed from the loaded diff
5406
+ // would land on top of edits the patch knows nothing about.
5407
+ const barDisabled = pendingBlock !== null || dirty
5408
+ const blockBar = (block: number): ReactNode => (
5409
+ <span className={css.blockBar}>
5410
+ {layer === 'staged' ? (
5411
+ <button
5412
+ type="button"
5413
+ className={css.blockBtn}
5414
+ disabled={barDisabled}
5415
+ onClick={() => { void runBlock('unstage', block) }}
5416
+ >{t('blockUnstage')}</button>
5417
+ ) : (
5418
+ <>
5419
+ <button
5420
+ type="button"
5421
+ className={css.blockBtn}
5422
+ disabled={barDisabled}
5423
+ onClick={() => { void runBlock('stage', block) }}
5424
+ >{t('blockStage')}</button>
5425
+ <button
5426
+ type="button"
5427
+ className={`${css.blockBtn} ${css.blockBtnDanger}`}
5428
+ disabled={barDisabled}
5429
+ onClick={() => { void runBlock('discard', block) }}
5430
+ >{t('blockDiscard')}</button>
5431
+ </>
5432
+ )}
5433
+ </span>
5434
+ )
5435
+ /** Clicking the working-tree column is the arm gesture readers will try
5436
+ * first — unless the click was really a text selection, or landed on a
5437
+ * block button, in which case it keeps its own meaning. */
5438
+ const armFromCell = (event: ReactMouseEvent<HTMLSpanElement>): void => {
5439
+ if (edit.armed) return
5440
+ if ((event.target as Element).closest('button') !== null) return
5441
+ const selection = window.getSelection()
5442
+ if (selection !== null && !selection.isCollapsed) return
5443
+ arm()
5444
+ }
5445
+ return (
5446
+ /* `tabIndex={-1}` is what makes F7 reachable. The handler below is on
5447
+ this element, so it only sees keys whose target is inside it — and
5448
+ clicking diff text, which is not focusable, otherwise leaves focus on
5449
+ the document body and the key never arrives. A negative index keeps the
5450
+ pane out of the tab order while letting a click land focus here. */
5451
+ <div className={css.sidePane} tabIndex={-1} onKeyDown={onPaneKeyDown}>
5452
+ <div className={css.sideTabs}>
5453
+ <button
5454
+ type="button"
5455
+ aria-pressed={layer === 'unstaged'}
5456
+ className={layer === 'unstaged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
5457
+ onClick={() => switchLayer('unstaged')}
5458
+ >{t('tabUnstaged')}</button>
5459
+ <button
5460
+ type="button"
5461
+ aria-pressed={layer === 'staged'}
5462
+ className={layer === 'staged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
5463
+ onClick={() => switchLayer('staged')}
5464
+ >{t('tabStaged')}</button>
5465
+ {/* A file whose whole delta is one line is unfindable by scrolling:
5466
+ the tint only shows once you are already looking at it. The row
5467
+ model knows where every change is, so these two say so. The count
5468
+ is the other half of the answer — "there is one place to look" is
5469
+ what stops the hunt. */}
5470
+ {changes > 0 ? (
5471
+ <span className={css.sideNav}>
5472
+ <button
5473
+ type="button"
5474
+ className={css.blockBtn}
5475
+ title={t('prevChangeHint')}
5476
+ aria-label={t('prevChange')}
5477
+ onClick={() => { goToChange(-1) }}
5478
+ ><NavGlyph of="prev" /></button>
5479
+ <button
5480
+ type="button"
5481
+ className={css.blockBtn}
5482
+ title={t('nextChangeHint')}
5483
+ aria-label={t('nextChange')}
5484
+ onClick={() => { goToChange(1) }}
5485
+ ><NavGlyph of="next" /></button>
5486
+ <span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
5487
+ </span>
5488
+ ) : null}
5489
+ {/* Editing arms explicitly and saves explicitly — the two halves of
5490
+ "never per keystroke". Save enables only while dirty; Revert drops
5491
+ the buffer back onto its basis without touching the file. A
5492
+ payload the CRLF gate refuses offers no Edit button at all — the
5493
+ notice below says why rather than leaving a button that does
5494
+ nothing. */}
5495
+ {layer === 'unstaged' ? (
5496
+ <span className={css.sideActions}>
5497
+ {edit.armed ? (
5498
+ <>
5499
+ <button
5500
+ type="button"
5501
+ className={`${css.blockBtn}${dirty ? ` ${css.sideSaveReady}` : ''}`}
5502
+ disabled={!dirty || saving}
5503
+ onClick={() => { void runSave(edit.baseSha) }}
5504
+ >{t('fileSave')}</button>
5505
+ <button
5506
+ type="button"
5507
+ className={css.blockBtn}
5508
+ disabled={!dirty || saving}
5509
+ onClick={revert}
5510
+ >{t('fileRevert')}</button>
5511
+ </>
5512
+ ) : armable ? (
5513
+ <button type="button" className={css.blockBtn} onClick={arm}>{t('editFile')}</button>
5514
+ ) : null}
5515
+ </span>
5516
+ ) : null}
5517
+ </div>
5518
+ {dirty ? <div className={css.sideNotice}>{t('editingNotice')}</div> : null}
5519
+ {layer === 'unstaged' && refusal !== null && !edit.armed ? <div className={css.sideNotice}>{t(refusal === 'encoding' ? 'encodingNotice' : 'crlfNotice')}</div> : null}
5520
+ {/* §4's row: the file moved underneath a dirty buffer — by the poll's
5521
+ notice or by a refused save — and the reader chooses which version
5522
+ survives. Overwrite waits for the refetch the refusal triggered, so
5523
+ it is checked against the file as it truly stands. */}
5524
+ {dirty && edit.conflict ? (
5525
+ <div className={css.sideBanner} role="alert">
5526
+ <span className={css.sideBannerTitle}>{t('staleTitle')}</span>
5527
+ <span>{t('staleBody')}</span>
5528
+ <span className={css.sideBannerActs}>
5529
+ <button type="button" className={`${css.blockBtn} ${css.blockBtnDanger}`} disabled={saving} onClick={reload}>{t('staleReload')}</button>
5530
+ <button type="button" className={css.blockBtn} disabled={!canOverwrite || saving} onClick={() => { void overwrite() }}>{t('staleOverwrite')}</button>
5531
+ </span>
5532
+ </div>
5533
+ ) : null}
5534
+ {saveFailed !== null ? (
5535
+ <div className={css.sideBanner} role="alert">
5536
+ <span className={css.sideBannerTitle}>{saveFailed.title}</span>
5537
+ {saveFailed.detail.length > 0 ? <span>{saveFailed.detail}</span> : null}
5538
+ <span className={css.sideBannerActs}>
5539
+ <button type="button" className={css.blockBtn} disabled={!dirty || saving} onClick={() => { void runSave(edit.baseSha) }}>{t('saveRetry')}</button>
5540
+ </span>
5541
+ </div>
5542
+ ) : null}
5543
+ {/* Two columns that scroll sideways independently, with a divider the
5544
+ reader can drag. One grid spanning both sides could not do this: its
5545
+ tracks are sized by the widest line in the file, so a drag moved
5546
+ nothing on exactly the wide files where the space matters. Vertical
5547
+ alignment survives the split because both columns render one row per
5548
+ aligned row at the same line height — the diff decides the rows, the
5549
+ layout only decides how much width each side gets. */}
5550
+ <div ref={scrollRef} className={css.sideScroll}>
5551
+ {bodyState.kind === 'empty' ? (
5552
+ <div className={css.empty}>{t('noTextDiff')}</div>
5553
+ ) : (
5554
+ <div
5555
+ ref={colsRef}
5556
+ className={css.sideCols}
5557
+ onMouseOver={onBodyHover}
5558
+ onMouseLeave={() => { setHotBlock(null) }}
5559
+ >
5560
+ <div className={css.sideCol} style={{ flexBasis: `${split * 100}%` }}>
5561
+ <div className={css.sideColGrid}>
5562
+ {bodyState.kind === 'editor' ? (
5563
+ /* While armed the left column renders the index side DENSE —
5564
+ one row per index line, no diff holes — because the right
5565
+ column is a buffer whose line count diverges from the diff
5566
+ the moment a keystroke lands. */
5567
+ leftRows.map((entry, k) => {
5568
+ const { row, i } = entry
5569
+ const hot = hotBlock !== null && row.block === hotBlock
5570
+ const hotClass = hot ? ` ${css.sideBlockHot}` : ''
5571
+ return (
5572
+ <Fragment key={`l${i}`}>
5573
+ <span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left!.line}</span>
5574
+ <span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.block >= 0 ? row.block : undefined}>
5575
+ {renderSideCode(row.left, leftSyntax?.[i])}
5576
+ {hot && k === hotFirstLeft ? blockBar(row.block) : null}
5577
+ </span>
5578
+ </Fragment>
5579
+ )
5580
+ })
5581
+ ) : (
5582
+ rows.map((row, i) => {
5583
+ const hot = hotBlock !== null && row.block === hotBlock
5584
+ const hotClass = hot ? ` ${css.sideBlockHot}` : ''
5585
+ // The block's action bar rides in this column only for a row
5586
+ // with no right-hand side — a pure deletion, where the right
5587
+ // column has no cell to hang it on.
5588
+ const bar = hot && i === hotFirst && row.right === null ? blockBar(row.block) : null
5589
+ return (
5590
+ <Fragment key={i}>
5591
+ <span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left === null ? '' : row.left.line}</span>
5592
+ <span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.block >= 0 ? row.block : undefined}>
5593
+ {renderSideCode(row.left, leftSyntax?.[i])}
5594
+ {bar}
5595
+ </span>
5596
+ </Fragment>
5597
+ )
5598
+ })
5599
+ )}
5600
+ </div>
5601
+ </div>
5602
+ <PaneDivider label={t('resizeSides')} onDrag={onSplitDrag} />
5603
+ <div className={`${css.sideCol} ${css.sideColRight}`}>
5604
+ {bodyState.kind === 'editor' ? (
5605
+ <CodeEditor
5606
+ value={edit.buffer}
5607
+ original={indexText}
5608
+ onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
5609
+ syntax={editSyntax}
5610
+ indent={indentOfBuffer}
5611
+ ariaLabel={path}
5612
+ onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
5613
+ />
5614
+ ) : (
5615
+ <div className={css.sideColGrid}>
5616
+ {rows.map((row, i) => {
5617
+ const hot = hotBlock !== null && row.block === hotBlock
5618
+ const hotClass = hot ? ` ${css.sideBlockHot}` : ''
5619
+ const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
5620
+ return (
5621
+ <Fragment key={i}>
5622
+ <span className={`${sideNumClass(row, 'right')}${hotClass}`}>{row.right === null ? '' : row.right.line}</span>
5623
+ <span
5624
+ className={`${css.sideCode} ${sideCodeClass(row, 'right')}${hotClass}${layer === 'unstaged' && armable ? ` ${css.sideArmable}` : ''}`}
5625
+ data-block={row.block >= 0 ? row.block : undefined}
5626
+ onClick={layer === 'unstaged' && armable ? armFromCell : undefined}
5627
+ >
5628
+ {renderSideCode(row.right, rightSyntax?.[i])}
5629
+ {bar}
5630
+ </span>
5631
+ </Fragment>
5632
+ )
5633
+ })}
5634
+ </div>
5635
+ )}
5636
+ </div>
5637
+ </div>
5638
+ )}
5639
+ </div>
5640
+ {pendingLayer !== null ? (
5641
+ <LeaveEditsConfirm t={t} path={path} onCancel={() => { setPendingLayer(null) }} onConfirm={confirmLeave} />
5642
+ ) : null}
5643
+ </div>
5644
+ )
5645
+ }
5646
+
5647
+ /**
5648
+ * The unsaved-edits guard, rendered at both sites that defer a gesture on the
5649
+ * buffer's answer: the pane's layer-tab switch, and the drawer level (file
5650
+ * selection, main tab, source switch, close) for every gesture that would
5651
+ * drop the buffer. Same reason as the roll-back confirmation — the click it
5652
+ * answers to is one gesture away from losing work. Cancel holds the initial
5653
+ * focus and Escape closes, because the default answer to losing edits is no.
5654
+ */
5655
+ function LeaveEditsConfirm({ t, path, onCancel, onConfirm }: {
5656
+ t: Translate
5657
+ path: string
5658
+ onCancel: () => void
5659
+ onConfirm: () => void
5660
+ }): ReactNode {
5661
+ const stayRef = useRef<HTMLButtonElement>(null)
5662
+ useEffect(() => { stayRef.current?.focus() }, [])
5663
+ useEffect(() => {
5664
+ // Capture phase, like the roll-back dialog: while a question about edits
5665
+ // is open, Escape answers it and nothing else — consumed here, before it
5666
+ // can reach the page's other Escape handlers (an open picker's dismiss,
5667
+ // the commit box's undo).
5668
+ const onKey = (event: KeyboardEvent): void => {
5669
+ if (event.key !== 'Escape') return
5670
+ event.stopPropagation()
5671
+ onCancel()
5672
+ }
5673
+ window.addEventListener('keydown', onKey, true)
5674
+ return () => { window.removeEventListener('keydown', onKey, true) }
5675
+ }, [onCancel])
5676
+ return (
5677
+ <div className={css.confirmScrim} onClick={onCancel}>
5678
+ <div
5679
+ className={css.confirmBox}
5680
+ role="alertdialog"
5681
+ aria-modal="true"
5682
+ aria-label={t('unsavedTitle')}
5683
+ onClick={event => event.stopPropagation()}
5684
+ >
5685
+ <div className={css.confirmTitle}>{t('unsavedTitle')}</div>
5686
+ <div className={css.confirmBody}>{t('unsavedBody', { path })}</div>
5687
+ <div className={css.confirmActions}>
5688
+ <button ref={stayRef} type="button" className={css.btn} onClick={onCancel}>{t('unsavedStay')}</button>
5689
+ <button type="button" className={`${css.btn} ${css.btnDanger}`} onClick={onConfirm}>{t('unsavedLeave')}</button>
5690
+ </div>
5691
+ </div>
5692
+ </div>
5693
+ )
5694
+ }
5695
+
5696
+ /** Line-number cell class: a PRESENT cell of a changed row carries its side's
5697
+ * tint into the gutter; an absent one stays blank, the way a split diff shows
5698
+ * a one-sided change with an empty opposite pane rather than a tinted void. */
5699
+ function sideNumClass(row: SideRow, side: 'left' | 'right'): string {
5700
+ const cell = side === 'left' ? row.left : row.right
5701
+ if (cell === null || row.kind === 'same') return css.sideNum
5702
+ return `${css.sideNum} ${side === 'left' ? css.sideNumDel : css.sideNumAdd}`
5703
+ }
5704
+
5705
+ /** Code cell class: deletions tint left, additions right, context stays quiet. */
5706
+ function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
5707
+ const cell = side === 'left' ? row.left : row.right
5708
+ if (cell === null || row.kind === 'same') return css.sideCodeSame
5709
+ return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
5710
+ }
5711
+
5712
+ /** One cell's Shiki runs, or its plain text when no tokens exist. */
5713
+ function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
5714
+ if (cell === null) return ''
5715
+ if (tokens === undefined || tokens.length === 0) return cell.text
5716
+ if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return cell.text
5717
+ return tokens.map((tok, i) => (
5718
+ <span
5719
+ key={i}
5720
+ style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
5721
+ >{tok.text}</span>
5722
+ ))
5723
+ }
5724
+
4426
5725
  /* ---------- shared helpers ---------- */
4427
5726
 
4428
5727
  /** Split a combined `git diff` into path -> its segment text. */