@young1lin/dsh-ui-gitworkbench 0.1.6 → 0.1.8

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.
@@ -196,3 +196,31 @@ export function countBlocks(ids: readonly number[]): number {
196
196
  for (const id of ids) if (id > top) top = id
197
197
  return top + 1
198
198
  }
199
+
200
+ /**
201
+ * Where each change block sits, derived from the rows rather than measured.
202
+ *
203
+ * A windowed pane cannot measure: the block being walked to usually has no
204
+ * element, because the whole point is that it is not on screen. The rows are a
205
+ * fixed height there by construction, so the position is arithmetic.
206
+ *
207
+ * @param blocks - each row's block id, `-1` for a row that is not part of one.
208
+ * @param rowH - row height in px.
209
+ * @param offset - px above the first row, e.g. the grid's top padding.
210
+ * @returns the first row of each block, in the order the blocks appear.
211
+ */
212
+ export function blockTopsFromRows(
213
+ blocks: readonly number[],
214
+ rowH: number,
215
+ offset = 0,
216
+ ): readonly BlockTop[] {
217
+ const tops: BlockTop[] = []
218
+ const seen = new Set<number>()
219
+ for (let i = 0; i < blocks.length; i += 1) {
220
+ const block = blocks[i]!
221
+ if (!Number.isInteger(block) || block < 0 || seen.has(block)) continue
222
+ seen.add(block)
223
+ tops.push({ block, top: offset + i * rowH })
224
+ }
225
+ return tops
226
+ }
@@ -223,6 +223,62 @@ export function highlightWholeFile(
223
223
  return tokenizeLines(lines, lang, theme)
224
224
  }
225
225
 
