@young1lin/dsh-ui-gitworkbench 0.1.5 → 0.1.7

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 (49) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CHANGELOG_EN.md +43 -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 +34960 -11826
  7. package/lib/git-ops.js +25 -0
  8. package/lib/image-sniff.js +197 -0
  9. package/lib/index.js +401 -7
  10. package/lib/patch-model.js +223 -0
  11. package/lib/side-guard.js +55 -0
  12. package/lib/write-checked.js +164 -0
  13. package/package.json +7 -1
  14. package/src/apply-blocks.ts +215 -0
  15. package/src/atomic-json.ts +29 -5
  16. package/src/blame.ts +94 -0
  17. package/src/client/CodeEditor.tsx +317 -0
  18. package/src/client/FileBrowser.tsx +657 -0
  19. package/src/client/GitWorkbenchPanel.module.css +491 -12
  20. package/src/client/GitWorkbenchPanel.tsx +1655 -190
  21. package/src/client/ImageView.tsx +120 -0
  22. package/src/client/blame-gutter.ts +108 -0
  23. package/src/client/blame-view.ts +104 -0
  24. package/src/client/cm-diff.ts +108 -0
  25. package/src/client/cm-tokens.ts +79 -0
  26. package/src/client/diff-nav.ts +198 -0
  27. package/src/client/file-icon.ts +190 -0
  28. package/src/client/file-rows.ts +184 -0
  29. package/src/client/files-place.ts +178 -0
  30. package/src/client/glyphs.tsx +86 -0
  31. package/src/client/highlight.ts +25 -0
  32. package/src/client/history-layout.ts +52 -0
  33. package/src/client/idle-value.ts +53 -0
  34. package/src/client/image-view.ts +106 -0
  35. package/src/client/indent.ts +74 -0
  36. package/src/client/index.ts +59 -0
  37. package/src/client/locales.ts +179 -4
  38. package/src/client/pane-size.ts +71 -0
  39. package/src/client/side-edit.ts +244 -0
  40. package/src/client/side-rows.ts +258 -0
  41. package/src/client/stable-list.ts +31 -0
  42. package/src/client/use-change-nav.ts +83 -0
  43. package/src/client/worktree-view.ts +11 -1
  44. package/src/git-ops.ts +36 -1
  45. package/src/image-sniff.ts +204 -0
  46. package/src/index.ts +447 -7
  47. package/src/patch-model.ts +267 -0
  48. package/src/side-guard.ts +58 -0
  49. 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,6 +56,23 @@ 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 { COMMIT_ROW_H, DEFAULT_HISTORY_LAYOUT, isHistoryLayout, type HistoryLayout } from './history-layout.ts'
64
+ import { useChangeNav } from './use-change-nav.ts'
65
+ import {
66
+ applySaveOk, applySides, armEdit, armRefusal, DISARMED, editableSides, gateLeave, isDirty,
67
+ LEAVE_GUARD_CLEAR, leaveAnswered, leaveAsked, markConflict, paneDirtyReport, reloadSides, resetSides,
68
+ type EditState, type LeaveGuard, type WriteResult,
69
+ } from './side-edit.ts'
70
+ import { FileBrowser } from './FileBrowser.tsx'
71
+ import { decodePlaces, encodePlaces, placeAt, withPlace, type FilesPlace, type FilesPlaces } from './files-place.ts'
72
+ import { HIGHLIGHT_IDLE_MS, HIGHLIGHT_LINE_CAP, useIdleValue } from './idle-value.ts'
73
+ import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
74
+ import { detectIndent } from './indent.ts'
75
+ import { CodeEditor } from './CodeEditor.tsx'
59
76
  import { layoutGraph, type GraphRow } from './commit-graph.ts'
60
77
  import { formatCommitDate } from './commit-filter.ts'
61
78
  import { chipsFromFilter, emptyQueryFilter, parseLogQuery, removeChip, serializeLogQuery } from './log-filter-query.ts'
@@ -71,8 +88,8 @@ import {
71
88
  fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
72
89
  type CheckState, type Tick, type TickAction,
73
90
  } from './stage-tree.ts'