226
+ /**
227
+ * How many lines above the window are tokenized for context.
228
+ *
229
+ * Shiki lexes a string from its start, so a slice beginning inside a block
230
+ * comment or a template literal would colour as if it were code. Reading a
231
+ * lead-in restores that state for everything but a construct longer than this,
232
+ * at a fraction of the cost of the file: at 4,000 lines the whole-file pass was
233
+ * the entire remaining freeze once the DOM was bounded.
234
+ */
235
+ export const HIGHLIGHT_LEAD_IN = 240
236
+
237
+ /**
238
+ * Runs for the rows in a window, and nothing outside it.
239
+ *
240
+ * This is the pane's whole highlighting cost now, and it is proportional to the
241
+ * viewport rather than to the file. Measured on a 4,000-line file with a
242
+ * one-line change: whole-file passes plus per-line re-lexing froze the pane for
243
+ * 1.4 seconds; the same file behind an extension no grammar claims cost 22ms of
244
+ * script, which is what proved the entire remainder was Shiki.
245
+ *
246
+ * Both passes happen inside the window: the slice pass, which knows about
247
+ * multi-line constructs within its reach, and the per-line re-lex that makes a
248
+ * diff reconstruction colour as top-level code.
249
+ *
250
+ * @param lines - source lines, no leading +/-.
251
+ * @param lang - from {@link shikiLangOf}.
252
+ * @param theme - from {@link shikiThemeOf}.
253
+ * @param from - first row in the window.
254
+ * @param to - one past the last row in the window.
255
+ * @returns an array indexed by ROW, filled only inside the window; undefined
256
+ * when no grammar applies, which the caller already renders as plain text.
257
+ */
258
+ export function highlightWindow(
259
+ lines: readonly string[],
260
+ lang: string | undefined,
261
+ theme: string,
262
+ from: number,
263
+ to: number,
264
+ ): (HighlightRun[] | undefined)[] | undefined {
265
+ const first = Math.max(0, Math.trunc(from))
266
+ const last = Math.min(lines.length, Math.trunc(to))
267
+ if (last <= first) return undefined
268
+ const lead = Math.max(0, first - HIGHLIGHT_LEAD_IN)
269
+ const sliceTok = tokenizeLines(lines.slice(lead, last), lang, theme)
270
+ if (sliceTok === undefined) return undefined
271
+ const out: (HighlightRun[] | undefined)[] = new Array<HighlightRun[] | undefined>(lines.length)
272
+ for (let i = first; i < last; i += 1) {
273
+ const line = lines[i]!
274
+ const together = sliceTok[i - lead] ?? [{ text: line, color: undefined }]
275
+ if (looksLikeCommentLine(line)) { out[i] = together; continue }
276
+ const solo = tokenizeLines([line], lang, theme)?.[0]
277
+ out[i] = solo !== undefined && solo.length > 0 ? solo : together
278
+ }
279
+ return out
280
+ }
281
+
226
282
  function looksLikeCommentLine(text: string): boolean {
227
283
  const t = text.trimStart()
228
284
  return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#')
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Which way the History tab arranges its panes.
3
+ *
4
+ * Two arrangements, kept because they are good at different things and the
5
+ * drawer's width is fixed, so neither wins outright.
6
+ *
7
+ * `columns` puts the commit list beside the tree and the diff. The list is then
8
+ * as tall as the drawer, which is what makes a log scannable — but it is a 26%
9
+ * column, and measured on a 1700px drawer that left about 420px, enough for a
10
+ * subject of roughly forty characters before the ellipsis.
11
+ *
12
+ * `stacked` spans the list across the top instead, the way IDEA's git log does.
13
+ * No subject is cut and thirty rows fit where the column fitted thirteen, at
14
+ * the cost of the height the diff below it would otherwise have had.
15
+ */
16
+ export type HistoryLayout = 'columns' | 'stacked'
17
+
18
+ /** Every layout, in the order the switch offers them. */
19
+ export const HISTORY_LAYOUTS: readonly HistoryLayout[] = ['columns', 'stacked']
20
+
21
+ /** The arrangement the tab had first, and what a reader who never touches the
22
+ * switch gets. */
23
+ export const DEFAULT_HISTORY_LAYOUT: HistoryLayout = 'columns'
24
+
25
+ /**
26
+ * Narrow a stored value to a layout this build has.
27
+ *
28
+ * Storage is a durable boundary: the value on disk was written by whatever
29
+ * build the reader ran last, and a layout this one has since dropped must fall
30
+ * back rather than reach the stylesheet.
31
+ * @param value - value read back from storage.
32
+ * @returns whether it names a layout.
33
+ */
34
+ export function isHistoryLayout(value: unknown): value is HistoryLayout {
35
+ return HISTORY_LAYOUTS.some(known => known === value)
36
+ }
37
+
38
+ /**
39
+ * How tall one commit row is, per layout.
40
+ *
41
+ * The two differ because the row itself differs: a column has no width to
42
+ * spend, so the hash, the author and the date take a line of their own above
43
+ * the subject; spanning the drawer they all fit on one line and a 48px row
44
+ * would spend the stacked layout's scarcest axis on padding.
45
+ *
46
+ * This is the ONE home for the number. The panel publishes the active entry as
47
+ * `--gs-commit-row` and the stylesheet sizes the row from it, while the lane
48
+ * graph draws each row's segment exactly that tall — the lanes only meet across
49
+ * the seam between rows if both come from here. Guarded by
50
+ * `tests/commit-row-height.test.ts`.
51
+ */
52
+ export const COMMIT_ROW_H: Record<HistoryLayout, number> = { columns: 48, stacked: 28 }
@@ -73,11 +73,19 @@ export function apply(ctx: ClientContext): void {
73
73
  },
74
74
  // On-demand single-file diff. With `commit` it is that commit's change to the
75
75
  // file; without it, the working tree (tracked: git diff HEAD --; untracked: synthesized).
76
- fetchFileDiff: async (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal): Promise<string> => {
76
+ fetchFileDiff: async (worktreePath: string | undefined, path: string, commit: string | undefined, range: { base: string; head: string } | undefined, signal: AbortSignal): Promise<string> => {
77
77
  const result = await connection.rpc.call(
78
78
  '/api',
79
79
  'gitWorkbench/fileDiff',
80
- { args: { worktreePath: worktreePath ?? '', path, ...commit === undefined ? {} : { commit } } },
80
+ {
81
+ args: {
82
+ worktreePath: worktreePath ?? '', path,
83
+ // Omitted rather than sent as undefined: a JSON payload with an
84
+ // undefined value is not what the gateway reads back.
85
+ ...commit === undefined ? {} : { commit },
86
+ ...range === undefined ? {} : { base: range.base, head: range.head },
87
+ },
88
+ },
81
89
  signal,
82
90
  ) as { ok: true; value: { diff: string } } | { ok: false; error: { message?: string } }
83
91
  return result.ok ? result.value.diff : ''
@@ -47,6 +47,8 @@ export type WorkbenchKey =
47
47
  | 'bgNone' | 'bgChoose' | 'bgClear' | 'bgBlur' | 'bgVeil' | 'bgWorking' | 'bgFailed' | 'bgTooBig'
48
48
  | 'cssPlaceholder' | 'cssImport' | 'cssApply' | 'cssUnapplied' | 'styleFailed'
49
49
  | 'resizeLabel' | 'resizeCommits' | 'resizeTree' | 'resizeSides'
50
+ // History arrangement switch: the commit list beside the diff, or above it
51
+ | 'historyLayout' | 'layoutColumns' | 'layoutStacked'
50
52
  | 'expandAll' | 'collapseAll' | 'noBranch'
51
53
  | 'copyCommit' | 'copiedCommit'
52
54
  // write operations
@@ -149,6 +151,9 @@ export const zh: Record<WorkbenchKey, string> = {
149
151
  styleFailed: '保存失败',
150
152
  resizeLabel: '拖动调整抽屉宽度',
151
153
  resizeCommits: '拖动调整提交列表宽度',
154
+ historyLayout: '提交列表布局',
155
+ layoutColumns: '并排:列表与差异分列',
156
+ layoutStacked: '上下:列表横跨顶部',
152
157
  resizeTree: '拖动调整文件树宽度',
153
158
  resizeSides: '拖动调整左右两栏宽度',
154
159
  sourceLabel: '统计来源切换',
@@ -384,6 +389,9 @@ export const en: Record<WorkbenchKey, string> = {
384
389
  styleFailed: 'Could not save',
385
390
  resizeLabel: 'Drag to resize the drawer',
386
391
  resizeCommits: 'Drag to resize the commit list',
392
+ historyLayout: 'Commit list layout',
393
+ layoutColumns: 'Side by side: list beside the diff',
394
+ layoutStacked: 'Stacked: list across the top',
387
395
  resizeTree: 'Drag to resize the file tree',
388
396
  resizeSides: 'Drag to resize the two columns',
389
397
  sourceLabel: 'Switch stats source',
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Which rows a long diff actually has to put in the DOM.
3
+ *
4
+ * The side-by-side pane renders the WHOLE file in two columns, so its cost is
5
+ * linear in the file's length rather than in the size of the change. Measured
6
+ * on two files with one changed line each: 22 lines cost 349ms of main thread
7
+ * and 44 cells; 4,000 lines cost 3,868ms, 8,000 cells and 112,000 token spans,
8
+ * with a single 3.6-second frame during which nothing on the page moved. The
9
+ * guard lets a file through at 20,000 lines, which is five times that again.
10
+ *
11
+ * Windowing is exact here rather than approximate, because the pane's rows are
12
+ * a fixed height by construction: `.sideCode` is `white-space: pre` so no line
13
+ * ever wraps, and `.sideColGrid` sets `line-height` and `align-content: start`,
14
+ * which puts row `i` at `i * rowH` with no measuring at all.
15
+ *
16
+ * Pure: no React, no DOM. `tests/row-window.test.ts` loads it directly.
17
+ *
18
+ * @module @young1lin/dsh-ui-gitworkbench/client/row-window
19
+ */
20
+
21
+ /** The pane's row height, matching `.sideColGrid`'s `line-height`. */
22
+ export const DIFF_ROW_H = 20
23
+
24
+ /**
25
+ * The grid's own top padding, from `.sideColGrid`.
26
+ *
27
+ * Only the change walk uses it, and only to place a block within the scrolled
28
+ * content. Being a few pixels out there is invisible — the walk deliberately
29
+ * leaves three rows of context above whatever it lands on, so this is well
30
+ * inside the margin it already keeps — which is why it is stated once here
31
+ * rather than published into the stylesheet as a custom property.
32
+ */
33
+ export const DIFF_GRID_PAD_TOP = 8
34
+
35
+ /**
36
+ * Files at or below this many rows are rendered whole.
37
+ *
38
+ * Windowing has its own costs — a scroll listener, two spacers, and rows that
39
+ * enter and leave the DOM — and none of them buys anything on a file that was
40
+ * never slow. Below the threshold the pane produces exactly the DOM it
41
+ * produced before, so the ordinary case is untouched by this change.
42
+ */
43
+ export const WINDOW_WHOLE_BELOW = 400
44
+
45
+ /**
46
+ * Rows kept beyond each edge of the viewport.
47
+ *
48
+ * Enough that a flick of the wheel lands on rows that are already there:
49
+ * dropping this to zero makes a fast scroll show blank bands, and raising it
50
+ * far just renders the file again.
51
+ */
52
+ export const WINDOW_OVERSCAN = 40
53
+
54
+ /**
55
+ * A viewport height to assume before the scroller has been measured.
56
+ *
57
+ * The first paint happens before any layout effect runs. Rendering nothing
58
+ * until the height is known would flash an empty pane; a screenful is both
59
+ * safe and close.
60
+ */
61
+ const ASSUMED_VIEWPORT_PX = 1200
62
+
63
+ /** The rows to render, and the empty space standing in for the rest. */
64
+ export interface RowWindow {
65
+ /** First row to render. */
66
+ readonly start: number
67
+ /** One past the last row to render. */
68
+ readonly end: number
69
+ /** Height of the spacer above, in px. */
70
+ readonly padTop: number
71
+ /** Height of the spacer below, in px. */
72
+ readonly padBottom: number
73
+ }
74
+
75
+ /**
76
+ * @param value - a number from the DOM, which can be NaN or negative.
77
+ * @param fallback - used when it is neither finite nor usable.
78
+ * @returns a finite, non-negative number.
79
+ */
80
+ function sane(value: number, fallback: number): number {
81
+ return Number.isFinite(value) && value > 0 ? value : fallback
82
+ }
83
+
84
+ /**
85
+ * The window of rows to render for a given scroll position.
86
+ *
87
+ * @param scrollTop - the scroller's current offset in px.
88
+ * @param viewportH - the scroller's visible height in px; 0 before it is measured.
89
+ * @param rowCount - how many rows the file has.
90
+ * @param rowH - row height in px.
91
+ * @param overscan - rows to keep beyond each edge.
92
+ * @returns the rows to render and the spacer heights standing in for the rest.
93
+ */
94
+ export function rowWindow(
95
+ scrollTop: number,
96
+ viewportH: number,
97
+ rowCount: number,
98
+ rowH: number = DIFF_ROW_H,
99
+ overscan: number = WINDOW_OVERSCAN,
100
+ ): RowWindow {
101
+ const rows = Math.max(0, Math.trunc(rowCount))
102
+ const height = sane(rowH, DIFF_ROW_H)
103
+ if (rows <= WINDOW_WHOLE_BELOW) return { start: 0, end: rows, padTop: 0, padBottom: 0 }
104
+
105
+ const top = Number.isFinite(scrollTop) && scrollTop > 0 ? scrollTop : 0
106
+ const view = sane(viewportH, ASSUMED_VIEWPORT_PX)
107
+ const pad = Math.max(0, Math.trunc(overscan))
108
+
109
+ const first = Math.max(0, Math.floor(top / height) - pad)
110
+ const last = Math.min(rows, Math.ceil((top + view) / height) + pad)
111
+ // Scrolled past the end (a file that shrank under a live poll), `last` can
112
+ // land below `first`; an empty window is still a valid answer, and the
113
+ // spacers must add up to the full height either way.
114
+ const start = Math.min(first, rows)
115
+ const end = Math.max(start, last)
116
+ return { start, end, padTop: start * height, padBottom: (rows - end) * height }
117
+ }
118
+
119
+ /**
120
+ * Where a row sits inside the scroller, without touching the DOM.
121
+ *
122
+ * The change walk used to measure this off `getBoundingClientRect`, which
123
+ * stops working the moment a row it wants is outside the window — and a
124
+ * windowed pane's next change is very often exactly that.
125
+ *
126
+ * @param index - the row's index.
127
+ * @param rowH - row height in px.
128
+ * @returns the row's offset from the top of the scrolled content.
129
+ */
130
+ export function rowTop(index: number, rowH: number = DIFF_ROW_H): number {
131
+ return Math.max(0, Math.trunc(index)) * sane(rowH, DIFF_ROW_H)
132
+ }
@@ -29,8 +29,15 @@ export interface ChangeNav {
29
29
  /**
30
30
  * @param scrollRef - the element that scrolls, and the one whose subtree
31
31
  * carries the `data-block` marks.
32
+ * @param tops - where the blocks are, when the caller can say. A WINDOWED pane
33
+ * must supply this: it renders only the rows near the viewport, so the block
34
+ * the reader is walking to usually has no DOM to measure. Omitted, the
35
+ * positions are measured off the `data-block` marks as before.
32
36
  */
33
- export function useChangeNav(scrollRef: MutableRefObject<HTMLDivElement | null>): ChangeNav {
37
+ export function useChangeNav(
38
+ scrollRef: MutableRefObject<HTMLDivElement | null>,
39
+ tops?: () => readonly BlockTop[],
40
+ ): ChangeNav {
34
41
  /** What the last press landed on. A ref, not state: it exists to make the
35
42
  * NEXT press correct, and nothing on screen reads it. */
36
43
  const navMemory = useRef<NavMemory | null>(null)
@@ -38,12 +45,15 @@ export function useChangeNav(scrollRef: MutableRefObject<HTMLDivElement | null>)
38
45
  /**
39
46
  * Every change block's position inside the scrolled content.
40
47
  *
41
- * Measured off the DOM rather than derived from a row model: a model knows
42
- * which rows changed, not how many pixels down the page they sit, and the
43
- * rows are never the only thing in the scroller padding, a sticky bar and,
44
- * in the armed side-by-side pane, a CodeMirror view of a different height
45
- * all move the answer. One layout read per PRESS is cheap; nothing here runs
46
- * on scroll.
48
+ * The fallback, for a pane that renders all of its rows. Measuring is the
49
+ * more general answer — a model knows which rows changed, not how many
50
+ * pixels down the page they sit, and the rows are never the only thing in
51
+ * the scroller. One layout read per PRESS is cheap; nothing here runs on
52
+ * scroll.
53
+ *
54
+ * It stops being available the moment a pane renders only part of its rows,
55
+ * which is why `tops` exists: what is not in the DOM cannot be measured, and
56
+ * a walk that skips every change outside the viewport is worse than no walk.
47
57
  *
48
58
  * `offsetTop` is deliberately not used: it is relative to whichever ancestor
49
59
  * happens to be positioned, which no rule in the stylesheet guarantees.
@@ -69,8 +79,8 @@ export function useChangeNav(scrollRef: MutableRefObject<HTMLDivElement | null>)
69
79
  const goToChange = (direction: 1 | -1): void => {
70
80
  const scroller = scrollRef.current
71
81
  if (scroller === null) return
72
- const tops = blockTops()
73
- const target = stepToBlock(tops, anchorFrom(tops, scroller.scrollTop, navMemory.current), direction)
82
+ const measured = tops === undefined ? blockTops() : tops()
83
+ const target = stepToBlock(measured, anchorFrom(measured, scroller.scrollTop, navMemory.current), direction)
74
84
  if (target === null) return
75
85
  scroller.scrollTop = scrollTopFor(target.top)
76
86
  // Read back rather than storing what was asked for: the browser clamps at
package/src/index.ts CHANGED
@@ -91,8 +91,7 @@ export type { WriteResult }
91
91
  const DIFF_CHAR_CAP = 400_000
92
92
  /** Untracked files larger than this are listed + counted but never diffed. */
93
93
  const UNTRACKED_FILE_BYTE_CAP = 1_000_000
94
- /** At most this many bytes of synthesized untracked diff ride along in `stats`. */
95
- const UNTRACKED_TOTAL_CHAR_CAP = 160_000
94
+
96
95
  /** Files with a NUL byte in the first 8k are treated as binary. */
97
96
  const BINARY_SNIFF_BYTES = 8_000
98
97
  /** Context radius that makes `git diff` emit ONE hunk covering the whole file —
@@ -413,13 +412,21 @@ export class GitWorkbenchService extends TypertRemoteService {
413
412
  async stats(worktreePath: string | undefined, signal: AbortSignal): Promise<WorkbenchStats> {
414
413
  const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
415
414
 
416
- // Four independent reads of the same worktree. Running them together is
415
+ // Three independent reads of the same worktree. Running them together is
417
416
  // safe: git takes .git/index.lock only to write back a refreshed index and
418
417
  // skips that write when it cannot get the lock, so the reports stay correct.
419
- const [statusInfo, numstat, diff, revInfo] = await Promise.all([
418
+ //
419
+ // `git diff HEAD` is NOT among them any more. This call is polled — every
420
+ // 3 seconds while an agent is running — and the full patch is the most
421
+ // expensive thing in it by a wide margin: measured on a worktree with
422
+ // 90,000 changed lines it took 595ms and produced 7.43MB, of which the
423
+ // 400,000-character clip below then discarded 94.6% before it ever reached
424
+ // the browser. The tree and the counters need only `status` and
425
+ // `--numstat`, both of which stay around 110-140ms at that size, and the
426
+ // pane already fetches the file it is actually showing through `fileDiff`.
427
+ const [statusInfo, numstat, revInfo] = await Promise.all([
420
428
  this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
421
429
  this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
422
- this.git(cwd, ['diff', 'HEAD'], signal),
423
430
  this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
424
431
  ])
425
432
  if (statusInfo.exitCode !== 0) {
@@ -430,25 +437,18 @@ export class GitWorkbenchService extends TypertRemoteService {
430
437
  const counts = parseNumstat(numstat.stdout)
431
438
  const files = parseStatus(statusInfo.stdout, counts)
432
439
 
433
- // Untracked files in two passes, because the two halves cost wildly
434
- // different amounts. EVERY untracked file needs a line count and a binary
435
- // flag, which come off the raw buffer with no utf8 decode; only the handful
436
- // that fit the payload budget pay for a decode and a synthesized segment.
440
+ // Every untracked file needs a line count and a binary flag for the tree,
441
+ // and both come off the raw buffer with no utf8 decode. The second pass
442
+ // that used to follow decoding some of them and synthesizing new-file
443
+ // segments into the payload is gone with the payload itself; `fileDiff`
444
+ // synthesizes the one segment the reader has actually opened.
437
445
  const untracked = files.filter(file => file.status === 'untracked')
438
446
  const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path))
439
447
 
440
- let budget = UNTRACKED_TOTAL_CHAR_CAP
441
- let untrackedDiff = ''
442
448
  for (const [index, file] of untracked.entries()) {
443
449
  const measure = measured[index]
444
450
  file.addedLines = measure.lineCount
445
451
  file.binary = measure.binary
446
- if (budget <= 0 || !measure.diffable) continue
447
- const segment = await untrackedSegment(cwd, file.path)
448
- if (segment === null) continue
449
- const text = clipDiff(segment, budget, '…[untracked diff truncated]')
450
- untrackedDiff += `${text}\n`
451
- budget -= text.length
452
452
  }
453
453
 
454
454
  let addedLines = 0
@@ -481,14 +481,13 @@ export class GitWorkbenchService extends TypertRemoteService {
481
481
  }
482
482
  const { ahead, behind } = parseBranch(statusInfo.stdout)
483
483
 
484
- let combined = diff.stdout
485
- if (untrackedDiff.length > 0) combined += `\n${untrackedDiff}`
486
- combined = clipDiff(combined, DIFF_CHAR_CAP, '…[diff truncated]')
487
484
 
488
485
  return {
489
486
  worktreePath: cwd, branch, ahead, behind, detached,
490
487
  addedLines, deletedLines, addedFiles, deletedFiles, modifiedFiles,
491
- files, diff: combined,
488
+ // No bundled patch: every per-file diff is fetched on demand. See the
489
+ // reads above for what that saves and why it is affordable.
490
+ files, diff: '',
492
491
  // No log here: this call is polled every 15s, and the history list follows
493
492
  // a ref this one knows nothing about. `commits` serves it instead.
494
493
  commits: [],
@@ -496,14 +495,36 @@ export class GitWorkbenchService extends TypertRemoteService {
496
495
  }
497
496
 
498
497
  /**
499
- * One file's diff on demand. With `commit` the diff is that commit's change to
500
- * the file; without it, the working tree against HEAD (plain-identifier params;
501
- * signal last).
498
+ * One file's diff on demand, for whichever view is asking.
499
+ *
500
+ * Three questions, because the drawer's three tabs are asking three different
501
+ * things about the same path and only the caller knows which:
502
+ *
503
+ * - with `commit`, that commit's change to the file;
504
+ * - with `base` and `head`, what differs between two refs — the Compare
505
+ * tab, which until now had no way to ask at all and showed a file with no
506
+ * detail whenever the bundled payload did not carry it;
507
+ * - with neither, the working tree against HEAD.
508
+ *
509
+ * The range answer is deliberately NOT cached, for the same reason
510
+ * `compareRefs` is not: a ref name is a moving pointer, unlike a commit hash.
511
+ *
512
+ * Plain-identifier params, signal last.
502
513
  */
503
514
  @Remote('fileDiff')
504
- async fileDiff(worktreePath: string, path: string, commit: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
515
+ async fileDiff(worktreePath: string, path: string, commit: string | undefined, base: string | undefined, head: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
505
516
  const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
506
517
  if (typeof path !== 'string' || path.length === 0) return { diff: '' }
518
+ if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
519
+ if (!isRefName(base) || !isRefName(head)) return { diff: '' }
520
+ const ranged = await this.git(cwd, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal)
521
+ if (ranged.exitCode === 0) return { diff: ranged.stdout }
522
+ // Unrelated histories have no merge base for `A...B` to diff from; the
523
+ // two-tip diff still answers what differs, exactly as `compareRefs` does.
524
+ if (!isNoMergeBaseError(ranged.stderr)) return { diff: '' }
525
+ const tips = await this.git(cwd, ['diff', '--no-renames', base, head, '--', path], signal)
526
+ return { diff: tips.exitCode === 0 ? tips.stdout : '' }
527
+ }
507
528
  if (typeof commit === 'string' && commit.length > 0) {
508
529
  if (!COMMIT_HASH.test(commit)) return { diff: '' }
509
530
  const key = cacheKey(cwd, commit, path)