74
- import { grammarLoadCount, highlightForRows, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
75
- import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
91
+ import { grammarLoadCount, highlightFile, highlightForRows, highlightWholeFile, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
92
+ import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
76
93
  import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
77
94
  import type { WorkbenchKey } from './locales.ts'
78
95
  import css from './GitWorkbenchPanel.module.css'
@@ -186,10 +203,12 @@ export interface SyncStatus {
186
203
  readonly hasRemote: boolean
187
204
  }
188
205
 
189
- /** Why a write operation failed, in terms the drawer can explain. */
206
+ /** Why a write operation failed, in terms the drawer can explain. `stale` is
207
+ * a sha the host re-derived and refused; `invalid` an argument combination
208
+ * the host rejected before running anything. */
190
209
  export type GitOpFailure =
191
210
  | 'auth' | 'network' | 'no-upstream' | 'diverged' | 'conflict'
192
- | 'nothing-to-commit' | 'dirty' | 'unknown'
211
+ | 'nothing-to-commit' | 'dirty' | 'stale' | 'invalid' | 'unknown'
193
212
 
194
213
  export interface GitOpResult {
195
214
  readonly ok: boolean
@@ -200,32 +219,145 @@ export interface GitOpResult {
200
219
  }
201
220
 
202
221
  /** The host endpoints under `gitWorkbench/` that change something. */
203
- export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile'
222
+ export type GitOpName = 'stage' | 'unstage' | 'commit' | 'fetch' | 'pull' | 'push' | 'discardFile' | 'applyBlocks'
204
223
 
205
224
  /** Extra arguments an operation needs beyond the worktree path. */
206
225
  export interface GitOpPayload {
207
226
  readonly paths?: readonly string[]
208
227
  readonly message?: string
209
228
  readonly amend?: boolean
210
- readonly mode?: 'ff-only' | 'rebase' | 'merge'
211
- /** `discardFile` only, and deliberately singular: the one irreversible thing
212
- * the drawer does takes one file per call, so a mistaken click costs one
213
- * file. */
229
+ /** `pull` picks how to integrate; `applyBlocks` which block mutation. One
230
+ * field serves both because the payload is a flat bag keyed by op — the
231
+ * host narrows and validates it per endpoint. */
232
+ readonly mode?: 'ff-only' | 'rebase' | 'merge' | BlockMode
233
+ /** `discardFile` and `applyBlocks`, and deliberately singular: the one
234
+ * irreversible thing the drawer does takes one file per call, so a mistaken
235
+ * click costs one file. */
214
236
  readonly path?: string
215
237
  /** `discardFile` only: the effect the confirmation stated. The host refuses
216
238
  * if the file changed underneath the dialog and now means something else. */
217
239
  readonly expectedEffect?: string
240
+ /** `applyBlocks` only: the layer whose diff the `diffSha` is over, and the
241
+ * block's hunk-line indices (`side-rows.blockLines`). The host re-fetches
242
+ * that layer's diff and refuses unless the sha still matches. */
243
+ readonly layer?: SideLayer
244
+ readonly diffSha?: string
245
+ readonly lines?: readonly number[]
218
246
  }
219
247
 
220
248
  export type { DiscardAnswer, DiscardNext, DiscardPreview } from './discard-flow.ts'
249
+ export type { WriteResult } from './side-edit.ts'
250
+
251
+ /** Which side of the index a side-by-side pane shows: `unstaged` is
252
+ * index→worktree (the editable side), `staged` is HEAD→index (read-only). */
253
+ export type SideLayer = 'unstaged' | 'staged'
254
+
255
+ /** A block mutation the side pane's buttons request: `stage` and `discard` act
256
+ * on the unstaged layer, `unstage` on the staged one. The host enforces the
257
+ * same matrix. */
258
+ export type BlockMode = 'stage' | 'unstage' | 'discard'
259
+
260
+ /**
261
+ * What one block action acts on, snapshotted from the diff the pane had
262
+ * rendered when the click (or its confirmation) happened.
263
+ *
264
+ * The snapshot is the point: `diffSha` proves the file has not changed since
265
+ * the pane rendered it, and `lines` — the block's hunk-line indices — only
266
+ * mean anything against exactly that diff. A confirmed roll-back carries the
267
+ * ask it opened with, so the answer cannot drift under the dialog.
268
+ */
269
+ export interface BlockAsk {
270
+ readonly path: string
271
+ readonly layer: SideLayer
272
+ readonly diffSha: string
273
+ readonly lines: readonly number[]
274
+ /** The block's line tallies, for the roll-back confirmation's wording. */
275
+ readonly added: number
276
+ readonly deleted: number
277
+ /** Whether the block is the file's entire content — the untracked case,
278
+ * whose roll-back DELETES the file and whose confirmation says so. */
279
+ readonly wholeFile: boolean
280
+ }
281
+
282
+ /**
283
+ * `gitWorkbench/fileSides`: one layer of one file for the side-by-side pane.
284
+ * Mirrors the host's `FileSides` (the client re-declares host shapes rather
285
+ * than importing the host module, which pulls node and the RPC decorators).
286
+ */
287
+ export interface FileSides {
288
+ /** Unified diff at full context; '' when the layer has no change. */
289
+ readonly diff: string
290
+ /** sha1 of `diff`, echoed back by mutations to prove the same snapshot. */
291
+ readonly diffSha: string
292
+ /** Whole right-hand text, the editor's initial buffer. */
293
+ readonly targetText: string
294
+ /** Blob sha of the right-hand side; '' when it does not exist. */
295
+ readonly targetSha: string
296
+ readonly binary: boolean
297
+ /** True when the file is past the size guard; the client shows the old view. */
298
+ readonly tooLarge: boolean
299
+ /** True when the working-tree file is not valid UTF-8; the pane shows the
300
+ * diff but withholds the editor. Optional so an older host half reads as
301
+ * "fine" rather than as a refusal this client cannot explain. */
302
+ readonly lossyEncoding?: boolean
303
+ }
304
+
305
+ /**
306
+ * `gitWorkbench/fileImage`: one working-tree file's bytes, when the host's
307
+ * signature check confirms they are an image a browser can draw.
308
+ *
309
+ * Every field is present in both outcomes — an image and a refusal — because
310
+ * the gateway's payloads carry no `undefined`. `reason` is '' exactly when
311
+ * `ok`, and names the refusal otherwise: 'notImage', 'tooLarge', 'missing'.
312
+ */
313
+ export interface FileImage {
314
+ readonly ok: boolean
315
+ /** MIME type to label the blob with; '' when declined. */
316
+ readonly mime: string
317
+ /** Short label for the caption — 'PNG', 'WebP', 'SVG'; '' when declined. */
318
+ readonly kind: string
319
+ /** The whole file, base64; '' when declined. */
320
+ readonly base64: string
321
+ /** The file's size in bytes, reported either way. */
322
+ readonly bytes: number
323
+ readonly reason: string
324
+ }
325
+
326
+ /** One line's provenance, as `gitWorkbench/blame` reports it. */
327
+ export interface BlameLine {
328
+ /** Full commit sha; all zeros for a line not committed yet. */
329
+ readonly hash: string
330
+ readonly author: string
331
+ /** Author time, unix seconds; 0 when git did not say. */
332
+ readonly time: number
333
+ readonly summary: string
334
+ readonly uncommitted: boolean
335
+ }
336
+
337
+ /** `gitWorkbench/blame`'s answer. `error` is present only on failure. */
338
+ export interface BlameAnswer {
339
+ readonly lines: readonly BlameLine[]
340
+ /** Whether the file was longer than the gutter's cap. */
341
+ readonly truncated: boolean
342
+ readonly error?: string
343
+ }
221
344
 
222
345
  /** Translate a key of this plugin's namespace, with optional `{name}` params. */
223
- type Translate = (key: string, params?: Record<string, string | number>) => string
346
+ export type Translate = (key: string, params?: Record<string, string | number>) => string
224
347
 
225
348
  type Props = PropsRuntime<'conversation.session.header.actions'> & {
226
349
  readonly t: Translate
227
350
  readonly fetchStats: (worktreePath: string | undefined, signal: AbortSignal) => Promise<WorkbenchStats | null>
228
351
  readonly fetchFileDiff: (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal) => Promise<string>
352
+ /** One layer of one file for the side-by-side diff pane. */
353
+ readonly fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
354
+ /** Save the editor buffer, checked against the sha it opened with. */
355
+ readonly writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
356
+ readonly fetchBlame: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<BlameAnswer | null>
357
+ /** One file's bytes, when they are an image. Null when the host half is
358
+ * older than this client: the view then falls back to the text answer. */
359
+ readonly fetchFileImage: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<FileImage | null>
360
+ /** Where the symbol at a zero-based protocol position is defined. */
229
361
  readonly fetchWorktreeStatus: (sessionId: string, repoPath: string | undefined, signal: AbortSignal) => Promise<WorktreeStatus | null>
230
362
  /** Binding only, no git — the probe the shut chip can afford to poll. */
231
363
  readonly fetchSessionBinding: (sessionId: string, signal: AbortSignal) => Promise<{ worktreePath: string | null; name: string | null } | null>
@@ -252,7 +384,7 @@ type Props = PropsRuntime<'conversation.session.header.actions'> & {
252
384
  * modes with a back action, so returning to the working tree is always one click
253
385
  * (the pattern GitHub Desktop, VS Code and the JetBrains git tooling converge on).
254
386
  */
255
- type Tab = 'changes' | 'history' | 'compare'
387
+ type Tab = 'changes' | 'history' | 'compare' | 'files'
256
388
 
257
389
  /** How many further commits one page request loads. */
258
390
  const HISTORY_PAGE = 30
@@ -265,6 +397,11 @@ const MIN_DRAWER_WIDTH = 760
265
397
  const MIN_COMMITS_WIDTH = 190
266
398
  const MIN_TREE_WIDTH = 170
267
399
  const MIN_DIFF_WIDTH = 300
400
+ /** Floors for the History tab's horizontal split. The list keeps enough for a
401
+ * few rows to read as a list rather than as a strip; the half below keeps
402
+ * enough that the tree and the diff are still worth rendering. */
403
+ const MIN_COMMITS_HEIGHT = 90
404
+ const MIN_STACKED_LOWER = 200
268
405
 
269
406
  /** Longest edge a chosen background image is resampled to before storage. Past
270
407
  * this the file grows fast while a blurred backdrop gains nothing. */
@@ -283,14 +420,35 @@ const CUSTOM_STYLE_ID = 'dsh-ui-gitworkbench-custom-css'
283
420
  const STORE_APPEARANCE = 'dsh-ui-gitworkbench:appearance'
284
421
  const STORE_WIDTH = 'dsh-ui-gitworkbench:width'
285
422
  const STORE_PANES = 'dsh-ui-gitworkbench:panes'
286
-
287
- /** Dragged pane widths in px; null on either side keeps that pane's CSS default. */
423
+ /** Where the reader was in the Files tab, per worktree. View state, so it
424
+ * belongs here beside the layout rather than in the host's per-project store:
425
+ * two people on one repository should not share each other's place. */
426
+ const STORE_FILES = 'dsh-ui-gitworkbench:files'
427
+ /** Which way the History tab arranges its panes. Its own key rather than a
428
+ * field of the appearance object: that one is about colour, and this choice
429
+ * has to survive a build that adds a palette. */
430
+ const STORE_HISTORY_LAYOUT = 'dsh-ui-gitworkbench:history-layout'
431
+
432
+ /** Dragged pane sizes in px; null on any of them keeps that pane's CSS default. */
288
433
  interface PaneWidths {
289
434
  readonly commits: number | null
290
435
  readonly tree: number | null
436
+ /**
437
+ * The commit list's HEIGHT, which is what the History tab's divider moves
438
+ * now that the list spans the drawer's full width.
439
+ *
440
+ * Optional because storage is a durable boundary: values written before the
441
+ * tab was stacked have no such field, and rejecting them outright would
442
+ * throw away the column widths the reader had already chosen.
443
+ */
444
+ readonly commitsTall?: number | null
291
445
  }
292
446
 
293
- const DEFAULT_PANES: PaneWidths = { commits: null, tree: null }
447
+ /** The two panes whose WIDTH a divider drags. `commitsTall` is deliberately
448
+ * not one of them — it is a height, with its own floors. */
449
+ type PaneWidthKey = 'commits' | 'tree'
450
+
451
+ const DEFAULT_PANES: PaneWidths = { commits: null, tree: null, commitsTall: null }
294
452
 
295
453
  /**
296
454
  * @param value - value read back from storage.
@@ -298,9 +456,9 @@ const DEFAULT_PANES: PaneWidths = { commits: null, tree: null }
298
456
  */
299
457
  function isPaneWidths(value: unknown): value is PaneWidths {
300
458
  if (typeof value !== 'object' || value === null) return false
301
- const { commits, tree } = value as Partial<PaneWidths>
459
+ const { commits, tree, commitsTall } = value as Partial<PaneWidths>
302
460
  const ok = (v: unknown): boolean => v === null || (typeof v === 'number' && Number.isFinite(v))
303
- return ok(commits) && ok(tree)
461
+ return ok(commits) && ok(tree) && (commitsTall === undefined || ok(commitsTall))
304
462
  }
305
463
 
306
464
  /**
@@ -349,24 +507,26 @@ function writeStored(key: string, value: unknown): void {
349
507
  * crosses a child that stops propagation.
350
508
  * @returns the active flag for styling, and the pointerdown handler to attach.
351
509
  */
352
- function useHorizontalDrag(): {
510
+ function usePaneDrag(axis: 'x' | 'y' = 'x'): {
353
511
  dragging: boolean
354
- start: (event: ReactPointerEvent<HTMLElement>, onDrag: (clientX: number, done: boolean) => void) => void
512
+ start: (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void) => void
355
513
  } {
514
+ const along = (event: { clientX: number; clientY: number }): number =>
515
+ axis === 'y' ? event.clientY : event.clientX
356
516
  const [dragging, setDragging] = useState(false)
357
- const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (clientX: number, done: boolean) => void): void => {
517
+ const start = (event: ReactPointerEvent<HTMLElement>, onDrag: (position: number, done: boolean) => void): void => {
358
518
  event.preventDefault()
359
519
  const handle = event.currentTarget
360
520
  handle.setPointerCapture(event.pointerId)
361
521
  setDragging(true)
362
- const onMove = (move: PointerEvent): void => { onDrag(move.clientX, false) }
522
+ const onMove = (move: PointerEvent): void => { onDrag(along(move), false) }
363
523
  // `pointercancel` ends a drag the browser took over (a touch became a
364
524
  // gesture, the window lost focus). It releases capture itself, so only the
365
525
  // pointerup path releases — and both must detach, or the next drag stacks a
366
526
  // second set of listeners on the same handle.
367
527
  const finish = (end: PointerEvent): void => {
368
528
  if (end.type === 'pointerup') {
369
- onDrag(end.clientX, true)
529
+ onDrag(along(end), true)
370
530
  handle.releasePointerCapture(end.pointerId)
371
531
  }
372
532
  handle.removeEventListener('pointermove', onMove)
@@ -387,22 +547,34 @@ function useHorizontalDrag(): {
387
547
  * @param onDrag - receives the pointer's x and whether the drag just ended.
388
548
  * @returns the divider element.
389
549
  */
390
- function PaneDivider({ label, onDrag }: {
550
+ function PaneDivider({ label, onDrag, axis = 'x' }: {
391
551
  label: string
392
- onDrag: (clientX: number, done: boolean) => void
552
+ /** clientX for an 'x' handle, clientY for a 'y' one. */
553
+ onDrag: (position: number, done: boolean) => void
554
+ /** Which way the handle moves. 'y' is the History tab's stacked split. */
555
+ axis?: 'x' | 'y'
393
556
  }): ReactNode {
394
- const { dragging, start } = useHorizontalDrag()
557
+ const { dragging, start } = usePaneDrag(axis)
558
+ const base = axis === 'y' ? `${css.paneDivider} ${css.paneDividerY}` : css.paneDivider
395
559
  return (
396
560
  <div
397
- className={dragging ? `${css.paneDivider} ${css.paneDividerActive}` : css.paneDivider}
561
+ className={dragging ? `${base} ${css.paneDividerActive}` : base}
398
562
  role="separator"
399
- aria-orientation="vertical"
563
+ // The orientation a separator reports is the axis it SEPARATES along,
564
+ // which is the opposite of the one it slides on.
565
+ aria-orientation={axis === 'y' ? 'horizontal' : 'vertical'}
400
566
  aria-label={label}
401
567
  onPointerDown={event => start(event, onDrag)}
402
568
  />
403
569
  )
404
570
  }
405
571
 
572
+ /** How narrow either side-by-side column may be dragged. A column pulled to
573
+ * nothing reads as a broken pane, and nothing on screen offers to pull it
574
+ * back out. */
575
+ const SPLIT_MIN = 0.15
576
+ const SPLIT_MAX = 0.85
577
+
406
578
  /** Commit change sets kept in the browser before the least recently used is dropped. */
407
579
  const COMMIT_CACHE_CAPACITY = 24
408
580
 
@@ -413,6 +585,19 @@ const EMPTY_STATS: WorkbenchStats = {
413
585
  files: [], diff: '', commits: [],
414
586
  }
415
587
 
588
+ /** How long the Files place must hold still before it is written. */
589
+ const PLACES_WRITE_MS = 500
590
+
591
+ /** One worktree's last-read file list. */
592
+ interface FilesTree {
593
+ readonly paths: readonly string[]
594
+ readonly truncated: boolean
595
+ }
596
+
597
+ /** A worktree nobody has opened the Files tab on yet. One instance, so an
598
+ * unvisited worktree does not re-render the browser on every pass. */
599
+ const EMPTY_TREE: FilesTree = { paths: [], truncated: false }
600
+
416
601
  /** The overlay with nothing on it — one instance, so an empty overlay never
417
602
  * re-renders the tree that receives it. */
418
603
  const EMPTY_TICKS: ReadonlyMap<string, TickAction> = new Map()
@@ -448,7 +633,7 @@ const STATUS_BADGE: Record<GitFileStatus, string> = {
448
633
  renamed: css.stRenamed, deleted: css.stDeleted,
449
634
  }
450
635
 
451
- export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetchFileDiff, fetchWorktreeStatus, fetchSessionBinding, fetchCommitStats, fetchCommits, fetchAuthors, fetchRepoTree, fetchCompare, fetchStyle, saveStyle, fetchSync, runGitOp, fetchDiscardPlan }: Props) {
636
+ 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) {
452
637
  const worktreePath = useSessions((state: { byId?: Record<string, { cwd?: string } | undefined> }) =>
453
638
  state?.byId?.[sessionId]?.cwd) as string | undefined
454
639
  /** Whether the session's agent has a turn in flight — the store mirrors it
@@ -467,6 +652,34 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
467
652
  const [gen, setGen] = useState(0)
468
653
  /** Tree expansion state, session-lifetime: survives polls, source switches and drawer close/reopen. */
469
654
  const [collapsed, setCollapsed] = useState<Set<string> | undefined>(undefined)
655
+ // The Files tab's place and its last file list, ONE PER WORKTREE. Held here
656
+ // rather than in the browser because the browser unmounts whenever another
657
+ // tab is looked at: without this, coming back lost the selection and every
658
+ // expanded folder, which is the difference between a tab you return to and
659
+ // one you start over in.
660
+ //
661
+ // Per worktree because a worktree is a different place — different files, at
662
+ // different paths, and the open one may not exist in the next one at all.
663
+ // Keying the cached list too is what stops a switch from rendering the
664
+ // previous worktree's files until the new list arrives.
665
+ const [filesPlaces, setFilesPlaces] = useState<FilesPlaces>(
666
+ () => decodePlaces(readStored<unknown>(STORE_FILES, (value): value is unknown => true, null)),
667
+ )
668
+ const [filesTrees, setFilesTrees] = useState<ReadonlyMap<string, FilesTree>>(() => new Map())
669
+ const rememberPlace = useCallback((key: string, next: FilesPlace): void => {
670
+ setFilesPlaces(prev => withPlace(prev, key, next))
671
+ }, [])
672
+ const rememberTree = useCallback((key: string, next: FilesTree): void => {
673
+ setFilesTrees(prev => {
674
+ const map = new Map(prev)
675
+ map.set(key, next)
676
+ return map
677
+ })
678
+ }, [])
679
+ // Written on a pause, not on the change: expanding a folder and typing in
680
+ // the search box both move the place, and localStorage writes synchronously.
681
+ const settledPlaces = useIdleValue(filesPlaces, PLACES_WRITE_MS)
682
+ useEffect(() => { writeStored(STORE_FILES, encodePlaces(settledPlaces)) }, [settledPlaces])
470
683
  /** Binding + the repository's worktrees, null until the first successful fetch. */
471
684
  const [wtStatus, setWtStatus] = useState<WorktreeStatus | null>(null)
472
685
  /** Worktree the drawer reads, by absolute path. null = follow the session's own
@@ -535,6 +748,16 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
535
748
  const [panes, setPanes] = useState<PaneWidths>(
536
749
  () => readStored(STORE_PANES, isPaneWidths, DEFAULT_PANES),
537
750
  )
751
+ /**
752
+ * The History tab's arrangement.
753
+ *
754
+ * Panel-level, beside the palette rather than inside the tab: the choice has
755
+ * to hold across tab switches and reopens, and the drawer's own card is where
756
+ * the row height it implies is published from.
757
+ */
758
+ const [historyLayout, setHistoryLayout] = useState<HistoryLayout>(
759
+ () => readStored(STORE_HISTORY_LAYOUT, isHistoryLayout, DEFAULT_HISTORY_LAYOUT),
760
+ )
538
761
  /** Per-project and global styling; both scopes, unresolved. */
539
762
  const [style, setStyle] = useState<StyleSettings>(EMPTY_SETTINGS)
540
763
  /** Whether dsh's resolved palette is currently dark. */
@@ -837,8 +1060,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
837
1060
  setPendingTicks(EMPTY_TICKS)
838
1061
  setStats({
839
1062
  ...EMPTY_STATS,
840
- worktreePath: statsPath,
841
- branch: branchOfWorktree(statsPath, worktreesRef.current),
1063
+ // No source pinned and no session binding yet: the empty path is what
1064
+ // EMPTY_STATS already means by "nowhere", not a missing value.
1065
+ worktreePath: statsPath ?? '',
1066
+ branch: branchOfWorktree(statsPath, worktreesRef.current) ?? '',
842
1067
  })
843
1068
  }, [statsPath])
844
1069
 
@@ -854,8 +1079,8 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
854
1079
  const prevBinding = lastBindingRef.current
855
1080
  lastBindingRef.current = bindingPath
856
1081
  if (prevBinding === bindingPath) return
857
- const prevSource = prevBinding ?? worktreePath
858
- if (sourcePath !== null && prevSource.replace(/\\/g, '/') === sourcePath.replace(/\\/g, '/')) {
1082
+ const prevSource = prevBinding ?? worktreePath ?? null
1083
+ if (sourcePath !== null && prevSource !== null && prevSource.replace(/\\/g, '/') === sourcePath.replace(/\\/g, '/')) {
859
1084
  setSourcePath(null)
860
1085
  }
861
1086
  }, [bindingPath, sourcePath, worktreePath])
@@ -1014,9 +1239,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1014
1239
  // made the drawer shake on every tick — and a tick's outcome is already
1015
1240
  // visible in place, in the box the user just clicked. So: the old banner
1016
1241
  // stays while the op runs (it still describes the last outcome), a
1017
- // successful stage/unstage clears it rather than replacing it, and only
1018
- // heavy operations and failures announce themselves at all.
1019
- if (result.ok && (op === 'stage' || op === 'unstage')) setOpResult(null)
1242
+ // successful stage/unstage tick or block clears it rather than
1243
+ // replacing it (the block visibly leaves its layer on the refetch), and
1244
+ // only heavy operations and failures announce themselves at all.
1245
+ if (result.ok && (op === 'stage' || op === 'unstage' || op === 'applyBlocks')) setOpResult(null)
1020
1246
  else setOpResult({ op, result })
1021
1247
  return result
1022
1248
  } finally {
@@ -1172,11 +1398,10 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1172
1398
  * @param measured - the drawer's inner width and the panes' current widths.
1173
1399
  * @param persist - whether to store it; false for intermediate drag frames.
1174
1400
  */
1175
- const applyPane = (which: keyof PaneWidths, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean): void => {
1401
+ const applyPane = (which: PaneWidthKey, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean): void => {
1176
1402
  const min = which === 'commits' ? MIN_COMMITS_WIDTH : MIN_TREE_WIDTH
1177
1403
  const other = which === 'commits' ? measured.tree : measured.commits
1178
- const max = Math.max(min, measured.drawer - other - MIN_DIFF_WIDTH)
1179
- const clamped = Math.min(Math.max(next, min), max)
1404
+ const clamped = clampPane(next, min, measured.drawer, other, MIN_DIFF_WIDTH)
1180
1405
  setPanes(prev => {
1181
1406
  const updated = { ...prev, [which]: clamped }
1182
1407
  if (persist) writeStored(STORE_PANES, updated)
@@ -1184,6 +1409,29 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1184
1409
  })
1185
1410
  }
1186
1411
 
1412
+ /**
1413
+ * Drag the History tab's horizontal split.
1414
+ *
1415
+ * Clamped the same way `applyPane` clamps a width, and for the same reason:
1416
+ * a pane dragged to nothing reads as broken and offers nothing to drag back
1417
+ * out. The outer `Math.max` keeps the range from inverting in a drawer too
1418
+ * short to hold both floors — the list then simply takes its minimum.
1419
+ *
1420
+ * @param next - the height in px the pointer implies.
1421
+ * @param bodyHeight - the space the two stacked halves share.
1422
+ * @param persist - false for every intermediate frame of a drag, so a
1423
+ * synchronous localStorage write never lands mid-resize.
1424
+ */
1425
+ const applyCommitsTall = (next: number, bodyHeight: number, persist: boolean): void => {
1426
+ const max = Math.max(MIN_COMMITS_HEIGHT, bodyHeight - MIN_STACKED_LOWER)
1427
+ const clamped = Math.min(Math.max(next, MIN_COMMITS_HEIGHT), max)
1428
+ setPanes(prev => {
1429
+ const updated = { ...prev, commitsTall: clamped }
1430
+ if (persist) writeStored(STORE_PANES, updated)
1431
+ return updated
1432
+ })
1433
+ }
1434
+
1187
1435
  /**
1188
1436
  * Drag the leading edge. Clamped at both ends — below the minimum the three
1189
1437
  * panes stop fitting, and past the viewport there is nothing to reveal. The
@@ -1202,6 +1450,18 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1202
1450
  if (persist) writeStored(STORE_WIDTH, clamped)
1203
1451
  }
1204
1452
 
1453
+ /**
1454
+ * Pick the History tab's arrangement.
1455
+ *
1456
+ * Written through immediately: unlike a drag this is one click, so there is
1457
+ * no intermediate frame to withhold a synchronous storage write for.
1458
+ * @param next - the arrangement to switch to.
1459
+ */
1460
+ const applyHistoryLayout = (next: HistoryLayout): void => {
1461
+ setHistoryLayout(next)
1462
+ writeStored(STORE_HISTORY_LAYOUT, next)
1463
+ }
1464
+
1205
1465
  /** Tab switch. No direction refetches the working tree: `viewKey` already
1206
1466
  * separates the tabs' per-file diff caches, so bumping `gen` here only cost a
1207
1467
  * redundant round trip. */
@@ -1300,6 +1560,9 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1300
1560
  onWidth={applyWidth}
1301
1561
  panes={panes}
1302
1562
  onPane={applyPane}
1563
+ onCommitsTall={applyCommitsTall}
1564
+ historyLayout={historyLayout}
1565
+ onHistoryLayout={applyHistoryLayout}
1303
1566
  onClose={() => setOpen(false)}
1304
1567
  onRefresh={refresh}
1305
1568
  commitDraft={commitDraft}
@@ -1315,9 +1578,17 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1315
1578
  pendingTicks={pendingTicks}
1316
1579
  onTick={queueTicks}
1317
1580
  fetchFileDiff={fetchDiffForView}
1581
+ fetchFileSides={fetchFileSides}
1582
+ writeChecked={writeChecked}
1583
+ fetchBlame={fetchBlame}
1584
+ fetchFileImage={fetchFileImage}
1318
1585
  viewKey={viewKey}
1319
1586
  gen={gen}
1320
1587
  collapsed={collapsed}
1588
+ filesPlaces={filesPlaces}
1589
+ onFilesPlace={rememberPlace}
1590
+ filesTrees={filesTrees}
1591
+ onFilesTree={rememberTree}
1321
1592
  onCollapsedChange={setCollapsed}
1322
1593
  />
1323
1594
  ) : null}
@@ -1353,6 +1624,26 @@ function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
1353
1624
  )
1354
1625
  }
1355
1626
 
1627
+ /**
1628
+ * Change-to-change navigation, as two chevrons.
1629
+ *
1630
+ * Bootstrap Icons again, at the same 16 viewBox — a pair of arrows is what
1631
+ * every editor spells this with, and the words would be longer than the
1632
+ * controls beside them.
1633
+ */
1634
+ const NAV_GLYPH = {
1635
+ 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',
1636
+ 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',
1637
+ } as const
1638
+
1639
+ function NavGlyph({ of }: { of: keyof typeof NAV_GLYPH }): ReactNode {
1640
+ return (
1641
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
1642
+ <path d={NAV_GLYPH[of]} />
1643
+ </svg>
1644
+ )
1645
+ }
1646
+
1356
1647
  /**
1357
1648
  * Tree glyph: a root with two working copies hanging off it.
1358
1649
  *
@@ -1482,7 +1773,12 @@ interface DrawerProps {
1482
1773
  onWidth: (next: number, persist: boolean) => void
1483
1774
  /** Dragged pane widths; null on either side keeps that pane's CSS default. */
1484
1775
  panes: PaneWidths
1485
- onPane: (which: keyof PaneWidths, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean) => void
1776
+ onPane: (which: PaneWidthKey, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean) => void
1777
+ /** Drag the History tab's horizontal split: the commit list's height in px. */
1778
+ onCommitsTall: (next: number, bodyHeight: number, persist: boolean) => void
1779
+ /** Which way the History tab arranges its panes. */
1780
+ historyLayout: HistoryLayout
1781
+ onHistoryLayout: (next: HistoryLayout) => void
1486
1782
  onClose: () => void
1487
1783
  onRefresh: () => void
1488
1784
  /** Commit draft, lifted so a tab switch cannot discard it. */
@@ -1512,14 +1808,24 @@ interface DrawerProps {
1512
1808
  /** Queue the git calls for a tick batch. */
1513
1809
  onTick: (action: TickAction, paths: readonly string[]) => void
1514
1810
  fetchFileDiff: (path: string, signal: AbortSignal) => Promise<string>
1811
+ /** One layer of one file for the side-by-side pane; the drawer binds the source. */
1812
+ fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
1813
+ /** Save the side pane's editor buffer; the drawer binds the source. */
1814
+ writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
1815
+ fetchBlame: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<BlameAnswer | null>
1816
+ fetchFileImage: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<FileImage | null>
1515
1817
  /** Identifies the view the per-file diff cache belongs to (working tree, or one commit). */
1516
1818
  viewKey: string
1517
1819
  gen: number
1518
1820
  collapsed: Set<string> | undefined
1821
+ filesPlaces: FilesPlaces
1822
+ onFilesPlace: (key: string, next: FilesPlace) => void
1823
+ filesTrees: ReadonlyMap<string, FilesTree>
1824
+ onFilesTree: (key: string, next: FilesTree) => void
1519
1825
  onCollapsedChange: (next: Set<string>) => void
1520
1826
  }
1521
1827
 
1522
- 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, onOpError, pendingTicks, onTick, fetchFileDiff, viewKey, gen, collapsed, onCollapsedChange }: DrawerProps): ReactNode {
1828
+ 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, historyLayout, onHistoryLayout, 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 {
1523
1829
  // Empty stand-in while a commit's change set loads, so every hook below keeps a
1524
1830
  // stable shape and the panes simply render nothing.
1525
1831
  const body = shown ?? EMPTY_STATS
@@ -1540,6 +1846,20 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1540
1846
  () => tab === 'history' ? parseLogQuery(historyQuery).paths : NO_PATHS,
1541
1847
  [tab, historyQuery],
1542
1848
  )
1849
+ /** Working-tree files the browser can open on top of what `repoTree` knows:
1850
+ * `git ls-tree HEAD` cannot see an untracked file, and a browser that will
1851
+ * not open the file you just created reads as broken. A deleted file is
1852
+ * left out — opening it would only fail. */
1853
+ /** Which worktree the Files tab is remembering for. */
1854
+ const filesKey = pathKey(statsPath)
1855
+ const rememberPlaceHere = useCallback(
1856
+ (next: FilesPlace) => { onFilesPlace(filesKey, next) }, [onFilesPlace, filesKey])
1857
+ const rememberTreeHere = useCallback(
1858
+ (next: FilesTree) => { onFilesTree(filesKey, next) }, [onFilesTree, filesKey])
1859
+ const browsablePaths = useMemo(
1860
+ () => stats.files.filter(file => file.status !== 'deleted').map(file => file.path),
1861
+ [stats.files],
1862
+ )
1543
1863
  // A selection the current source no longer lists (e.g. after a source or tab
1544
1864
  // switch) falls back to the filtered file, else the first — never a dangling
1545
1865
  // highlight. See `active-file.ts` for the order and the reasoning.
@@ -1576,7 +1896,55 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1576
1896
  // Reset the on-demand cache when the generation (refresh) advances.
1577
1897
  useEffect(() => { setFetched(new Map()) }, [gen])
1578
1898
 
1579
- const selectAndReveal = (path: string): void => onSelect(path)
1899
+ // The side pane's dirty flag, reported upward: every gesture that would
1900
+ // drop the editor's buffer asks before it acts, and the layer tab is only
1901
+ // the rarest of them — clicking another file in the tree is this pane's
1902
+ // PRIMARY navigation. The guard is the little state machine in
1903
+ // side-edit.ts (gateLeave / leaveAsked / leaveAnswered / paneDirtyReport);
1904
+ // the PANE is the dirty flag's only writer, so the drawer never guesses
1905
+ // it — clearing the flag on a confirmed Leave is what let a no-op gesture
1906
+ // (the already-active tab, the already-shown file's row) disarm the guard
1907
+ // for every gesture after it.
1908
+ const [leaveGuard, setLeaveGuard] = useState<LeaveGuard>(LEAVE_GUARD_CLEAR)
1909
+ const [pendingLeave, setPendingLeave] = useState<(() => void) | null>(null)
1910
+ const onSideDirty = useCallback((dirty: boolean): void => {
1911
+ setLeaveGuard(prev => paneDirtyReport(prev, dirty))
1912
+ }, [])
1913
+ const guardLeave = (act: () => void, same: boolean): void => {
1914
+ const gate = gateLeave(leaveGuard, same)
1915
+ if (gate.kind === 'wait') return
1916
+ if (gate.kind === 'ask') {
1917
+ setPendingLeave(() => act)
1918
+ setLeaveGuard(leaveAsked)
1919
+ return
1920
+ }
1921
+ act()
1922
+ }
1923
+ const settleLeaveAsk = (): void => {
1924
+ setPendingLeave(null)
1925
+ setLeaveGuard(leaveAnswered)
1926
+ }
1927
+ const confirmDrawerLeave = (): void => {
1928
+ const act = pendingLeave
1929
+ if (act === null) return
1930
+ // Leave closes the ask and runs the gesture; the flag keeps the pane's
1931
+ // last report. A real navigation's reset reports clean on its own; a
1932
+ // no-op gesture never should have prompted, and its Leave leaves the
1933
+ // guard armed.
1934
+ settleLeaveAsk()
1935
+ act()
1936
+ }
1937
+ const closeDrawer = (): void => guardLeave(onClose, false)
1938
+ const leaveTab = (next: Tab): void => guardLeave(() => onSwitchTab(next), next === tab)
1939
+ // `active`, not `selected`: the pane's identity is the file it SHOWS, and
1940
+ // the preferred-file fallback can leave `selected` naming a file the pane
1941
+ // is not rendering — the row that changes nothing is the shown file's.
1942
+ const selectAndReveal = (path: string): void => guardLeave(() => onSelect(path), path === active)
1943
+ // The source picker swaps the whole worktree under the drawer — the buffer
1944
+ // belongs to a file the new source may not even list. Picking the source
1945
+ // already on screen changes nothing, so it runs unguarded like every other
1946
+ // no-op gesture.
1947
+ const leaveSource = (next: string): void => guardLeave(() => onSwitchSource(next), samePath(next, statsPath))
1580
1948
 
1581
1949
  /**
1582
1950
  * Roll-back, in two steps that are deliberately not one.
@@ -1618,10 +1986,43 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1618
1986
  void runOp('discardFile', { path: pending.file.path, expectedEffect: pending.plan.effect })
1619
1987
  }
1620
1988
 
1989
+ /** The BLOCK roll-back being asked about, snapshotted at click time; null
1990
+ * while none is open. The confirmation states this ask, and the confirmed
1991
+ * call carries it verbatim — so if the file moves underneath the dialog,
1992
+ * the host's diffSha refusal is what stops the apply, not a re-derived
1993
+ * (different) block. */
1994
+ const [blockDiscard, setBlockDiscard] = useState<BlockAsk | null>(null)
1995
+
1996
+ /**
1997
+ * One block action from the side pane, routed the way `askDiscard` routes a
1998
+ * file's: stage and unstage are not destructive and run now, through the same
1999
+ * op machinery as every tick; discard is the irreversible one, so its click
2000
+ * only asks. The chain has no plan RPC to call — the consequence of reverting
2001
+ * THIS block's lines is fully stated by the pane's own rows — and the
2002
+ * "refuse if the answer changed" step is the host's stale check, which fires
2003
+ * on the diffSha the dialog was opened against.
2004
+ */
2005
+ const askBlockAction = (mode: BlockMode, ask: BlockAsk): Promise<GitOpResult> => {
2006
+ if (mode === 'discard') {
2007
+ setBlockDiscard(ask)
2008
+ return Promise.resolve({ ok: true })
2009
+ }
2010
+ return runOp('applyBlocks', { path: ask.path, layer: ask.layer, diffSha: ask.diffSha, lines: ask.lines, mode })
2011
+ }
2012
+
2013
+ const confirmBlockDiscard = (): void => {
2014
+ const ask = blockDiscard
2015
+ if (ask === null) return
2016
+ setBlockDiscard(null)
2017
+ void runOp('applyBlocks', { path: ask.path, layer: ask.layer, diffSha: ask.diffSha, lines: ask.lines, mode: 'discard' })
2018
+ }
2019
+
1621
2020
  const drawerRef = useRef<HTMLDivElement>(null)
2021
+ /** The two stacked halves' shared box, which the History split measures in. */
2022
+ const bodyRef = useRef<HTMLDivElement>(null)
1622
2023
  const commitsRef = useRef<HTMLDivElement>(null)
1623
2024
  const treeRef = useRef<HTMLDivElement>(null)
1624
- const edgeDrag = useHorizontalDrag()
2025
+ const edgeDrag = usePaneDrag()
1625
2026
 
1626
2027
  /**
1627
2028
  * What a pane drag is clamped against: the drawer's inner width and what the
@@ -1629,18 +2030,27 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1629
2030
  * resized since the last render.
1630
2031
  * @returns the three widths in px.
1631
2032
  */
1632
- const measurePanes = (): { drawer: number; commits: number; tree: number } => ({
1633
- drawer: drawerRef.current?.clientWidth ?? window.innerWidth,
1634
- commits: commitsRef.current?.getBoundingClientRect().width ?? 0,
1635
- tree: treeRef.current?.getBoundingClientRect().width ?? 0,
1636
- })
2033
+ const measurePanes = (): { drawer: number; commits: number; tree: number } => {
2034
+ const commits = commitsRef.current?.getBoundingClientRect() ?? null
2035
+ const tree = treeRef.current?.getBoundingClientRect() ?? null
2036
+ return {
2037
+ drawer: drawerRef.current?.clientWidth ?? window.innerWidth,
2038
+ // Each width counts only where the pane is actually IN THE WAY of the
2039
+ // other. Stacked, the commit list spans the drawer above the row rather
2040
+ // than sitting beside the tree, and taking its width as the tree's
2041
+ // neighbour made the ceiling smaller than the floor — the tree stayed at
2042
+ // its minimum whatever the pointer did.
2043
+ commits: neighbourWidth(commits, tree),
2044
+ tree: neighbourWidth(tree, commits),
2045
+ }
2046
+ }
1637
2047
 
1638
2048
  /**
1639
2049
  * @param which - the pane a divider resizes.
1640
2050
  * @param ref - that pane's element, whose left edge the width is measured from.
1641
2051
  * @returns a drag handler for {@link PaneDivider}.
1642
2052
  */
1643
- const paneDrag = (which: keyof PaneWidths, ref: { current: HTMLDivElement | null }) =>
2053
+ const paneDrag = (which: PaneWidthKey, ref: { current: HTMLDivElement | null }) =>
1644
2054
  (clientX: number, done: boolean): void => {
1645
2055
  const left = ref.current?.getBoundingClientRect().left
1646
2056
  if (left === undefined) return
@@ -1660,6 +2070,43 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1660
2070
  const paneStyle = (px: number | null): CSSProperties | undefined =>
1661
2071
  px === null ? undefined : { width: `${px}px`, maxWidth: 'none' }
1662
2072
 
2073
+ /**
2074
+ * The History tab's commit list, sized by height instead. `flex: none` so
2075
+ * the height is the height — a flex child in a column would otherwise
2076
+ * stretch or shrink away from it.
2077
+ *
2078
+ * The floor and the ceiling are NOT applied here. A stored height is a
2079
+ * number of pixels, and the drag that produced it clamped against the body
2080
+ * as it stood at that moment; the window can be made shorter afterwards,
2081
+ * and then a height that was reasonable becomes taller than everything.
2082
+ * The lower half collapses to nothing and the handle is pushed past the
2083
+ * bottom edge — there is no longer anything on screen to drag back, which
2084
+ * is the one failure mode a resizable split must not have. So the clamp
2085
+ * lives in the stylesheet, against `100%` of whatever the body currently
2086
+ * is, from the same two constants the drag clamp uses. The reader's chosen
2087
+ * height is kept, not rewritten: make the window tall again and it returns.
2088
+ */
2089
+ const paneTall = (px: number | null): CSSProperties | undefined =>
2090
+ px === null ? undefined : { height: `${px}px`, flex: 'none' }
2091
+
2092
+ /** The stacked split, measured from the top of the body rather than from the
2093
+ * list: the list's own top is what the drag is moving the bottom of, and
2094
+ * measuring from a moving edge makes the handle drift under the pointer. */
2095
+ const commitsTallDrag = (clientY: number, done: boolean): void => {
2096
+ const box = bodyRef.current?.getBoundingClientRect()
2097
+ if (box === undefined) return
2098
+ onCommitsTall(clientY - box.top, box.height, done)
2099
+ }
2100
+
2101
+ /**
2102
+ * Whether the History tab is showing its stacked arrangement.
2103
+ *
2104
+ * Three things follow from it and they must agree: the body's direction, how
2105
+ * the commit list is sized, and which way its divider slides. Read from one
2106
+ * name so a fourth reader cannot be added out of step.
2107
+ */
2108
+ const stackedHistory = tab === 'history' && historyLayout === 'stacked'
2109
+
1663
2110
  // Width and the background's three tunables are inline because both are live
1664
2111
  // user values; the stylesheet only says what reads them. The pane floors are
1665
2112
  // inline for a different reason: they belong to the drag clamp above, and
@@ -1671,6 +2118,13 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1671
2118
  '--gs-min-commits': `${MIN_COMMITS_WIDTH}px`,
1672
2119
  '--gs-min-tree': `${MIN_TREE_WIDTH}px`,
1673
2120
  '--gs-min-diff': `${MIN_DIFF_WIDTH}px`,
2121
+ '--gs-min-commits-tall': `${MIN_COMMITS_HEIGHT}px`,
2122
+ '--gs-min-stacked-lower': `${MIN_STACKED_LOWER}px`,
2123
+ // The commit row's height, which the lane graph also draws itself at.
2124
+ // Published rather than written into the stylesheet twice: the two
2125
+ // arrangements want different rows, and lanes only meet across the seam
2126
+ // between rows while both numbers come from `COMMIT_ROW_H`.
2127
+ '--gs-commit-row': `${COMMIT_ROW_H[historyLayout]}px`,
1674
2128
  } as CSSProperties,
1675
2129
  ...maximized || width === null ? {} : { width: `${width}px` },
1676
2130
  ...background === null ? {} : {
@@ -1685,7 +2139,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1685
2139
  className={maximized ? `${css.overlay} ${css.overlayMax}` : css.overlay}
1686
2140
  data-gs-theme={theme}
1687
2141
  data-gs-part="overlay"
1688
- onClick={onClose}
2142
+ onClick={closeDrawer}
1689
2143
  >
1690
2144
  <div
1691
2145
  ref={drawerRef}
@@ -1717,7 +2171,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1717
2171
  sessionPath={sessionPath}
1718
2172
  statsPath={statsPath}
1719
2173
  fallbackBranch={stats.branch}
1720
- onSwitch={onSwitchSource}
2174
+ onSwitch={leaveSource}
1721
2175
  />
1722
2176
  <Elided text={stats.worktreePath} className={css.headerPathMain} title={stats.worktreePath} />
1723
2177
  {tab === 'changes' && stats.detached ? <span className={css.headerDetached}>detached HEAD</span> : null}
@@ -1776,7 +2230,7 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1776
2230
  type="button"
1777
2231
  className={`${css.btn} ${css.btnIcon} ${css.btnClose}`}
1778
2232
  aria-label={t('close')} title={t('close')}
1779
- onClick={onClose}
2233
+ onClick={closeDrawer}
1780
2234
  ><ChromeGlyph of="close" /></button>
1781
2235
  </div>
1782
2236
  </div>
@@ -1786,22 +2240,29 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1786
2240
  role="tab"
1787
2241
  aria-selected={tab === 'changes'}
1788
2242
  className={tab === 'changes' ? `${css.tab} ${css.tabActive}` : css.tab}
1789
- onClick={() => onSwitchTab('changes')}
2243
+ onClick={() => leaveTab('changes')}
1790
2244
  >{t('tabChanges')}</button>
1791
2245
  <button
1792
2246
  type="button"
1793
2247
  role="tab"
1794
2248
  aria-selected={tab === 'history'}
1795
2249
  className={tab === 'history' ? `${css.tab} ${css.tabActive}` : css.tab}
1796
- onClick={() => onSwitchTab('history')}
2250
+ onClick={() => leaveTab('history')}
1797
2251
  >{t('tabHistory')}</button>
1798
2252
  <button
1799
2253
  type="button"
1800
2254
  role="tab"
1801
2255
  aria-selected={tab === 'compare'}
1802
2256
  className={tab === 'compare' ? `${css.tab} ${css.tabActive}` : css.tab}
1803
- onClick={() => onSwitchTab('compare')}
2257
+ onClick={() => leaveTab('compare')}
1804
2258
  >{t('tabCompare')}</button>
2259
+ <button
2260
+ type="button"
2261
+ role="tab"
2262
+ aria-selected={tab === 'files'}
2263
+ className={tab === 'files' ? `${css.tab} ${css.tabActive}` : css.tab}
2264
+ onClick={() => leaveTab('files')}
2265
+ >{t('tabFiles')}</button>
1805
2266
  </div>
1806
2267
  {tab === 'compare' ? (
1807
2268
  <CompareBar
@@ -1815,14 +2276,46 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1815
2276
  onHeadRef={onHeadRef}
1816
2277
  />
1817
2278
  ) : null}
1818
- {tab === 'history' && branches.length > 0 ? (
2279
+ {/* History's own toolbar: which ref is listed, and how the panes are
2280
+ arranged. The switch lives HERE rather than in the commit list's
2281
+ head because that head is about 340px wide in the column layout and
2282
+ already holds a title, a funnel and a search box — adding a fourth
2283
+ control pushed it off the pane, and the narrower the reader dragged
2284
+ the list the sooner it went. This row spans the drawer whichever
2285
+ arrangement is in force, so the control cannot be squeezed out of
2286
+ reach by the thing it controls.
2287
+
2288
+ The bar renders for the whole tab, not only when there are branches
2289
+ to pick between: a repository with an unborn HEAD still has an
2290
+ arrangement, and a control that comes and goes is worse than one
2291
+ beside an empty space. */}
2292
+ {tab === 'history' ? (
1819
2293
  <div className={css.compareBar}>
1820
- <RefPicker
1821
- t={t} label={t('historyRefLabel')} value={historyRef}
1822
- branches={branches} worktreeBranches={worktreeBranches} truncated={branchesTruncated}
1823
- onPick={onHistoryRef}
1824
- allLabel={t('allBranches')}
1825
- />
2294
+ {branches.length > 0 ? (
2295
+ <RefPicker
2296
+ t={t} label={t('historyRefLabel')} value={historyRef}
2297
+ branches={branches} worktreeBranches={worktreeBranches} truncated={branchesTruncated}
2298
+ onPick={onHistoryRef}
2299
+ allLabel={t('allBranches')}
2300
+ />
2301
+ ) : null}
2302
+ {/* Two pressed-state buttons rather than one that toggles, so the
2303
+ arrangement in force is readable without knowing which way a
2304
+ toggle points. */}
2305
+ <div className={css.layoutSwitch} role="group" aria-label={t('historyLayout')}>
2306
+ <LayoutButton
2307
+ glyph={<ColumnsGlyph />}
2308
+ label={t('layoutColumns')}
2309
+ on={historyLayout === 'columns'}
2310
+ onPick={() => onHistoryLayout('columns')}
2311
+ />
2312
+ <LayoutButton
2313
+ glyph={<StackedGlyph />}
2314
+ label={t('layoutStacked')}
2315
+ on={historyLayout === 'stacked'}
2316
+ onPick={() => onHistoryLayout('stacked')}
2317
+ />
2318
+ </div>
1826
2319
  </div>
1827
2320
  ) : null}
1828
2321
  {/* Write operations act on the working tree, so they belong to the tab
@@ -1837,12 +2330,19 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1837
2330
  role="status"
1838
2331
  >{opMessage(t, opResult.op, opResult.result)}</div>
1839
2332
  ) : null}
1840
- <div className={css.body}>
2333
+ {/* History arranges itself two ways and the reader picks; see
2334
+ `history-layout.ts` for what each is good at. Stacked, the commit
2335
+ list spans the top and the tree and diff sit below it, so a subject
2336
+ is never cut; in columns the list is a pane beside them, so the log
2337
+ is as tall as the drawer. Everything else on the tab is identical,
2338
+ which is why one flag decides all three differences here. */}
2339
+ <div ref={bodyRef} className={css.body} data-stacked={stackedHistory ? '' : undefined}>
1841
2340
  {tab === 'history' ? (
1842
2341
  <>
1843
2342
  <CommitList
1844
2343
  paneRef={commitsRef}
1845
- style={paneStyle(panes.commits)}
2344
+ style={stackedHistory ? paneTall(panes.commitsTall ?? null) : paneStyle(panes.commits)}
2345
+ layout={historyLayout}
1846
2346
  t={t}
1847
2347
  loading={historyLoading}
1848
2348
  commits={commits}
@@ -1859,9 +2359,40 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1859
2359
  fetchAuthors={fetchAuthors}
1860
2360
  fetchRepoTree={fetchRepoTree}
1861
2361
  />
1862
- <PaneDivider label={t('resizeCommits')} onDrag={paneDrag('commits', commitsRef)} />
2362
+ {/* Each arrangement drags its own stored size — a width and a
2363
+ height are separate fields, so switching back finds the pane
2364
+ where it was left rather than reset. */}
2365
+ {stackedHistory
2366
+ ? <PaneDivider axis="y" label={t('resizeCommits')} onDrag={commitsTallDrag} />
2367
+ : <PaneDivider label={t('resizeCommits')} onDrag={paneDrag('commits', commitsRef)} />}
1863
2368
  </>
1864
2369
  ) : null}
2370
+ <div className={css.bodyRow}>
2371
+ {tab === 'files' ? (
2372
+ <FileBrowser
2373
+ t={t}
2374
+ palette={theme}
2375
+ statsPath={statsPath}
2376
+ extraPaths={browsablePaths}
2377
+ gen={gen}
2378
+ treeStyle={paneStyle(panes.tree)}
2379
+ treeRef={treeRef}
2380
+ divider={<PaneDivider label={t('resizeTree')} onDrag={paneDrag('tree', treeRef)} />}
2381
+ place={placeAt(filesPlaces, filesKey)}
2382
+ onPlace={rememberPlaceHere}
2383
+ cached={filesTrees.get(filesKey) ?? EMPTY_TREE}
2384
+ onTree={rememberTreeHere}
2385
+ fetchRepoTree={fetchRepoTree}
2386
+ fetchFileSides={fetchFileSides}
2387
+ writeChecked={writeChecked}
2388
+ fetchBlame={fetchBlame}
2389
+ fetchFileImage={fetchFileImage}
2390
+ onSaved={onRefresh}
2391
+ onDirtyChange={onSideDirty}
2392
+ onShowHistory={query => { onHistoryQuery(query); leaveTab('history') }}
2393
+ />
2394
+ ) : (
2395
+ <>
1865
2396
  <div ref={treeRef} className={css.treeCol} style={paneStyle(panes.tree)} data-gs-part="tree">
1866
2397
  <FileTree
1867
2398
  t={t}
@@ -1917,24 +2448,65 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1917
2448
  {(shown === null && tab !== 'changes') || (tab === 'compare' && !comparable) ? null
1918
2449
  : activeFile !== null && activeFile.binary ? (
1919
2450
  <div className={css.empty}>{t('binaryFile')}</div>
2451
+ ) : tab === 'changes' && active !== null ? (
2452
+ <SideBySideView
2453
+ t={t}
2454
+ path={active}
2455
+ palette={theme}
2456
+ statsPath={statsPath}
2457
+ fetchSides={fetchFileSides}
2458
+ writeChecked={writeChecked}
2459
+ scopeKey={viewKey}
2460
+ gen={gen}
2461
+ fallbackSegment={segment}
2462
+ fallbackLoading={loading && segment.length === 0}
2463
+ onBlockAction={askBlockAction}
2464
+ onSaved={onRefresh}
2465
+ onDirtyChange={onSideDirty}
2466
+ />
1920
2467
  ) : loading && segment.length === 0 ? (
1921
2468
  <div className={css.empty}>{t('loadingDiff')}</div>
1922
2469
  ) : segment.length > 0 ? (
1923
- <DiffView segment={segment} path={active ?? ''} palette={theme} />
2470
+ <DiffView segment={segment} path={active ?? ''} palette={theme} t={t} />
1924
2471
  ) : (
1925
2472
  <div className={css.empty}>{t('noTextDiff')}</div>
1926
2473
  )}
1927
2474
  </div>
2475
+ </>
2476
+ )}
2477
+ </div>
1928
2478
  </div>
1929
2479
  {discardPending?.plan != null ? (
1930
2480
  <DiscardConfirm
1931
2481
  t={t}
1932
- file={discardPending.file}
1933
- plan={discardPending.plan}
2482
+ body={discardBodyText(t, discardPending.file, discardPending.plan)}
1934
2483
  onCancel={() => setDiscardPending(null)}
1935
2484
  onConfirm={confirmDiscard}
1936
2485
  />
1937
2486
  ) : null}
2487
+ {blockDiscard !== null ? (
2488
+ <DiscardConfirm
2489
+ t={t}
2490
+ body={blockDiscardBodyText(
2491
+ t,
2492
+ blockDiscard,
2493
+ body.files.find(file => file.path === blockDiscard.path),
2494
+ )}
2495
+ onCancel={() => setBlockDiscard(null)}
2496
+ onConfirm={confirmBlockDiscard}
2497
+ />
2498
+ ) : null}
2499
+ {/* The drawer-level unsaved-edits guard: the file being left is the
2500
+ one the reader was editing, and the deferred gesture — another
2501
+ file, another tab, closing — runs only on the dialog's answer. */}
2502
+ {pendingLeave !== null ? (
2503
+ <LeaveEditsConfirm
2504
+ t={t}
2505
+ path={active ?? ''}
2506
+ onCancel={settleLeaveAsk}
2507
+ onConfirm={confirmDrawerLeave}
2508
+ />
2509
+ ) : null}
1938
2510
  </div>
1939
2511
  </div>
1940
2512
  )
@@ -1943,28 +2515,28 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
1943
2515
  /**
1944
2516
  * The one dialog in this drawer, because this is the one act it cannot undo.
1945
2517
  *
1946
- * It never asks a generic "are you sure": the body names the file and states
1947
- * which of the three consequences is about to happen, in the host's own reading
1948
- * of that file taken moments ago. Cancel holds the initial focus and Escape
1949
- * closes, because the default answer to an irreversible question is no.
2518
+ * It never asks a generic "are you sure": the caller hands it a body that names
2519
+ * the file and states which consequence is about to happen the whole-file
2520
+ * roll-back's wording derived from the host's own reading of that file, the
2521
+ * block roll-back's from the pane's rows. Cancel holds the initial focus and
2522
+ * Escape closes, because the default answer to an irreversible question is no.
1950
2523
  *
1951
2524
  * There is deliberately no "don't ask again". This is the only path in the
1952
2525
  * drawer with nothing behind it, and a checkbox whose whole function is to
1953
2526
  * switch off the last guard is a feature that eventually gets clicked.
1954
2527
  */
1955
- function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
2528
+ function DiscardConfirm({ t, body, onCancel, onConfirm }: {
1956
2529
  t: Translate
1957
- file: GitFile
1958
- plan: DiscardPreview
2530
+ body: string
1959
2531
  onCancel: () => void
1960
2532
  onConfirm: () => void
1961
2533
  }): ReactNode {
1962
2534
  const cancelRef = useRef<HTMLButtonElement>(null)
1963
2535
  useEffect(() => { cancelRef.current?.focus() }, [])
1964
2536
  useEffect(() => {
1965
- // Capture phase: the drawer's own Escape handler closes the whole drawer,
1966
- // and answering a question about deleting a file should not also dismiss
1967
- // the thing that asked it.
2537
+ // Capture phase: while this question is open, Escape belongs to it alone
2538
+ // consumed here, before it can reach the page's other Escape handlers
2539
+ // (an open picker's dismiss, the commit box's undo).
1968
2540
  const onKey = (event: KeyboardEvent): void => {
1969
2541
  if (event.key !== 'Escape') return
1970
2542
  event.stopPropagation()
@@ -1974,12 +2546,6 @@ function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
1974
2546
  return () => { window.removeEventListener('keydown', onKey, true) }
1975
2547
  }, [onCancel])
1976
2548
 
1977
- const body = plan.effect === 'delete'
1978
- ? t('discardBodyDelete', { path: file.path })
1979
- : plan.effect === 'unrename'
1980
- ? t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
1981
- : t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
1982
-
1983
2549
  return (
1984
2550
  <div className={css.confirmScrim} onClick={onCancel}>
1985
2551
  <div
@@ -2000,6 +2566,35 @@ function DiscardConfirm({ t, file, plan, onCancel, onConfirm }: {
2000
2566
  )
2001
2567
  }
2002
2568
 
2569
+ /**
2570
+ * The whole-file roll-back's consequence, in the host's own fresh reading of
2571
+ * the file — the difference between "goes back to its committed content" and
2572
+ * "leaves the disk and cannot come back" is the entire question the dialog
2573
+ * asks, and it is exactly what a stale row gets wrong.
2574
+ */
2575
+ function discardBodyText(t: Translate, file: GitFile, plan: DiscardPreview): string {
2576
+ if (plan.effect === 'delete') return t('discardBodyDelete', { path: file.path })
2577
+ if (plan.effect === 'unrename') return t('discardBodyUnrename', { path: file.path, previousPath: plan.previousPath ?? '' })
2578
+ return t('discardBodyRestore', { path: file.path, added: file.addedLines, deleted: file.deletedLines })
2579
+ }
2580
+
2581
+ /**
2582
+ * The BLOCK roll-back's consequence, in the pane's own rows.
2583
+ *
2584
+ * One case outranks the tally wording: an untracked file's whole content is
2585
+ * the one block, and rolling THAT block back reverse-applies the new-file
2586
+ * patch — which deletes the file from the working tree, not rewrites it. The
2587
+ * file row's status is the gate (a tracked file whose every line changed has
2588
+ * the same block shape and only rewrites), which is why the ask's shape alone
2589
+ * is not enough.
2590
+ */
2591
+ function blockDiscardBodyText(t: Translate, ask: BlockAsk, file: GitFile | undefined): string {
2592
+ if (file !== undefined && file.status === 'untracked' && ask.wholeFile) {
2593
+ return t('blockDiscardBodyDelete', { path: ask.path })
2594
+ }
2595
+ return t('blockDiscardBody', { path: ask.path, added: ask.added, deleted: ask.deleted })
2596
+ }
2597
+
2003
2598
  /**
2004
2599
  * A slash-separated name that gives up its HEAD, never its tail.
2005
2600
  *
@@ -3006,9 +3601,6 @@ function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNod
3006
3601
  */
3007
3602
  /* ---------- commit graph ---------- */
3008
3603
 
3009
- /** Row height the graph and the list agree on. The lines only join up if every
3010
- * row is exactly as tall as the segment drawn for it. */
3011
- const GRAPH_ROW_H = 48
3012
3604
  /** Horizontal distance between lanes. */
3013
3605
  const GRAPH_LANE_W = 14
3014
3606
  /** Ref chips shown inline before the subject; the rest collapse into a "+N". */
@@ -3022,23 +3614,28 @@ const laneX = (lane: number): number => lane * GRAPH_LANE_W + GRAPH_LANE_W / 2
3022
3614
  /**
3023
3615
  * One row's slice of the commit graph.
3024
3616
  *
3025
- * Drawn as an SVG of exactly {@link GRAPH_ROW_H} pixels, so consecutive rows
3026
- * butt together and a lane reads as one unbroken line down the list. The dot
3027
- * sits at the vertical centre; edges leave the top edge, the dot, or the bottom
3028
- * edge, and a cubic with its control points at the quarter heights gives the
3029
- * S-curve every git client draws for a branch or a merge.
3617
+ * Drawn as an SVG exactly as tall as the row, so consecutive rows butt together
3618
+ * and a lane reads as one unbroken line down the list. The dot sits at the
3619
+ * vertical centre; edges leave the top edge, the dot, or the bottom edge, and a
3620
+ * cubic with its control points at the quarter heights gives the S-curve every
3621
+ * git client draws for a branch or a merge.
3622
+ *
3623
+ * The height is passed in rather than read from a constant here: the two
3624
+ * History arrangements want differently shaped rows, and the segment and the
3625
+ * row it belongs to must come from the same entry of `COMMIT_ROW_H` or the
3626
+ * lanes stop meeting across the seam between rows.
3030
3627
  */
3031
- function GraphCell({ row, width, active }: { row: GraphRow; width: number; active: boolean }): ReactNode {
3628
+ function GraphCell({ row, width, active, rowH }: { row: GraphRow; width: number; active: boolean; rowH: number }): ReactNode {
3032
3629
  const lanes = Math.min(width, GRAPH_MAX_LANES)
3033
3630
  const w = lanes * GRAPH_LANE_W
3034
- const mid = GRAPH_ROW_H / 2
3631
+ const mid = rowH / 2
3035
3632
  const visible = (lane: number): boolean => lane < GRAPH_MAX_LANES
3036
3633
  const stroke = (lane: number): string => `var(--gs-graph-${lane % 6})`
3037
3634
 
3038
3635
  const paths: ReactNode[] = []
3039
3636
  for (const lane of row.through) {
3040
3637
  if (!visible(lane)) continue
3041
- paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${GRAPH_ROW_H}`} stroke={stroke(lane)} />)
3638
+ paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${rowH}`} stroke={stroke(lane)} />)
3042
3639
  }
3043
3640
  for (const lane of row.into) {
3044
3641
  if (!visible(lane) || !visible(row.lane)) continue
@@ -3055,11 +3652,11 @@ function GraphCell({ row, width, active }: { row: GraphRow; width: number; activ
3055
3652
  for (const lane of row.outOf) {
3056
3653
  if (!visible(lane) || !visible(row.lane)) continue
3057
3654
  paths.push(lane === row.lane
3058
- ? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${GRAPH_ROW_H}`} stroke={stroke(lane)} />
3655
+ ? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${rowH}`} stroke={stroke(lane)} />
3059
3656
  : (
3060
3657
  <path
3061
3658
  key={`o${lane}`}
3062
- d={`M ${laneX(row.lane)} ${mid} C ${laneX(row.lane)} ${mid + mid / 2}, ${laneX(lane)} ${mid + mid / 2}, ${laneX(lane)} ${GRAPH_ROW_H}`}
3659
+ d={`M ${laneX(row.lane)} ${mid} C ${laneX(row.lane)} ${mid + mid / 2}, ${laneX(lane)} ${mid + mid / 2}, ${laneX(lane)} ${rowH}`}
3063
3660
  stroke={stroke(lane)}
3064
3661
  />
3065
3662
  ))
@@ -3069,8 +3666,8 @@ function GraphCell({ row, width, active }: { row: GraphRow; width: number; activ
3069
3666
  <svg
3070
3667
  className={css.graphCell}
3071
3668
  width={w}
3072
- height={GRAPH_ROW_H}
3073
- viewBox={`0 0 ${w} ${GRAPH_ROW_H}`}
3669
+ height={rowH}
3670
+ viewBox={`0 0 ${w} ${rowH}`}
3074
3671
  aria-hidden="true"
3075
3672
  focusable="false"
3076
3673
  >
@@ -3091,7 +3688,7 @@ function GraphCell({ row, width, active }: { row: GraphRow; width: number; activ
3091
3688
  )
3092
3689
  }
3093
3690
 
3094
- function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3691
+ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth, layout }: {
3095
3692
  t: Translate
3096
3693
  commit: GitCommit
3097
3694
  active: boolean
@@ -3099,6 +3696,8 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3099
3696
  /** This commit's lane geometry; absent while the graph is still empty. */
3100
3697
  graphRow?: GraphRow
3101
3698
  graphWidth: number
3699
+ /** Which arrangement the list is in, which decides the row's shape. */
3700
+ layout: HistoryLayout
3102
3701
  }): ReactNode {
3103
3702
  const rowRef = useRef<HTMLButtonElement>(null)
3104
3703
  const [open, setOpen] = useState(false)
@@ -3141,6 +3740,25 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3141
3740
 
3142
3741
  const refs = commit.refs ?? []
3143
3742
 
3743
+ /* The part both shapes share, and the only part either is really for. */
3744
+ const subjectRow = (
3745
+ <span className={css.commitSubjectRow}>
3746
+ {/* Capped at two. A release commit can carry six refs, and the
3747
+ subject is what the row is actually for — the rest are counted
3748
+ and named in the title rather than crowding it out. */}
3749
+ {refs.slice(0, COMMIT_REF_CHIPS).map(ref => (
3750
+ <span key={ref} className={css.commitRef} title={ref}>{ref}</span>
3751
+ ))}
3752
+ {refs.length > COMMIT_REF_CHIPS ? (
3753
+ <span className={css.commitRefMore} title={refs.slice(COMMIT_REF_CHIPS).join('\n')}>
3754
+ +{refs.length - COMMIT_REF_CHIPS}
3755
+ </span>
3756
+ ) : null}
3757
+ <span className={css.commitSubject}>{commit.subject}</span>
3758
+ {body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
3759
+ </span>
3760
+ )
3761
+
3144
3762
  return (
3145
3763
  <>
3146
3764
  {/* The graph is a SIBLING of the row button, spanning the line's full
@@ -3149,7 +3767,7 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3149
3767
  inset and its rounded corners. */}
3150
3768
  <div className={css.commitLine}>
3151
3769
  {graphRow !== undefined
3152
- ? <GraphCell row={graphRow} width={graphWidth} active={active} />
3770
+ ? <GraphCell row={graphRow} width={graphWidth} active={active} rowH={COMMIT_ROW_H[layout]} />
3153
3771
  : null}
3154
3772
  <button
3155
3773
  ref={rowRef}
@@ -3161,26 +3779,33 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3161
3779
  onMouseEnter={show}
3162
3780
  onMouseLeave={hide}
3163
3781
  >
3164
- <span className={css.commitTop}>
3165
- <code className={css.commitHash}>{commit.hash}</code>
3166
- {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
3167
- <span className={css.commitWhen}>{commit.when}</span>
3168
- </span>
3169
- <span className={css.commitSubjectRow}>
3170
- {/* Capped at two. A release commit can carry six refs, and the
3171
- subject is what the row is actually for — the rest are counted
3172
- and named in the title rather than crowding it out. */}
3173
- {refs.slice(0, COMMIT_REF_CHIPS).map(ref => (
3174
- <span key={ref} className={css.commitRef} title={ref}>{ref}</span>
3175
- ))}
3176
- {refs.length > COMMIT_REF_CHIPS ? (
3177
- <span className={css.commitRefMore} title={refs.slice(COMMIT_REF_CHIPS).join('\n')}>
3178
- +{refs.length - COMMIT_REF_CHIPS}
3782
+ {layout === 'stacked' ? (
3783
+ <>
3784
+ {/* One line, in git log --oneline's order: a fixed-width hash,
3785
+ then the subject, then who and when pushed to the right. The
3786
+ hash being fixed width is what aligns every subject into a
3787
+ column the eye can run down — leading with the author's name
3788
+ instead would start each subject at a different place. */}
3789
+ <code className={css.commitHash}>{commit.hash}</code>
3790
+ {subjectRow}
3791
+ <span className={css.commitMeta}>
3792
+ {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
3793
+ <span className={css.commitWhen}>{commit.when}</span>
3179
3794
  </span>
3180
- ) : null}
3181
- <span className={css.commitSubject}>{commit.subject}</span>
3182
- {body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
3183
- </span>
3795
+ </>
3796
+ ) : (
3797
+ <>
3798
+ {/* Two lines, because a pane beside the diff has no width to
3799
+ spare: everything but the subject goes above it, and the
3800
+ subject then gets the column to itself. */}
3801
+ <span className={css.commitTop}>
3802
+ <code className={css.commitHash}>{commit.hash}</code>
3803
+ {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
3804
+ <span className={css.commitWhen}>{commit.when}</span>
3805
+ </span>
3806
+ {subjectRow}
3807
+ </>
3808
+ )}
3184
3809
  </button>
3185
3810
  </div>
3186
3811
  {open && box !== null && host !== null ? createPortal(
@@ -3274,58 +3899,6 @@ function FilterCalendar({ year, month, after, before, locale, onPick, onShift }:
3274
3899
  )
3275
3900
  }
3276
3901
 
3277
- /**
3278
- * The two node glyphs, in IntelliJ's New UI icon idiom: a 16px grid, 1px
3279
- * strokes, no fill, rounded joins — outlines, where the old UI shipped filled
3280
- * silhouettes. Hand-drawn here rather than imported, because the bundle purity
3281
- * gate forbids an icon package and the drawer needs exactly these two; they
3282
- * are shapes in that language, not JetBrains' own assets.
3283
- *
3284
- * `strokeWidth` is 1 against a viewBox that renders 1:1 at 16px, so every
3285
- * stroke lands on a whole pixel instead of straddling two.
3286
- *
3287
- * Every place the drawer names a file or a directory uses these: the path
3288
- * picker in the history filter, and the file tree behind all three tabs. The
3289
- * CLASS names keep their `path` prefix — `scripts/verify_history_feature.py`
3290
- * selects the picker's file rows by `label:has([class*="pathFileGlyph"])`.
3291
- */
3292
- function PathDirGlyph(): ReactNode {
3293
- return (
3294
- <svg
3295
- className={css.pathDirGlyph}
3296
- width="16" height="16" viewBox="0 0 16 16"
3297
- fill="none" stroke="currentColor" strokeWidth="1"
3298
- strokeLinejoin="round" strokeLinecap="round"
3299
- aria-hidden="true"
3300
- >
3301
- {/* Body, with the tab stepping up over the left third. The step is a
3302
- full 2px: at 1.3px it read as a rounded rectangle with a nick in it
3303
- rather than a folder. Every straight edge sits on a .5 coordinate so
3304
- a 1px stroke lands on one pixel instead of straddling two. */}
3305
- <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" />
3306
- </svg>
3307
- )
3308
- }
3309
-
3310
- function PathFileGlyph(): ReactNode {
3311
- return (
3312
- <svg
3313
- className={css.pathFileGlyph}
3314
- width="16" height="16" viewBox="0 0 16 16"
3315
- fill="none" stroke="currentColor" strokeWidth="1"
3316
- strokeLinejoin="round" strokeLinecap="round"
3317
- aria-hidden="true"
3318
- >
3319
- {/* Sheet, cut back at the top-right for the fold. Narrower and one step
3320
- taller than the folder, sharing its optical band, so the two never
3321
- look like different-sized icons in one column. */}
3322
- <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" />
3323
- {/* The fold itself — the corner turned back on the sheet. */}
3324
- <path d="M9 2.5v2.75a.75.75 0 0 0 .75.75h2.75" />
3325
- </svg>
3326
- )
3327
- }
3328
-
3329
3902
  /** Files shown per expanded directory. The search box is the way to a file in
3330
3903
  * a crowded directory; the tree shows enough to browse without flooding the
3331
3904
  * list, and says so when it cut the tail. */
@@ -3403,7 +3976,7 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
3403
3976
  <label key={file} className={css.funnelRow} style={{ paddingLeft: (depth + 1) * PATH_INDENT + 4 }}>
3404
3977
  <span className={css.funnelChevron} aria-hidden="true" />
3405
3978
  <TriStateCheckbox state={stateOf(`${dir.path}/${file}`)} ariaLabel={`${dir.path}/${file}`} onChange={() => onTogglePath(`${dir.path}/${file}`)} />
3406
- <PathFileGlyph />
3979
+ <PathFileGlyph path={`${dir.path}/${file}`} />
3407
3980
  <span className={css.funnelName} title={`${dir.path}/${file}`}>{file}</span>
3408
3981
  </label>
3409
3982
  ))}
@@ -3420,15 +3993,81 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
3420
3993
  }
3421
3994
 
3422
3995
  /**
3423
- * The commit log as its own full-height pane.
3996
+ * The two arrangements, drawn in the same 16px/1px idiom as the drawer's other
3997
+ * glyphs: a pane and its neighbour, either side by side or one over the other.
3998
+ * The filled half is the list, so the picture says which pane moves.
3999
+ */
4000
+ function ColumnsGlyph(): ReactNode {
4001
+ return (
4002
+ <svg
4003
+ className={css.layoutGlyph}
4004
+ width="16" height="16" viewBox="0 0 16 16"
4005
+ fill="none" stroke="currentColor" strokeWidth="1"
4006
+ strokeLinejoin="round" aria-hidden="true"
4007
+ >
4008
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
4009
+ <rect x="1.5" y="2.5" width="5" height="11" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
4010
+ <path d="M6.5 2.5 V13.5" />
4011
+ </svg>
4012
+ )
4013
+ }
4014
+
4015
+ function StackedGlyph(): ReactNode {
4016
+ return (
4017
+ <svg
4018
+ className={css.layoutGlyph}
4019
+ width="16" height="16" viewBox="0 0 16 16"
4020
+ fill="none" stroke="currentColor" strokeWidth="1"
4021
+ strokeLinejoin="round" aria-hidden="true"
4022
+ >
4023
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
4024
+ <rect x="1.5" y="2.5" width="13" height="4" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
4025
+ <path d="M1.5 6.5 H14.5" />
4026
+ </svg>
4027
+ )
4028
+ }
4029
+
4030
+ /**
4031
+ * One end of the arrangement switch.
3424
4032
  *
3425
- * It sits BESIDE the file tree rather than stacked above it, which is what
3426
- * GitHub Desktop, the JetBrains git log and GitKraken all do: a commit list and
3427
- * the selected commit's files are peer panes, each with its own scrollbar. The
3428
- * earlier stacked layout had to be collapsible because two scrolling lists were
3429
- * sharing one narrow column a control that hid the thing you were reading and
3430
- * that nobody could be expected to discover. Side by side, there is nothing to
3431
- * collapse and nothing to explain.
4033
+ * `aria-pressed` rather than a radio group: these are two states of one view
4034
+ * control, not a value being submitted, and a screen reader then reads the
4035
+ * arrangement in force without the group needing a name per option.
4036
+ * @param glyph - the arrangement, drawn.
4037
+ * @param label - accessible name, also the tooltip.
4038
+ * @param on - whether this arrangement is the one in force.
4039
+ * @param onPick - switch to it.
4040
+ */
4041
+ function LayoutButton({ glyph, label, on, onPick }: {
4042
+ glyph: ReactNode
4043
+ label: string
4044
+ on: boolean
4045
+ onPick: () => void
4046
+ }): ReactNode {
4047
+ return (
4048
+ <button
4049
+ type="button"
4050
+ className={on ? `${css.layoutButton} ${css.layoutButtonOn}` : css.layoutButton}
4051
+ aria-pressed={on}
4052
+ aria-label={label}
4053
+ title={label}
4054
+ onClick={onPick}
4055
+ >{glyph}</button>
4056
+ )
4057
+ }
4058
+
4059
+ /**
4060
+ * The commit log as its own pane, in whichever arrangement the reader picked.
4061
+ *
4062
+ * Beside the file tree it is a peer pane the way GitHub Desktop, the JetBrains
4063
+ * git log and GitKraken all draw it — list and selected commit's files side by
4064
+ * side, each with its own scrollbar. Across the top it is IDEA's git log
4065
+ * instead, which is the arrangement that stops a long subject being cut; see
4066
+ * `history-layout.ts` for what each costs. The switch that picks between them
4067
+ * is in the toolbar row above, not in this pane's head — the head is the first
4068
+ * thing to run out of room when the pane is dragged narrow, which is exactly
4069
+ * when a reader reaches for the switch. Either way there is nothing to
4070
+ * collapse and nothing to discover.
3432
4071
  *
3433
4072
  * Pages load by scrolling. A button at the end of a growing list is the worst
3434
4073
  * of both worlds — it retreats every time it is used, and it asks the reader to
@@ -3437,13 +4076,18 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
3437
4076
  * what GitHub and GitLens do. The observer is rebuilt whenever the list grows,
3438
4077
  * so a page too short to fill the pane immediately triggers the next one.
3439
4078
  */
3440
- function CommitList({ paneRef, style, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore, query, onQueryChange, error, statsPath, refName, fetchAuthors, fetchRepoTree }: {
4079
+ function CommitList({ paneRef, style, layout, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore, query, onQueryChange, error, statsPath, refName, fetchAuthors, fetchRepoTree }: {
3441
4080
  /** The pane element, which the divider beside it measures from. Not named
3442
4081
  * `ref`: React reserves that on a function component, so it would be stripped
3443
4082
  * from props and never reach this element. */
3444
4083
  paneRef: Ref<HTMLDivElement>
3445
- /** Dragged width, when the divider has been used. */
4084
+ /** Dragged size, when the divider has been used: a width beside the diff, a
4085
+ * height above it. */
3446
4086
  style: CSSProperties | undefined
4087
+ /** The arrangement in force, which decides the row's shape as well as the
4088
+ * pane's. The control that CHANGES it is not in here — see the toolbar row
4089
+ * above the panes. */
4090
+ layout: HistoryLayout
3447
4091
  t: Translate
3448
4092
  /** First page in flight — the pane says "loading", not "no history", which
3449
4093
  * would be a claim about the repository the data has not made. */
@@ -3557,6 +4201,12 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3557
4201
  return () => { ctrl.abort() }
3558
4202
  }, [funnelOpen, statsPath, refName, fetchAuthors, fetchRepoTree])
3559
4203
 
4204
+ // Where the popover mounts: the drawer's overlay layer when there is one,
4205
+ // the body otherwise. Same resolution the commit-row popover does, and the
4206
+ // render below skips the portal when neither exists rather than handing
4207
+ // createPortal a null container.
4208
+ const funnelHost = funnelAnchorRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body)
4209
+
3560
4210
  /** Every funnel interaction writes the filter through the box's grammar, so
3561
4211
  * the box, the chips and the fetch can never disagree about the query. */
3562
4212
  const applyFilter = (next: LogFilter): void => { onQueryChange(serializeLogQuery(next)) }
@@ -3628,7 +4278,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3628
4278
  }, [hasMore, loadingMore, commits.length, onLoadMore])
3629
4279
 
3630
4280
  return (
3631
- <div ref={paneRef} className={css.commitsPane} style={style} data-gs-part="commits">
4281
+ <div ref={paneRef} className={css.commitsPane} style={style} data-layout={layout} data-gs-part="commits">
3632
4282
  {/* No count: the only number available is how many pages have been loaded,
3633
4283
  which is not how many commits exist. A number that cannot be right is
3634
4284
  worse than none. */}
@@ -3652,7 +4302,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3652
4302
  spellCheck={false}
3653
4303
  />
3654
4304
  </div>
3655
- {funnelOpen && funnelBox !== null ? createPortal(
4305
+ {funnelOpen && funnelBox !== null && funnelHost !== null ? createPortal(
3656
4306
  <div
3657
4307
  ref={funnelPanelRef}
3658
4308
  className={css.funnelPop}
@@ -3814,7 +4464,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3814
4464
  {hits.map(hit => (
3815
4465
  <label key={hit.path} className={css.funnelRow}>
3816
4466
  <TriStateCheckbox state={pathState(hit.path)} ariaLabel={hit.path} onChange={() => togglePath(hit.path)} />
3817
- {hit.isFile ? <PathFileGlyph /> : <PathDirGlyph />}
4467
+ {hit.isFile ? <PathFileGlyph path={hit.path} /> : <PathDirGlyph />}
3818
4468
  <span className={css.funnelName} title={hit.path}>{hit.path}</span>
3819
4469
  </label>
3820
4470
  ))}
@@ -3857,7 +4507,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3857
4507
  >{t('filterClearAll')}</button>
3858
4508
  </div>
3859
4509
  </div>,
3860
- funnelAnchorRef.current?.closest('[data-gs-part="overlay"]') ?? (typeof document === 'undefined' ? null : document.body),
4510
+ funnelHost,
3861
4511
  ) : null}
3862
4512
  {chips.length > 0 ? (
3863
4513
  <div className={css.filterChips}>
@@ -3890,6 +4540,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
3890
4540
  onSelect={onSelect}
3891
4541
  graphRow={graph.rows[index]}
3892
4542
  graphWidth={graph.width}
4543
+ layout={layout}
3893
4544
  />
3894
4545
  ))}
3895
4546
  <div ref={sentinelRef} className={css.commitsSentinel} />
@@ -4350,7 +5001,7 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
4350
5001
  left-to-right as "what this is, then what happened to it",
4351
5002
  and the badge still lands in an aligned column — `.filePath`
4352
5003
  is the only flexible child. */}
4353
- <PathFileGlyph />
5004
+ <PathFileGlyph path={file.path} />
4354
5005
  <span className={css.filePath}>{basePart(file.path)}</span>
4355
5006
  {file.binary ? <span className={css.fileBinary}>BIN</span> : (
4356
5007
  <span className={css.fileCounts}>
@@ -4380,8 +5031,27 @@ function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCh
4380
5031
 
4381
5032
  /* ---------- diff rendering: rows, word-level ranges, syntax pass ---------- */
4382
5033
 
4383
- /** Render one file's unified-diff segment with word-level highlights and Shiki. */
4384
- function DiffView({ segment, path, palette }: { segment: string; path: string; palette: string }): ReactNode {
5034
+ /**
5035
+ * Render one file's unified-diff segment with word-level highlights and Shiki.
5036
+ *
5037
+ * This is what History and Compare show, and — unlike the side-by-side pane —
5038
+ * it has no row model carrying block ids, because nothing here acts on a block:
5039
+ * a commit's contents were decided long ago, so there is no staging and no
5040
+ * roll-back. The walk still needs them, so the runs are read off the row kinds
5041
+ * by `unifiedBlocks` and marked on the rows that scroll.
5042
+ *
5043
+ * The scroller is this component's own rather than the pane's. A bar that
5044
+ * scrolls away is not a control, and the pane scrolls in BOTH directions —
5045
+ * `sticky` fixes the vertical half and nothing fixes the horizontal one, since
5046
+ * a block child of a scroller is only ever as wide as the scrollport. A header
5047
+ * outside the scrolled box has neither problem.
5048
+ */
5049
+ function DiffView({ segment, path, palette, t }: {
5050
+ segment: string
5051
+ path: string
5052
+ palette: string
5053
+ t: Translate
5054
+ }): ReactNode {
4385
5055
  const lang = shikiLangOf(path)
4386
5056
  const shikiTheme = shikiThemeOf(palette)
4387
5057
  const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
@@ -4391,10 +5061,57 @@ function DiffView({ segment, path, palette }: { segment: string; path: string; p
4391
5061
  () => highlightForRows(rowsWithWords, lang, shikiTheme),
4392
5062
  [rowsWithWords, lang, shikiTheme, grammarGen],
4393
5063
  )
5064
+ const blocks = useMemo(() => unifiedBlocks(rowsWithWords.map(row => row.kind)), [rowsWithWords])
5065
+ const changes = useMemo(() => countBlocks(blocks), [blocks])
5066
+ const scrollRef = useRef<HTMLDivElement>(null)
5067
+ const { goToChange } = useChangeNav(scrollRef)
5068
+ // Read by the key listener below, which is attached once. `goToChange` only
5069
+ // ever touches refs, but pinning it here says so rather than relying on it.
5070
+ const walk = useRef(goToChange)
5071
+ walk.current = goToChange
5072
+
5073
+ // F7 / Shift+F7, the spelling IDEA's diff viewer taught, on the same element
5074
+ // that scrolls — so the key and the buttons cannot disagree about which pane
5075
+ // they move. `tabIndex` is what makes it able to receive the key at all:
5076
+ // diff text is not focusable, and a click on it would otherwise leave focus
5077
+ // on the document body.
5078
+ useEffect(() => {
5079
+ const scroller = scrollRef.current
5080
+ if (scroller === null) return
5081
+ const onKey = (event: KeyboardEvent): void => {
5082
+ if (event.key !== 'F7') return
5083
+ event.preventDefault()
5084
+ walk.current(event.shiftKey ? -1 : 1)
5085
+ }
5086
+ scroller.addEventListener('keydown', onKey)
5087
+ return () => { scroller.removeEventListener('keydown', onKey) }
5088
+ }, [])
5089
+
4394
5090
  return (
5091
+ <div className={css.diffWrap}>
5092
+ {changes > 0 ? (
5093
+ <div className={css.diffNav}>
5094
+ <button
5095
+ type="button"
5096
+ className={css.blockBtn}
5097
+ title={t('prevChangeHint')}
5098
+ aria-label={t('prevChange')}
5099
+ onClick={() => { goToChange(-1) }}
5100
+ ><NavGlyph of="prev" /></button>
5101
+ <button
5102
+ type="button"
5103
+ className={css.blockBtn}
5104
+ title={t('nextChangeHint')}
5105
+ aria-label={t('nextChange')}
5106
+ onClick={() => { goToChange(1) }}
5107
+ ><NavGlyph of="next" /></button>
5108
+ <span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
5109
+ </div>
5110
+ ) : null}
5111
+ <div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
4395
5112
  <pre className={css.diffPre}>
4396
5113
  {rowsWithWords.map((row, i) => (
4397
- <div key={i} className={`${css.line} ${rowClass(row.kind)}`}>
5114
+ <div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
4398
5115
  {sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
4399
5116
  {sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
4400
5117
  <span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
@@ -4404,6 +5121,8 @@ function DiffView({ segment, path, palette }: { segment: string; path: string; p
4404
5121
  </div>
4405
5122
  ))}
4406
5123
  </pre>
5124
+ </div>
5125
+ </div>
4407
5126
  )
4408
5127
  }
4409
5128
 
@@ -4429,6 +5148,752 @@ function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactN
4429
5148
  ))
4430
5149
  }
4431
5150
 
5151
+ /* ---------- side-by-side diff rendering (working tree only) ---------- */
5152
+
5153
+ /**
5154
+ * The working tree's per-file diff as IDEA shows it: one tab per layer of the
5155
+ * index, two columns with the whole file, aligned row by row.
5156
+ *
5157
+ * The rows come from `side-rows.ts` over the layer's full-context diff, so the
5158
+ * alignment is read off the diff rather than computed. A change block — a
5159
+ * maximal run of changed rows — carries its own actions: hovering any of its
5160
+ * cells outlines the whole block and floats its buttons (stage + roll back on
5161
+ * the unstaged tab, unstage on the staged one). The click carries the block's
5162
+ * hunk-line indices and the rendered diff's sha, so the host can prove the
5163
+ * file has not changed since the pane drew it.
5164
+ *
5165
+ * The unstaged tab's right column is also EDITABLE (the staged one is not, by
5166
+ * design: editing the index would mean writing a blob with no file behind it).
5167
+ * Editing arms explicitly — never per keystroke — and the buffer's whole life
5168
+ * against the file and the poll is `side-edit.ts`'s to decide: a refresh over
5169
+ * a dirty buffer keeps the buffer, a file that moved underneath raises the
5170
+ * reload-or-overwrite banner, and the one save path carries the sha the buffer
5171
+ * is based on so the host can refuse a stale write. While editing, the layout
5172
+ * trades the diff's hole-aligned grid for a dense editor column (same
5173
+ * metrics, same gutter rhythm): one grid cannot stay diff-aligned AND hold a
5174
+ * dense buffer whenever deletions outrun additions, and re-diffing per
5175
+ * keystroke is exactly the editor-library work the first cut declines.
5176
+ *
5177
+ * `tooLarge` and `binary` fall back to the unified view the pane already had
5178
+ * (history and compare keep it unconditionally), with a notice — a silently
5179
+ * different view reads as a broken one, not a guarded one.
5180
+ */
5181
+ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
5182
+ t: Translate
5183
+ path: string
5184
+ palette: string
5185
+ statsPath: string | undefined
5186
+ fetchSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
5187
+ /** Save the editor buffer; the host refuses a stale sha and nothing is written. */
5188
+ writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
5189
+ /** Names the view the fetch belongs to, as `viewKey` does for the diff cache. */
5190
+ scopeKey: string
5191
+ /** Refresh generation: a new one means the tree was re-read, so refetch. */
5192
+ gen: number
5193
+ /** The drawer's polled view of this file's HEAD-diff; a CHANGE in it means
5194
+ * the drawer noticed the file move, so the pane refetches even between
5195
+ * refresh generations — this is how the poll reaches a dirty buffer. */
5196
+ fallbackSegment: string
5197
+ fallbackLoading: boolean
5198
+ /** Run one block action; a discard routes to the drawer's confirmation. */
5199
+ onBlockAction: (mode: BlockMode, ask: BlockAsk) => Promise<GitOpResult>
5200
+ /** After a successful save: refresh the tree and the pane together. */
5201
+ onSaved: () => void
5202
+ /** Reports the buffer's dirty flag outward: the drawer guards every
5203
+ * gesture that would drop the buffer (file selection, close, main tab)
5204
+ * on it, so it must live where those gestures are handled. */
5205
+ onDirtyChange: (dirty: boolean) => void
5206
+ }): ReactNode {
5207
+ const [layer, setLayer] = useState<SideLayer>('unstaged')
5208
+ // How much of the pane the left column gets. Lives here rather than in the
5209
+ // drawer so it is one setting for the pane, and survives a file switch —
5210
+ // the reader sized the columns for how they read, not for one file.
5211
+ const [split, setSplit] = useState(0.5)
5212
+ const colsRef = useRef<HTMLDivElement>(null)
5213
+ /** The pane's one vertical scroller — what "next change" moves. */
5214
+ const scrollRef = useRef<HTMLDivElement>(null)
5215
+ const { goToChange } = useChangeNav(scrollRef)
5216
+
5217
+ const [sides, setSides] = useState<FileSides | null>(null)
5218
+ // Set when the RPC itself failed — most plausibly a host half older than
5219
+ // this client (the two halves reload on different cycles). The unified view
5220
+ // still renders, so an old host costs the new pane, not the diff.
5221
+ const [failed, setFailed] = useState(false)
5222
+ // The block under the pointer, or null over context rows and gutters. Hover
5223
+ // names the BLOCK, not the cell: the outline and the buttons belong to a
5224
+ // whole run of rows, and a per-cell affordance would scatter them.
5225
+ const [hotBlock, setHotBlock] = useState<number | null>(null)
5226
+ // The block whose stage/unstage call is in flight, disabling its buttons.
5227
+ const [pendingBlock, setPendingBlock] = useState<number | null>(null)
5228
+ // The editable right column's state and its one save path. `saving` disables
5229
+ // the controls for the call's duration; `saveFailed` carries a non-stale
5230
+ // failure's sentence (the stale case is `edit.conflict`'s banner instead).
5231
+ const [edit, setEdit] = useState<EditState>(DISARMED)
5232
+ const [saving, setSaving] = useState(false)
5233
+ const [saveFailed, setSaveFailed] = useState<{ title: string; detail: string } | null>(null)
5234
+ // The layer a dirty-buffer tab switch is waiting on the reader to confirm.
5235
+ const [pendingLayer, setPendingLayer] = useState<SideLayer | null>(null)
5236
+ // Internal fetch generation: a stale save or the banner's reload refetch
5237
+ // without waiting for the drawer's next refresh.
5238
+ const [refetch, setRefetch] = useState(0)
5239
+ // Which edit session the fetched payload belongs to, and what a landing
5240
+ // payload may do with the buffer. The ref is read inside the fetch callback
5241
+ // (which closes over a render that may be several states old by the time the
5242
+ // answer arrives), and the adopt mode is consumed once by the next run.
5243
+ const idRef = useRef('')
5244
+ const adoptRef = useRef<'auto' | 'reload'>('auto')
5245
+ const editRef = useRef(edit)
5246
+ editRef.current = edit
5247
+
5248
+ // Switching tabs refetches: the two layers are different diffs of the same
5249
+ // file, and neither is a transform of the other client-side. A change in the
5250
+ // drawer's polled segment for this file refetches too — the poll's way of
5251
+ // saying the file moved — which is what lets a change under a DIRTY buffer
5252
+ // raise the banner within one poll interval instead of at the next refresh.
5253
+ //
5254
+ // Only a NEW file/layer/scope may blank the pane and disarm the editor; a
5255
+ // refetch of the same identity keeps the current payload on screen until the
5256
+ // answer lands, because blanking it would unmount the editor mid-keystroke.
5257
+ useEffect(() => {
5258
+ const id = `${scopeKey}\x1f${path}\x1f${layer}`
5259
+ const identityChanged = idRef.current !== id
5260
+ if (identityChanged) idRef.current = id
5261
+ const adopt = identityChanged ? 'reset' : adoptRef.current
5262
+ adoptRef.current = 'auto'
5263
+ const ctrl = new AbortController()
5264
+ let alive = true
5265
+ if (identityChanged) {
5266
+ setSides(null)
5267
+ setSaveFailed(null)
5268
+ setPendingLayer(null)
5269
+ }
5270
+ if (identityChanged || !editRef.current.armed) setFailed(false)
5271
+ fetchSides(statsPath, path, layer, ctrl.signal)
5272
+ .then(value => {
5273
+ if (!alive) return
5274
+ // While armed, a failed background refetch keeps the pane as it is:
5275
+ // dropping the editor over a transient RPC failure would cost the
5276
+ // buffer's DOM (focus, IME composition) for no reader benefit.
5277
+ if (value === null) {
5278
+ if (!editRef.current.armed) setFailed(true)
5279
+ return
5280
+ }
5281
+ setSides(value)
5282
+ setEdit(prev => adopt === 'reset' ? resetSides(prev, value)
5283
+ : adopt === 'reload' ? reloadSides(prev, value)
5284
+ : applySides(prev, value))
5285
+ })
5286
+ .catch(() => { if (alive && !editRef.current.armed) setFailed(true) })
5287
+ return () => { alive = false; ctrl.abort() }
5288
+ }, [fetchSides, statsPath, path, layer, scopeKey, gen, fallbackSegment, refetch])
5289
+
5290
+ const lang = shikiLangOf(path)
5291
+ const shikiTheme = shikiThemeOf(palette)
5292
+ const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
5293
+ const rows = useMemo(() => {
5294
+ if (sides === null || sides.diff.length === 0) return []
5295
+ const file = parsePatch(sides.diff)
5296
+ // A diff with no hunk (mode-only change, or text patch-model cannot parse)
5297
+ // has no rows to align; the no-change treatment below is the honest view.
5298
+ return file === null ? [] : alignRows(file)
5299
+ }, [sides])
5300
+ // Highlight each column as one file — a row is not a program, and lexing
5301
+ // fragments is what made the unified view paint keywords as plain text.
5302
+ //
5303
+ // These are UNDEFINED until a lazy grammar loads, and stay undefined for a
5304
+ // file whose extension has no grammar at all (`go.mod`, `Dockerfile`), so
5305
+ // every read below is optional-chained. `renderSideCode` already takes
5306
+ // `undefined` and renders the plain text for it; what crashes is indexing
5307
+ // the array itself, and `strict` is off in tsconfig, so the compiler will
5308
+ // not say so.
5309
+ const leftSyntax = useMemo(
5310
+ () => highlightFile(rows.map(row => row.left === null ? '' : row.left.text), lang, shikiTheme),
5311
+ [rows, lang, shikiTheme, grammarGen],
5312
+ )
5313
+ const rightSyntax = useMemo(
5314
+ () => highlightFile(rows.map(row => row.right === null ? '' : row.right.text), lang, shikiTheme),
5315
+ [rows, lang, shikiTheme, grammarGen],
5316
+ )
5317
+
5318
+ /** The editor half of the pane, present only on the unstaged layer. */
5319
+ const editable = layer === 'unstaged' && edit.armed
5320
+ const dirty = isDirty(edit)
5321
+ // Whether this payload may enter the editor at all: text carrying \r would
5322
+ // be normalised to \n by the textarea the moment it landed, and the next
5323
+ // save would rewrite every line ending in the file. The gate lives in
5324
+ // `side-edit.ts` with the rest of the buffer's rules.
5325
+ const armable = sides !== null && editableSides(sides)
5326
+ // Which sentence the withheld editor gets: CRLF and a non-UTF-8 encoding are
5327
+ // different problems, and one message for both leaves the reader guessing
5328
+ // whether converting line endings would help.
5329
+ const refusal = sides === null ? null : armRefusal(sides)
5330
+
5331
+
5332
+ // The drawer guards every gesture that would drop the buffer — selecting
5333
+ // another file, closing, switching the main tab — so it needs the flag as
5334
+ // it changes, not at click time from a stale render. Reported on the FLAG
5335
+ // (not the buffer) so it fires on the transitions that matter; the cleanup
5336
+ // clears it when this pane unmounts, so no orphaned flag prompts later.
5337
+ useEffect(() => {
5338
+ onDirtyChange(dirty)
5339
+ return () => { onDirtyChange(false) }
5340
+ }, [dirty, onDirtyChange])
5341
+ // The buffer's lines and their highlight, for the editor's underlay: the
5342
+ // visible text under the transparent textarea, which is what keeps syntax
5343
+ // coloring and the caret on the same grid while typing.
5344
+ const bufferLines = useMemo(() => edit.buffer.split('\n'), [edit.buffer])
5345
+ // What Tab inserts, learned from the file rather than configured. Keyed on
5346
+ // the BASE text, not the buffer: re-detecting mid-edit would let a couple of
5347
+ // freshly typed lines redefine the unit under the reader's hands.
5348
+ const indentOfBuffer = useMemo(() => detectIndent(edit.baseText), [edit.baseText])
5349
+ // The index side as one text, which is what the editor tints against while
5350
+ // the reader types. It is the diff's own left column joined back up — every
5351
+ // row of a full-context diff carries a left cell unless the line is an
5352
+ // addition, which by definition is not on that side.
5353
+ const indexText = useMemo(() => {
5354
+ const left = rows.filter(row => row.left !== null).map(row => row.left!.text)
5355
+ return left.length === 0 ? '' : left.join('\n') + '\n'
5356
+ }, [rows])
5357
+ // The editor's buffer is a whole file, so it takes the whole-file pass —
5358
+ // and it lags the typing, for the same reason the browser's does: a Shiki
5359
+ // pass per keystroke is what makes a large file unusable to edit.
5360
+ const paintedLines = useIdleValue(bufferLines, HIGHLIGHT_IDLE_MS)
5361
+ const editSyntax = useMemo(
5362
+ () => edit.armed && paintedLines.length <= HIGHLIGHT_LINE_CAP
5363
+ ? highlightWholeFile(paintedLines, lang, shikiTheme)
5364
+ : [],
5365
+ [edit.armed, paintedLines, lang, shikiTheme, grammarGen],
5366
+ )
5367
+ // The left column while editing renders dense — one row per INDEX line, no
5368
+ // holes — because the right column is now the dense buffer; a hole-aligned
5369
+ // left beside a dense right is the alignment the diff view owes, not the
5370
+ // editor. Each entry keeps its index into `rows` for its syntax tokens.
5371
+ const leftRows = useMemo(() => rows.map((row, i) => ({ row, i })).filter(entry => entry.row.left !== null), [rows])
5372
+
5373
+ // Arming drops the caret straight into the buffer: the click that armed the
5374
+ // editor said "I want to type here", and a second click to focus is a tax.
5375
+
5376
+ /** Arm the editor from the payload on screen; the unstaged tab, and only
5377
+ * for a payload `editableSides` accepts — armEdit itself refuses the rest,
5378
+ * so even a stray call cannot put CRLF text into the buffer. */
5379
+ const arm = (): void => {
5380
+ if (sides === null || layer !== 'unstaged' || !editableSides(sides)) return
5381
+ setEdit(prev => armEdit(prev, sides))
5382
+ }
5383
+
5384
+ /**
5385
+ * The one save path, shared by the Save button, Ctrl/Cmd+S and the banner's
5386
+ * overwrite action — they differ only in WHICH sha the host is asked to
5387
+ * check: the buffer's basis for a save, the file as it stands NOW for an
5388
+ * explicit overwrite of a concurrent writer's version.
5389
+ *
5390
+ * On success the basis moves to the sha the host read back and the drawer
5391
+ * refreshes (tree and pane together). On `stale` the banner goes up and the
5392
+ * pane refetches WITHOUT touching the buffer, so the banner's reload and
5393
+ * overwrite actions read the file's true current state. Everything else is
5394
+ * a failed save with a sentence.
5395
+ */
5396
+ const runSave = async (expectedSha: string): Promise<void> => {
5397
+ if (sides === null || !dirty || saving) return
5398
+ const savedText = edit.buffer
5399
+ // The edit session this save belongs to. A slow RPC can outlive a file or
5400
+ // layer switch, and applying THIS save's outcome to the NEXT file's edit
5401
+ // state would re-base that buffer onto text it never held — so every
5402
+ // pane-local effect below is gated on the session still being current.
5403
+ // The tree refresh on success is not: the file on disk did move.
5404
+ const session = idRef.current
5405
+ setSaving(true)
5406
+ try {
5407
+ const result = await writeChecked(statsPath, path, savedText, expectedSha, new AbortController().signal)
5408
+ const stillHere = idRef.current === session
5409
+ if (result === null) {
5410
+ if (stillHere) setSaveFailed({ title: t('saveUnavailable'), detail: '' })
5411
+ } else if (result.ok) {
5412
+ if (stillHere) {
5413
+ setSaveFailed(null)
5414
+ setEdit(prev => applySaveOk(prev, savedText, result.sha ?? ''))
5415
+ }
5416
+ onSaved()
5417
+ } else if (result.failure === 'stale') {
5418
+ if (stillHere) {
5419
+ setEdit(prev => markConflict(prev))
5420
+ setRefetch(n => n + 1)
5421
+ }
5422
+ } else {
5423
+ if (stillHere) setSaveFailed({ title: t('saveFailed'), detail: (result.error ?? '').trim() })
5424
+ }
5425
+ } finally {
5426
+ setSaving(false)
5427
+ }
5428
+ }
5429
+
5430
+ /**
5431
+ * The banner's two answers to a file that moved underneath. Overwrite may
5432
+ * only run once the post-refusal refetch has landed (the fresh targetSha is
5433
+ * what the host checks the overwrite against); until then the button waits,
5434
+ * because re-sending the refused sha would just refuse again.
5435
+ */
5436
+ const canOverwrite = dirty && edit.conflict && sides !== null && sides.targetSha !== edit.baseSha
5437
+ const overwrite = (): Promise<void> => sides === null ? Promise.resolve() : runSave(sides.targetSha)
5438
+ /** Reload: the reader chose the file over the buffer; drop the edits. */
5439
+ const reload = (): void => {
5440
+ setSaveFailed(null)
5441
+ adoptRef.current = 'reload'
5442
+ setRefetch(n => n + 1)
5443
+ }
5444
+ /** Revert the buffer to its basis, in place; the conflict flag stands. */
5445
+ const revert = (): void => {
5446
+ setSaveFailed(null)
5447
+ setEdit(prev => ({ ...prev, buffer: prev.baseText }))
5448
+ }
5449
+
5450
+ /**
5451
+ * A layer tab is one click away from dropping the buffer: with unsaved
5452
+ * edits the click asks first, and only the dialog's answer switches.
5453
+ */
5454
+ const switchLayer = (next: SideLayer): void => {
5455
+ if (next === layer) return
5456
+ if (dirty) {
5457
+ setPendingLayer(next)
5458
+ return
5459
+ }
5460
+ setLayer(next)
5461
+ }
5462
+ const confirmLeave = (): void => {
5463
+ const next = pendingLayer
5464
+ setPendingLayer(null)
5465
+ if (next !== null) setLayer(next)
5466
+ }
5467
+
5468
+ /** Ctrl/Cmd+S inside the pane: the editor's other save affordance. */
5469
+ const onPaneKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
5470
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
5471
+ event.preventDefault()
5472
+ if (dirty && !saving) void runSave(edit.baseSha)
5473
+ }
5474
+ // F7 and Shift+F7, the spelling IDEA's diff viewer taught. Chosen over
5475
+ // Alt+Arrow because CodeMirror's default keymap binds those to moving a
5476
+ // line, and the armed editor lives inside this same pane.
5477
+ if (event.key === 'F7') {
5478
+ event.preventDefault()
5479
+ goToChange(event.shiftKey ? -1 : 1)
5480
+ }
5481
+ }
5482
+
5483
+ /**
5484
+ * One bubbling hover listener turns the cell under the pointer into its
5485
+ * block id: every changed row's code cells carry `data-block`, so `closest`
5486
+ * reads the block off whatever the pointer is over — no handler per cell,
5487
+ * and a pointer over context or a gutter simply clears the hot block.
5488
+ */
5489
+ /**
5490
+ * The divider: a ratio, not a pixel width, so the columns keep their
5491
+ * proportion when the drawer itself is resized.
5492
+ *
5493
+ * Clamped well short of either edge — a column dragged to nothing looks
5494
+ * like a broken pane, and there is no affordance to drag it back out of.
5495
+ */
5496
+ const onSplitDrag = (clientX: number): void => {
5497
+ const box = colsRef.current?.getBoundingClientRect()
5498
+ if (box === undefined || box.width === 0) return
5499
+ const ratio = (clientX - box.left) / box.width
5500
+ setSplit(Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, ratio)))
5501
+ }
5502
+
5503
+ const onBodyHover = (event: ReactMouseEvent<HTMLDivElement>): void => {
5504
+ const hit = (event.target as Element).closest('[data-block]')
5505
+ const id = hit === null ? null : Number(hit.getAttribute('data-block'))
5506
+ setHotBlock(prev => (prev === id ? prev : id))
5507
+ }
5508
+
5509
+ /**
5510
+ * Run one block action with the coordinates of the diff on screen.
5511
+ *
5512
+ * Discard never acts from the click — the drawer opens the confirmation,
5513
+ * and the confirmed call carries this same snapshot, so a file that moved
5514
+ * underneath the dialog is refused host-side rather than re-derived from
5515
+ * whatever the poll has fetched since. Stage and unstage run now; the
5516
+ * clicked block's buttons stay disabled until the answer lands, and the
5517
+ * drawer's op lock refuses any other block click meanwhile.
5518
+ */
5519
+ const runBlock = async (mode: BlockMode, block: number): Promise<void> => {
5520
+ if (sides === null) return
5521
+ const ask: BlockAsk = {
5522
+ path, layer, diffSha: sides.diffSha,
5523
+ lines: blockLines(rows, block),
5524
+ ...blockTally(rows, block),
5525
+ wholeFile: blockIsWholeFile(rows, block),
5526
+ }
5527
+ if (mode === 'discard') {
5528
+ void onBlockAction(mode, ask)
5529
+ return
5530
+ }
5531
+ setPendingBlock(block)
5532
+ try {
5533
+ await onBlockAction(mode, ask)
5534
+ } finally {
5535
+ setPendingBlock(null)
5536
+ }
5537
+ }
5538
+
5539
+ /** The pane the drawer had before this view existed, notice included. */
5540
+ const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
5541
+ ? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} />
5542
+ : <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
5543
+
5544
+ if (failed) return unifiedFallback()
5545
+ if (sides === null) return <div className={css.empty}>{t('loadingDiff')}</div>
5546
+ if (sides.binary) return <div className={css.empty}>{t('binaryFile')}</div>
5547
+ if (sides.tooLarge) {
5548
+ return (
5549
+ <>
5550
+ <div className={css.sideNotice}>{t('diffTooLarge')}</div>
5551
+ {unifiedFallback()}
5552
+ </>
5553
+ )
5554
+ }
5555
+ // The tabs are the pane's, not one layer's: an empty diff here (a fully
5556
+ // staged file's unstaged side, a file with nothing staged) is one click from
5557
+ // the other layer, and the Edit button still arms — the working tree is the
5558
+ // edit target even when every change in it is already staged. So the
5559
+ // no-change treatment below is a state of the BODY (`sideBodyState`), never
5560
+ // an early return for the pane: returning here is what used to blank the
5561
+ // tabs for exactly these files.
5562
+ const bodyState = sideBodyState(rows, editable)
5563
+ /**
5564
+ * How many separate places this file changed — the count the nav walks.
5565
+ *
5566
+ * Offered only for the read view. While armed, the left column is the index
5567
+ * side rendered DENSE and the right one is a buffer whose line count has
5568
+ * already diverged from it, so scrolling to a block would put the change at
5569
+ * the top of one column and unrelated code at the top of the other.
5570
+ */
5571
+ const changes = bodyState.kind === 'rows' ? blockCount(rows) : 0
5572
+ // The hovered block's first row hosts the action bar; a del-only block has
5573
+ // no right cell, so its bar rides the left one instead. In the editor
5574
+ // layout the left column is dense, so the bar rides its first left row.
5575
+ const hotFirst = hotBlock === null ? -1 : rows.findIndex(row => row.block === hotBlock)
5576
+ const hotFirstLeft = hotBlock === null ? -1 : leftRows.findIndex(entry => entry.row.block === hotBlock)
5577
+ // Dirty buffer, no block actions: a patch computed from the loaded diff
5578
+ // would land on top of edits the patch knows nothing about.
5579
+ const barDisabled = pendingBlock !== null || dirty
5580
+ const blockBar = (block: number): ReactNode => (
5581
+ <span className={css.blockBar}>
5582
+ {layer === 'staged' ? (
5583
+ <button
5584
+ type="button"
5585
+ className={css.blockBtn}
5586
+ disabled={barDisabled}
5587
+ onClick={() => { void runBlock('unstage', block) }}
5588
+ >{t('blockUnstage')}</button>
5589
+ ) : (
5590
+ <>
5591
+ <button
5592
+ type="button"
5593
+ className={css.blockBtn}
5594
+ disabled={barDisabled}
5595
+ onClick={() => { void runBlock('stage', block) }}
5596
+ >{t('blockStage')}</button>
5597
+ <button
5598
+ type="button"
5599
+ className={`${css.blockBtn} ${css.blockBtnDanger}`}
5600
+ disabled={barDisabled}
5601
+ onClick={() => { void runBlock('discard', block) }}
5602
+ >{t('blockDiscard')}</button>
5603
+ </>
5604
+ )}
5605
+ </span>
5606
+ )
5607
+ /** Clicking the working-tree column is the arm gesture readers will try
5608
+ * first — unless the click was really a text selection, or landed on a
5609
+ * block button, in which case it keeps its own meaning. */
5610
+ const armFromCell = (event: ReactMouseEvent<HTMLSpanElement>): void => {
5611
+ if (edit.armed) return
5612
+ if ((event.target as Element).closest('button') !== null) return
5613
+ const selection = window.getSelection()
5614
+ if (selection !== null && !selection.isCollapsed) return
5615
+ arm()
5616
+ }
5617
+ return (
5618
+ /* `tabIndex={-1}` is what makes F7 reachable. The handler below is on
5619
+ this element, so it only sees keys whose target is inside it — and
5620
+ clicking diff text, which is not focusable, otherwise leaves focus on
5621
+ the document body and the key never arrives. A negative index keeps the
5622
+ pane out of the tab order while letting a click land focus here. */
5623
+ <div className={css.sidePane} tabIndex={-1} onKeyDown={onPaneKeyDown}>
5624
+ <div className={css.sideTabs}>
5625
+ <button
5626
+ type="button"
5627
+ aria-pressed={layer === 'unstaged'}
5628
+ className={layer === 'unstaged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
5629
+ onClick={() => switchLayer('unstaged')}
5630
+ >{t('tabUnstaged')}</button>
5631
+ <button
5632
+ type="button"
5633
+ aria-pressed={layer === 'staged'}
5634
+ className={layer === 'staged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
5635
+ onClick={() => switchLayer('staged')}
5636
+ >{t('tabStaged')}</button>
5637
+ {/* A file whose whole delta is one line is unfindable by scrolling:
5638
+ the tint only shows once you are already looking at it. The row
5639
+ model knows where every change is, so these two say so. The count
5640
+ is the other half of the answer — "there is one place to look" is
5641
+ what stops the hunt. */}
5642
+ {changes > 0 ? (
5643
+ <span className={css.sideNav}>
5644
+ <button
5645
+ type="button"
5646
+ className={css.blockBtn}
5647
+ title={t('prevChangeHint')}
5648
+ aria-label={t('prevChange')}
5649
+ onClick={() => { goToChange(-1) }}
5650
+ ><NavGlyph of="prev" /></button>
5651
+ <button
5652
+ type="button"
5653
+ className={css.blockBtn}
5654
+ title={t('nextChangeHint')}
5655
+ aria-label={t('nextChange')}
5656
+ onClick={() => { goToChange(1) }}
5657
+ ><NavGlyph of="next" /></button>
5658
+ <span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
5659
+ </span>
5660
+ ) : null}
5661
+ {/* Editing arms explicitly and saves explicitly — the two halves of
5662
+ "never per keystroke". Save enables only while dirty; Revert drops
5663
+ the buffer back onto its basis without touching the file. A
5664
+ payload the CRLF gate refuses offers no Edit button at all — the
5665
+ notice below says why rather than leaving a button that does
5666
+ nothing. */}
5667
+ {layer === 'unstaged' ? (
5668
+ <span className={css.sideActions}>
5669
+ {edit.armed ? (
5670
+ <>
5671
+ <button
5672
+ type="button"
5673
+ className={`${css.blockBtn}${dirty ? ` ${css.sideSaveReady}` : ''}`}
5674
+ disabled={!dirty || saving}
5675
+ onClick={() => { void runSave(edit.baseSha) }}
5676
+ >{t('fileSave')}</button>
5677
+ <button
5678
+ type="button"
5679
+ className={css.blockBtn}
5680
+ disabled={!dirty || saving}
5681
+ onClick={revert}
5682
+ >{t('fileRevert')}</button>
5683
+ </>
5684
+ ) : armable ? (
5685
+ <button type="button" className={css.blockBtn} onClick={arm}>{t('editFile')}</button>
5686
+ ) : null}
5687
+ </span>
5688
+ ) : null}
5689
+ </div>
5690
+ {dirty ? <div className={css.sideNotice}>{t('editingNotice')}</div> : null}
5691
+ {layer === 'unstaged' && refusal !== null && !edit.armed ? <div className={css.sideNotice}>{t(refusal === 'encoding' ? 'encodingNotice' : 'crlfNotice')}</div> : null}
5692
+ {/* §4's row: the file moved underneath a dirty buffer — by the poll's
5693
+ notice or by a refused save — and the reader chooses which version
5694
+ survives. Overwrite waits for the refetch the refusal triggered, so
5695
+ it is checked against the file as it truly stands. */}
5696
+ {dirty && edit.conflict ? (
5697
+ <div className={css.sideBanner} role="alert">
5698
+ <span className={css.sideBannerTitle}>{t('staleTitle')}</span>
5699
+ <span>{t('staleBody')}</span>
5700
+ <span className={css.sideBannerActs}>
5701
+ <button type="button" className={`${css.blockBtn} ${css.blockBtnDanger}`} disabled={saving} onClick={reload}>{t('staleReload')}</button>
5702
+ <button type="button" className={css.blockBtn} disabled={!canOverwrite || saving} onClick={() => { void overwrite() }}>{t('staleOverwrite')}</button>
5703
+ </span>
5704
+ </div>
5705
+ ) : null}
5706
+ {saveFailed !== null ? (
5707
+ <div className={css.sideBanner} role="alert">
5708
+ <span className={css.sideBannerTitle}>{saveFailed.title}</span>
5709
+ {saveFailed.detail.length > 0 ? <span>{saveFailed.detail}</span> : null}
5710
+ <span className={css.sideBannerActs}>
5711
+ <button type="button" className={css.blockBtn} disabled={!dirty || saving} onClick={() => { void runSave(edit.baseSha) }}>{t('saveRetry')}</button>
5712
+ </span>
5713
+ </div>
5714
+ ) : null}
5715
+ {/* Two columns that scroll sideways independently, with a divider the
5716
+ reader can drag. One grid spanning both sides could not do this: its
5717
+ tracks are sized by the widest line in the file, so a drag moved
5718
+ nothing on exactly the wide files where the space matters. Vertical
5719
+ alignment survives the split because both columns render one row per
5720
+ aligned row at the same line height — the diff decides the rows, the
5721
+ layout only decides how much width each side gets. */}
5722
+ <div ref={scrollRef} className={css.sideScroll}>
5723
+ {bodyState.kind === 'empty' ? (
5724
+ <div className={css.empty}>{t('noTextDiff')}</div>
5725
+ ) : (
5726
+ <div
5727
+ ref={colsRef}
5728
+ className={css.sideCols}
5729
+ onMouseOver={onBodyHover}
5730
+ onMouseLeave={() => { setHotBlock(null) }}
5731
+ >
5732
+ <div className={css.sideCol} style={{ flexBasis: `${split * 100}%` }}>
5733
+ <div className={css.sideColGrid}>
5734
+ {bodyState.kind === 'editor' ? (
5735
+ /* While armed the left column renders the index side DENSE —
5736
+ one row per index line, no diff holes — because the right
5737
+ column is a buffer whose line count diverges from the diff
5738
+ the moment a keystroke lands. */
5739
+ leftRows.map((entry, k) => {
5740
+ const { row, i } = entry
5741
+ const hot = hotBlock !== null && row.block === hotBlock
5742
+ const hotClass = hot ? ` ${css.sideBlockHot}` : ''
5743
+ return (
5744
+ <Fragment key={`l${i}`}>
5745
+ <span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left!.line}</span>
5746
+ <span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.block >= 0 ? row.block : undefined}>
5747
+ {renderSideCode(row.left, leftSyntax?.[i])}
5748
+ {hot && k === hotFirstLeft ? blockBar(row.block) : null}
5749
+ </span>
5750
+ </Fragment>
5751
+ )
5752
+ })
5753
+ ) : (
5754
+ rows.map((row, i) => {
5755
+ const hot = hotBlock !== null && row.block === hotBlock
5756
+ const hotClass = hot ? ` ${css.sideBlockHot}` : ''
5757
+ // The block's action bar rides in this column only for a row
5758
+ // with no right-hand side — a pure deletion, where the right
5759
+ // column has no cell to hang it on.
5760
+ const bar = hot && i === hotFirst && row.right === null ? blockBar(row.block) : null
5761
+ return (
5762
+ <Fragment key={i}>
5763
+ <span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left === null ? '' : row.left.line}</span>
5764
+ <span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.block >= 0 ? row.block : undefined}>
5765
+ {renderSideCode(row.left, leftSyntax?.[i])}
5766
+ {bar}
5767
+ </span>
5768
+ </Fragment>
5769
+ )
5770
+ })
5771
+ )}
5772
+ </div>
5773
+ </div>
5774
+ <PaneDivider label={t('resizeSides')} onDrag={onSplitDrag} />
5775
+ <div className={`${css.sideCol} ${css.sideColRight}`}>
5776
+ {bodyState.kind === 'editor' ? (
5777
+ <CodeEditor
5778
+ value={edit.buffer}
5779
+ original={indexText}
5780
+ onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
5781
+ syntax={editSyntax}
5782
+ indent={indentOfBuffer}
5783
+ ariaLabel={path}
5784
+ onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
5785
+ />
5786
+ ) : (
5787
+ <div className={css.sideColGrid}>
5788
+ {rows.map((row, i) => {
5789
+ const hot = hotBlock !== null && row.block === hotBlock
5790
+ const hotClass = hot ? ` ${css.sideBlockHot}` : ''
5791
+ const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
5792
+ return (
5793
+ <Fragment key={i}>
5794
+ <span className={`${sideNumClass(row, 'right')}${hotClass}`}>{row.right === null ? '' : row.right.line}</span>
5795
+ <span
5796
+ className={`${css.sideCode} ${sideCodeClass(row, 'right')}${hotClass}${layer === 'unstaged' && armable ? ` ${css.sideArmable}` : ''}`}
5797
+ data-block={row.block >= 0 ? row.block : undefined}
5798
+ onClick={layer === 'unstaged' && armable ? armFromCell : undefined}
5799
+ >
5800
+ {renderSideCode(row.right, rightSyntax?.[i])}
5801
+ {bar}
5802
+ </span>
5803
+ </Fragment>
5804
+ )
5805
+ })}
5806
+ </div>
5807
+ )}
5808
+ </div>
5809
+ </div>
5810
+ )}
5811
+ </div>
5812
+ {pendingLayer !== null ? (
5813
+ <LeaveEditsConfirm t={t} path={path} onCancel={() => { setPendingLayer(null) }} onConfirm={confirmLeave} />
5814
+ ) : null}
5815
+ </div>
5816
+ )
5817
+ }
5818
+
5819
+ /**
5820
+ * The unsaved-edits guard, rendered at both sites that defer a gesture on the
5821
+ * buffer's answer: the pane's layer-tab switch, and the drawer level (file
5822
+ * selection, main tab, source switch, close) for every gesture that would
5823
+ * drop the buffer. Same reason as the roll-back confirmation — the click it
5824
+ * answers to is one gesture away from losing work. Cancel holds the initial
5825
+ * focus and Escape closes, because the default answer to losing edits is no.
5826
+ */
5827
+ function LeaveEditsConfirm({ t, path, onCancel, onConfirm }: {
5828
+ t: Translate
5829
+ path: string
5830
+ onCancel: () => void
5831
+ onConfirm: () => void
5832
+ }): ReactNode {
5833
+ const stayRef = useRef<HTMLButtonElement>(null)
5834
+ useEffect(() => { stayRef.current?.focus() }, [])
5835
+ useEffect(() => {
5836
+ // Capture phase, like the roll-back dialog: while a question about edits
5837
+ // is open, Escape answers it and nothing else — consumed here, before it
5838
+ // can reach the page's other Escape handlers (an open picker's dismiss,
5839
+ // the commit box's undo).
5840
+ const onKey = (event: KeyboardEvent): void => {
5841
+ if (event.key !== 'Escape') return
5842
+ event.stopPropagation()
5843
+ onCancel()
5844
+ }
5845
+ window.addEventListener('keydown', onKey, true)
5846
+ return () => { window.removeEventListener('keydown', onKey, true) }
5847
+ }, [onCancel])
5848
+ return (
5849
+ <div className={css.confirmScrim} onClick={onCancel}>
5850
+ <div
5851
+ className={css.confirmBox}
5852
+ role="alertdialog"
5853
+ aria-modal="true"
5854
+ aria-label={t('unsavedTitle')}
5855
+ onClick={event => event.stopPropagation()}
5856
+ >
5857
+ <div className={css.confirmTitle}>{t('unsavedTitle')}</div>
5858
+ <div className={css.confirmBody}>{t('unsavedBody', { path })}</div>
5859
+ <div className={css.confirmActions}>
5860
+ <button ref={stayRef} type="button" className={css.btn} onClick={onCancel}>{t('unsavedStay')}</button>
5861
+ <button type="button" className={`${css.btn} ${css.btnDanger}`} onClick={onConfirm}>{t('unsavedLeave')}</button>
5862
+ </div>
5863
+ </div>
5864
+ </div>
5865
+ )
5866
+ }
5867
+
5868
+ /** Line-number cell class: a PRESENT cell of a changed row carries its side's
5869
+ * tint into the gutter; an absent one stays blank, the way a split diff shows
5870
+ * a one-sided change with an empty opposite pane rather than a tinted void. */
5871
+ function sideNumClass(row: SideRow, side: 'left' | 'right'): string {
5872
+ const cell = side === 'left' ? row.left : row.right
5873
+ if (cell === null || row.kind === 'same') return css.sideNum
5874
+ return `${css.sideNum} ${side === 'left' ? css.sideNumDel : css.sideNumAdd}`
5875
+ }
5876
+
5877
+ /** Code cell class: deletions tint left, additions right, context stays quiet. */
5878
+ function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
5879
+ const cell = side === 'left' ? row.left : row.right
5880
+ if (cell === null || row.kind === 'same') return css.sideCodeSame
5881
+ return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
5882
+ }
5883
+
5884
+ /** One cell's Shiki runs, or its plain text when no tokens exist. */
5885
+ function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
5886
+ if (cell === null) return ''
5887
+ if (tokens === undefined || tokens.length === 0) return cell.text
5888
+ if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return cell.text
5889
+ return tokens.map((tok, i) => (
5890
+ <span
5891
+ key={i}
5892
+ style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
5893
+ >{tok.text}</span>
5894
+ ))
5895
+ }
5896
+
4432
5897
  /* ---------- shared helpers ---------- */
4433
5898
 
4434
5899
  /** Split a combined `git diff` into path -> its segment text. */