@young1lin/dsh-ui-gitworkbench 0.1.7 → 0.1.9

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.
package/lib/index.js CHANGED
@@ -100,8 +100,6 @@ import { bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWor
100
100
  const DIFF_CHAR_CAP = 400_000;
101
101
  /** Untracked files larger than this are listed + counted but never diffed. */
102
102
  const UNTRACKED_FILE_BYTE_CAP = 1_000_000;
103
- /** At most this many bytes of synthesized untracked diff ride along in `stats`. */
104
- const UNTRACKED_TOTAL_CHAR_CAP = 160_000;
105
103
  /** Files with a NUL byte in the first 8k are treated as binary. */
106
104
  const BINARY_SNIFF_BYTES = 8_000;
107
105
  /** Context radius that makes `git diff` emit ONE hunk covering the whole file —
@@ -391,13 +389,21 @@ let GitWorkbenchService = (() => {
391
389
  /** Working-tree change stats for a worktree (plain-identifier params; signal last — SRC requirements). */
392
390
  async stats(worktreePath, signal) {
393
391
  const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
394
- // Four independent reads of the same worktree. Running them together is
392
+ // Three independent reads of the same worktree. Running them together is
395
393
  // safe: git takes .git/index.lock only to write back a refreshed index and
396
394
  // skips that write when it cannot get the lock, so the reports stay correct.
397
- const [statusInfo, numstat, diff, revInfo] = await Promise.all([
395
+ //
396
+ // `git diff HEAD` is NOT among them any more. This call is polled — every
397
+ // 3 seconds while an agent is running — and the full patch is the most
398
+ // expensive thing in it by a wide margin: measured on a worktree with
399
+ // 90,000 changed lines it took 595ms and produced 7.43MB, of which the
400
+ // 400,000-character clip below then discarded 94.6% before it ever reached
401
+ // the browser. The tree and the counters need only `status` and
402
+ // `--numstat`, both of which stay around 110-140ms at that size, and the
403
+ // pane already fetches the file it is actually showing through `fileDiff`.
404
+ const [statusInfo, numstat, revInfo] = await Promise.all([
398
405
  this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
399
406
  this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
400
- this.git(cwd, ['diff', 'HEAD'], signal),
401
407
  this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
402
408
  ]);
403
409
  if (statusInfo.exitCode !== 0) {
@@ -406,26 +412,17 @@ let GitWorkbenchService = (() => {
406
412
  }
407
413
  const counts = parseNumstat(numstat.stdout);
408
414
  const files = parseStatus(statusInfo.stdout, counts);
409
- // Untracked files in two passes, because the two halves cost wildly
410
- // different amounts. EVERY untracked file needs a line count and a binary
411
- // flag, which come off the raw buffer with no utf8 decode; only the handful
412
- // that fit the payload budget pay for a decode and a synthesized segment.
415
+ // Every untracked file needs a line count and a binary flag for the tree,
416
+ // and both come off the raw buffer with no utf8 decode. The second pass
417
+ // that used to follow decoding some of them and synthesizing new-file
418
+ // segments into the payload is gone with the payload itself; `fileDiff`
419
+ // synthesizes the one segment the reader has actually opened.
413
420
  const untracked = files.filter(file => file.status === 'untracked');
414
421
  const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path));
415
- let budget = UNTRACKED_TOTAL_CHAR_CAP;
416
- let untrackedDiff = '';
417
422
  for (const [index, file] of untracked.entries()) {
418
423
  const measure = measured[index];
419
424
  file.addedLines = measure.lineCount;
420
425
  file.binary = measure.binary;
421
- if (budget <= 0 || !measure.diffable)
422
- continue;
423
- const segment = await untrackedSegment(cwd, file.path);
424
- if (segment === null)
425
- continue;
426
- const text = clipDiff(segment, budget, '…[untracked diff truncated]');
427
- untrackedDiff += `${text}\n`;
428
- budget -= text.length;
429
426
  }
430
427
  let addedLines = 0;
431
428
  let deletedLines = 0;
@@ -459,28 +456,51 @@ let GitWorkbenchService = (() => {
459
456
  }
460
457
  }
461
458
  const { ahead, behind } = parseBranch(statusInfo.stdout);
462
- let combined = diff.stdout;
463
- if (untrackedDiff.length > 0)
464
- combined += `\n${untrackedDiff}`;
465
- combined = clipDiff(combined, DIFF_CHAR_CAP, '…[diff truncated]');
466
459
  return {
467
460
  worktreePath: cwd, branch, ahead, behind, detached,
468
461
  addedLines, deletedLines, addedFiles, deletedFiles, modifiedFiles,
469
- files, diff: combined,
462
+ // No bundled patch: every per-file diff is fetched on demand. See the
463
+ // reads above for what that saves and why it is affordable.
464
+ files, diff: '',
470
465
  // No log here: this call is polled every 15s, and the history list follows
471
466
  // a ref this one knows nothing about. `commits` serves it instead.
472
467
  commits: [],
473
468
  };
474
469
  }
475
470
  /**
476
- * One file's diff on demand. With `commit` the diff is that commit's change to
477
- * the file; without it, the working tree against HEAD (plain-identifier params;
478
- * signal last).
471
+ * One file's diff on demand, for whichever view is asking.
472
+ *
473
+ * Three questions, because the drawer's three tabs are asking three different
474
+ * things about the same path and only the caller knows which:
475
+ *
476
+ * - with `commit`, that commit's change to the file;
477
+ * - with `base` and `head`, what differs between two refs — the Compare
478
+ * tab, which until now had no way to ask at all and showed a file with no
479
+ * detail whenever the bundled payload did not carry it;
480
+ * - with neither, the working tree against HEAD.
481
+ *
482
+ * The range answer is deliberately NOT cached, for the same reason
483
+ * `compareRefs` is not: a ref name is a moving pointer, unlike a commit hash.
484
+ *
485
+ * Plain-identifier params, signal last.
479
486
  */
480
- async fileDiff(worktreePath, path, commit, signal) {
487
+ async fileDiff(worktreePath, path, commit, base, head, signal) {
481
488
  const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
482
489
  if (typeof path !== 'string' || path.length === 0)
483
490
  return { diff: '' };
491
+ if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
492
+ if (!isRefName(base) || !isRefName(head))
493
+ return { diff: '' };
494
+ const ranged = await this.git(cwd, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal);
495
+ if (ranged.exitCode === 0)
496
+ return { diff: ranged.stdout };
497
+ // Unrelated histories have no merge base for `A...B` to diff from; the
498
+ // two-tip diff still answers what differs, exactly as `compareRefs` does.
499
+ if (!isNoMergeBaseError(ranged.stderr))
500
+ return { diff: '' };
501
+ const tips = await this.git(cwd, ['diff', '--no-renames', base, head, '--', path], signal);
502
+ return { diff: tips.exitCode === 0 ? tips.stdout : '' };
503
+ }
484
504
  if (typeof commit === 'string' && commit.length > 0) {
485
505
  if (!COMMIT_HASH.test(commit))
486
506
  return { diff: '' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@young1lin/dsh-ui-gitworkbench",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1806,6 +1806,13 @@
1806
1806
  .sideNumAdd { background: var(--gs-add-num, var(--gs-add-line)); }
1807
1807
  .sideNumDel { background: var(--gs-del-num, var(--gs-del-line)); }
1808
1808
  .sideCode { white-space: pre; padding: 0 16px 0 10px; min-height: 20px; }
1809
+ /* Stands in for the rows outside the window, so the scrollbar is the length of
1810
+ the FILE rather than of whatever is currently rendered. Spans every column,
1811
+ including the blame gutter's. */
1812
+ .sideSpacer { grid-column: 1 / -1; }
1813
+ /* The same, for the unified view's flex column. `flex: none` so it is exactly
1814
+ the height it claims rather than one stretched by its siblings. */
1815
+ .diffSpacer { flex: none; }
1809
1816
  .sideCodeSame { color: var(--gs-fg-muted); }
1810
1817
  .sideCodeAdd { background: var(--gs-add-line); }
1811
1818
  .sideCodeDel { background: var(--gs-del-line); }
@@ -58,8 +58,9 @@ import {
58
58
  import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
59
59
  import { parsePatch } from '../patch-model.ts'
60
60
  import { alignRows, blockCount, blockIsWholeFile, blockLines, blockTally, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
61
- import { countBlocks, unifiedBlocks } from './diff-nav.ts'
61
+ import { blockTopsFromRows, countBlocks, unifiedBlocks } from './diff-nav.ts'
62
62
  import { clampPane, neighbourWidth } from './pane-size.ts'
63
+ import { DIFF_GRID_PAD_TOP, DIFF_ROW_H, rowWindow, type RowWindow } from './row-window.ts'
63
64
  import { COMMIT_ROW_H, DEFAULT_HISTORY_LAYOUT, isHistoryLayout, type HistoryLayout } from './history-layout.ts'
64
65
  import { useChangeNav } from './use-change-nav.ts'
65
66
  import {
@@ -88,7 +89,7 @@ import {
88
89
  fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
89
90
  type CheckState, type Tick, type TickAction,
90
91
  } from './stage-tree.ts'
91
- import { grammarLoadCount, highlightFile, highlightForRows, highlightWholeFile, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
92
+ import { grammarLoadCount, highlightFile, highlightForRowsWindow, highlightWholeFile, highlightWindow, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
92
93
  import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
93
94
  import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
94
95
  import type { WorkbenchKey } from './locales.ts'
@@ -348,7 +349,7 @@ export type Translate = (key: string, params?: Record<string, string | number>)
348
349
  type Props = PropsRuntime<'conversation.session.header.actions'> & {
349
350
  readonly t: Translate
350
351
  readonly fetchStats: (worktreePath: string | undefined, signal: AbortSignal) => Promise<WorkbenchStats | null>
351
- readonly fetchFileDiff: (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal) => Promise<string>
352
+ readonly fetchFileDiff: (worktreePath: string | undefined, path: string, commit: string | undefined, range: { base: string; head: string } | undefined, signal: AbortSignal) => Promise<string>
352
353
  /** One layer of one file for the side-by-side diff pane. */
353
354
  readonly fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
354
355
  /** Save the editor buffer, checked against the sha it opened with. */
@@ -1189,14 +1190,15 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1189
1190
  * drawer's on-demand effect stops re-running on every render. */
1190
1191
  const fetchDiffForView = useCallback(
1191
1192
  (path: string, signal: AbortSignal): Promise<string> => {
1192
- // A comparison's per-file diff would need the ref range, which `fileDiff`
1193
- // does not take. Answering with the working tree's diff for that path
1194
- // would be plainly wrong, so a file past the payload cap simply has no
1195
- // detail on this tab.
1196
- if (tab === 'compare') return Promise.resolve('')
1197
- return fetchFileDiff(statsPath, path, tab === 'history' ? commitHash ?? undefined : undefined, signal)
1193
+ // Each tab asks its own question about the path. Compare used to ask
1194
+ // nothing at all `fileDiff` had no way to take a ref range, so a file
1195
+ // the bundled payload did not carry simply had no detail, which is what
1196
+ // an added XML file past the payload cap looked like.
1197
+ const range = tab === 'compare' ? { base: baseRef, head: headRef } : undefined
1198
+ const commit = tab === 'history' ? commitHash ?? undefined : undefined
1199
+ return fetchFileDiff(statsPath, path, commit, range, signal)
1198
1200
  },
1199
- [fetchFileDiff, statsPath, tab, commitHash],
1201
+ [fetchFileDiff, statsPath, tab, commitHash, baseRef, headRef],
1200
1202
  )
1201
1203
 
1202
1204
  // First stats fetch still in flight: render nothing. The cheap binding RPC
@@ -5057,14 +5059,25 @@ function DiffView({ segment, path, palette, t }: {
5057
5059
  const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
5058
5060
  const rowsWithWords = useMemo(() => attachWordRanges(parseRows(segment)), [segment])
5059
5061
  const sides = useMemo(() => gutterSides(rowsWithWords), [rowsWithWords])
5062
+ const scrollRef = useRef<HTMLDivElement>(null)
5063
+ // Windowed for the same reason the side-by-side pane is: a unified diff of a
5064
+ // long file put every row in the DOM and re-lexed every one of them, so
5065
+ // opening one froze the pane in exactly the same way.
5066
+ const win = useRowWindow(scrollRef, rowsWithWords.length)
5060
5067
  const syntax = useMemo(
5061
- () => highlightForRows(rowsWithWords, lang, shikiTheme),
5062
- [rowsWithWords, lang, shikiTheme, grammarGen],
5068
+ () => highlightForRowsWindow(rowsWithWords, lang, shikiTheme, win.start, win.end),
5069
+ [rowsWithWords, lang, shikiTheme, win.start, win.end, grammarGen],
5063
5070
  )
5064
5071
  const blocks = useMemo(() => unifiedBlocks(rowsWithWords.map(row => row.kind)), [rowsWithWords])
5065
5072
  const changes = useMemo(() => countBlocks(blocks), [blocks])
5066
- const scrollRef = useRef<HTMLDivElement>(null)
5067
- const { goToChange } = useChangeNav(scrollRef)
5073
+ // Derived rather than measured, because a windowed pane has no element for
5074
+ // the block being walked to.
5075
+ const blocksForNav = useRef<readonly number[]>(blocks)
5076
+ blocksForNav.current = blocks
5077
+ const { goToChange } = useChangeNav(
5078
+ scrollRef,
5079
+ useCallback(() => blockTopsFromRows(blocksForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
5080
+ )
5068
5081
  // Read by the key listener below, which is attached once. `goToChange` only
5069
5082
  // ever touches refs, but pinning it here says so rather than relying on it.
5070
5083
  const walk = useRef(goToChange)
@@ -5110,7 +5123,10 @@ function DiffView({ segment, path, palette, t }: {
5110
5123
  ) : null}
5111
5124
  <div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
5112
5125
  <pre className={css.diffPre}>
5113
- {rowsWithWords.map((row, i) => (
5126
+ {win.padTop > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padTop}px` }} aria-hidden="true" /> : null}
5127
+ {rowsWithWords.slice(win.start, win.end).map((row, k) => {
5128
+ const i = win.start + k
5129
+ return (
5114
5130
  <div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
5115
5131
  {sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
5116
5132
  {sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
@@ -5119,7 +5135,9 @@ function DiffView({ segment, path, palette, t }: {
5119
5135
  </span>
5120
5136
  <span className={css.code}>{renderCode(row, syntax[i] ?? [])}</span>
5121
5137
  </div>
5122
- ))}
5138
+ )
5139
+ })}
5140
+ {win.padBottom > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padBottom}px` }} aria-hidden="true" /> : null}
5123
5141
  </pre>
5124
5142
  </div>
5125
5143
  </div>
@@ -5212,7 +5230,16 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5212
5230
  const colsRef = useRef<HTMLDivElement>(null)
5213
5231
  /** The pane's one vertical scroller — what "next change" moves. */
5214
5232
  const scrollRef = useRef<HTMLDivElement>(null)
5215
- const { goToChange } = useChangeNav(scrollRef)
5233
+ /** Where the rows are, filled in below once they exist. A ref, because the
5234
+ * walk is set up here and the rows are decided further down; reading it
5235
+ * only when a key is pressed is what lets the two live apart. */
5236
+ const rowsForNav = useRef<readonly number[]>([])
5237
+ const { goToChange } = useChangeNav(
5238
+ scrollRef,
5239
+ // Derived, not measured: the pane renders only the rows near the viewport
5240
+ // now, so the block being walked to usually has no element at all.
5241
+ useCallback(() => blockTopsFromRows(rowsForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
5242
+ )
5216
5243
 
5217
5244
  const [sides, setSides] = useState<FileSides | null>(null)
5218
5245
  // Set when the RPC itself failed — most plausibly a host half older than
@@ -5306,13 +5333,25 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5306
5333
  // `undefined` and renders the plain text for it; what crashes is indexing
5307
5334
  // the array itself, and `strict` is off in tsconfig, so the compiler will
5308
5335
  // not say so.
5336
+ // Only the rows the reader can see reach the DOM, and only they are re-lexed
5337
+ // line by line. Declared here because both the render and the highlighting
5338
+ // below are bounded by it.
5339
+ const win = useRowWindow(scrollRef, rows.length)
5340
+ //
5341
+ // Two passes with two lifetimes. The whole-file pass runs once per file and
5342
+ // is what knows about block comments and template literals; the per-line
5343
+ // re-lex — one Shiki call each, and the reason a 4,000-line file froze the
5344
+ // pane for 2.8 seconds — runs only over the rows in the window, and so again
5345
+ // whenever the reader scrolls.
5346
+ const leftLines = useMemo(() => rows.map(row => row.left === null ? '' : row.left.text), [rows])
5347
+ const rightLines = useMemo(() => rows.map(row => row.right === null ? '' : row.right.text), [rows])
5309
5348
  const leftSyntax = useMemo(
5310
- () => highlightFile(rows.map(row => row.left === null ? '' : row.left.text), lang, shikiTheme),
5311
- [rows, lang, shikiTheme, grammarGen],
5349
+ () => highlightWindow(leftLines, lang, shikiTheme, win.start, win.end),
5350
+ [leftLines, lang, shikiTheme, win.start, win.end, grammarGen],
5312
5351
  )
5313
5352
  const rightSyntax = useMemo(
5314
- () => highlightFile(rows.map(row => row.right === null ? '' : row.right.text), lang, shikiTheme),
5315
- [rows, lang, shikiTheme, grammarGen],
5353
+ () => highlightWindow(rightLines, lang, shikiTheme, win.start, win.end),
5354
+ [rightLines, lang, shikiTheme, win.start, win.end, grammarGen],
5316
5355
  )
5317
5356
 
5318
5357
  /** The editor half of the pane, present only on the unstaged layer. */
@@ -5370,6 +5409,14 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5370
5409
  // editor. Each entry keeps its index into `rows` for its syntax tokens.
5371
5410
  const leftRows = useMemo(() => rows.map((row, i) => ({ row, i })).filter(entry => entry.row.left !== null), [rows])
5372
5411
 
5412
+ // Only the rows the reader can see reach the DOM. Two windows because the
5413
+ // two columns render two different row lists while the editor is armed: the
5414
+ // right side is a buffer, and the left side is then the index side DENSE,
5415
+ // one row per index line rather than one per aligned row.
5416
+ const leftWin = useRowWindow(scrollRef, leftRows.length)
5417
+ // Kept current for the change walk set up at the top of this component.
5418
+ rowsForNav.current = useMemo(() => rows.map(row => row.block), [rows])
5419
+
5373
5420
  // Arming drops the caret straight into the buffer: the click that armed the
5374
5421
  // editor said "I want to type here", and a second click to focus is a tax.
5375
5422
 
@@ -5736,7 +5783,10 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5736
5783
  one row per index line, no diff holes — because the right
5737
5784
  column is a buffer whose line count diverges from the diff
5738
5785
  the moment a keystroke lands. */
5739
- leftRows.map((entry, k) => {
5786
+ <>
5787
+ <RowSpacer height={leftWin.padTop} />
5788
+ {leftRows.slice(leftWin.start, leftWin.end).map((entry, kk) => {
5789
+ const k = leftWin.start + kk
5740
5790
  const { row, i } = entry
5741
5791
  const hot = hotBlock !== null && row.block === hotBlock
5742
5792
  const hotClass = hot ? ` ${css.sideBlockHot}` : ''
@@ -5749,9 +5799,14 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5749
5799
  </span>
5750
5800
  </Fragment>
5751
5801
  )
5752
- })
5802
+ })}
5803
+ <RowSpacer height={leftWin.padBottom} />
5804
+ </>
5753
5805
  ) : (
5754
- rows.map((row, i) => {
5806
+ <>
5807
+ <RowSpacer height={win.padTop} />
5808
+ {rows.slice(win.start, win.end).map((row, k) => {
5809
+ const i = win.start + k
5755
5810
  const hot = hotBlock !== null && row.block === hotBlock
5756
5811
  const hotClass = hot ? ` ${css.sideBlockHot}` : ''
5757
5812
  // The block's action bar rides in this column only for a row
@@ -5767,7 +5822,9 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5767
5822
  </span>
5768
5823
  </Fragment>
5769
5824
  )
5770
- })
5825
+ })}
5826
+ <RowSpacer height={win.padBottom} />
5827
+ </>
5771
5828
  )}
5772
5829
  </div>
5773
5830
  </div>
@@ -5785,7 +5842,9 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5785
5842
  />
5786
5843
  ) : (
5787
5844
  <div className={css.sideColGrid}>
5788
- {rows.map((row, i) => {
5845
+ <RowSpacer height={win.padTop} />
5846
+ {rows.slice(win.start, win.end).map((row, k) => {
5847
+ const i = win.start + k
5789
5848
  const hot = hotBlock !== null && row.block === hotBlock
5790
5849
  const hotClass = hot ? ` ${css.sideBlockHot}` : ''
5791
5850
  const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
@@ -5803,6 +5862,7 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
5803
5862
  </Fragment>
5804
5863
  )
5805
5864
  })}
5865
+ <RowSpacer height={win.padBottom} />
5806
5866
  </div>
5807
5867
  )}
5808
5868
  </div>
@@ -5896,6 +5956,58 @@ function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] |
5896
5956
 
5897
5957
  /* ---------- shared helpers ---------- */
5898
5958
 
5959
+ /**
5960
+ * The rows a diff actually has to put in the DOM, tracked against its scroller.
5961
+ *
5962
+ * Rendering a whole file cost the pane 3.9 seconds of main thread and a
5963
+ * 3.6-second frozen frame at 4,000 lines — for a one-line change, because the
5964
+ * side-by-side view draws every line of the file whether it changed or not.
5965
+ * This is the fix that removes the length from the cost rather than capping it.
5966
+ *
5967
+ * The window is held in state rather than the raw scroll offset so a scroll
5968
+ * that does not move it renders nothing: `start` only changes once a whole row
5969
+ * has passed under the viewport's edge.
5970
+ *
5971
+ * @param scrollRef - the element that scrolls the rows.
5972
+ * @param rowCount - how many rows the diff has.
5973
+ * @returns the rows to render and the spacer heights standing in for the rest.
5974
+ */
5975
+ function useRowWindow(scrollRef: { current: HTMLElement | null }, rowCount: number): RowWindow {
5976
+ const [win, setWin] = useState<RowWindow>(() => rowWindow(0, 0, rowCount))
5977
+ useEffect(() => {
5978
+ const el = scrollRef.current
5979
+ if (el === null) return
5980
+ const read = (): void => {
5981
+ const next = rowWindow(el.scrollTop, el.clientHeight, rowCount)
5982
+ setWin(prev => prev.start === next.start && prev.end === next.end ? prev : next)
5983
+ }
5984
+ read()
5985
+ // Passive: this listener never calls preventDefault, and saying so keeps
5986
+ // it off the scroll's critical path.
5987
+ el.addEventListener('scroll', read, { passive: true })
5988
+ // The drawer resizes without the page doing so — a dragged edge, the
5989
+ // maximize button — and a taller pane needs more rows.
5990
+ const observer = new ResizeObserver(read)
5991
+ observer.observe(el)
5992
+ return () => {
5993
+ el.removeEventListener('scroll', read)
5994
+ observer.disconnect()
5995
+ }
5996
+ }, [scrollRef, rowCount])
5997
+ return win
5998
+ }
5999
+
6000
+ /**
6001
+ * The spacer standing in for the rows above or below the window.
6002
+ *
6003
+ * It spans every column of the grid, so a blame gutter does not change it.
6004
+ * @param height - px of rows it stands in for; nothing is rendered for 0.
6005
+ */
6006
+ function RowSpacer({ height }: { height: number }): ReactNode {
6007
+ if (height <= 0) return null
6008
+ return <span className={css.sideSpacer} style={{ height: `${height}px` }} aria-hidden="true" />
6009
+ }
6010
+
5899
6011
  /** Split a combined `git diff` into path -> its segment text. */
5900
6012
  function splitDiff(diff: string): Map<string, string> {
5901
6013
  const out = new Map<string, string>()
@@ -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('#')
@@ -261,6 +317,39 @@ export function highlightForRows(
261
317
  lang: string | undefined,
262
318
  theme = 'github-dark-default',
263
319
  ): HighlightRun[][] {
320
+ // One implementation, asked for the whole range. The panes all window now;
321
+ // this shape is what a caller wants when it really does need every row.
322
+ return highlightForRowsWindow(rows, lang, theme, 0, rows.length)
323
+ .map((runs, i) => runs ?? [{ text: rows[i]!.text, color: undefined }])
324
+ }
325
+
326
+ /**
327
+ * {@link highlightForRows} for the rows in a window, and nothing outside it.
328
+ *
329
+ * Same reason as {@link highlightWindow}, in the view History and Compare use:
330
+ * a unified diff of a long file re-lexed every one of its rows, so opening one
331
+ * froze the pane exactly as the side-by-side view did.
332
+ *
333
+ * The mapping from rows to the two sides' line arrays is built over ALL rows —
334
+ * it is array bookkeeping with no Shiki in it, and a row's position on its side
335
+ * depends on every row before it. Only the tokenizing is windowed, and because
336
+ * a window of rows is contiguous, so is the span of lines it needs from each
337
+ * side.
338
+ *
339
+ * @param rows - parsed unified-diff rows.
340
+ * @param lang - from {@link shikiLangOf}.
341
+ * @param theme - from {@link shikiThemeOf}.
342
+ * @param from - first row in the window.
343
+ * @param to - one past the last row in the window.
344
+ * @returns an array indexed by ROW, filled only inside the window.
345
+ */
346
+ export function highlightForRowsWindow(
347
+ rows: readonly Row[],
348
+ lang: string | undefined,
349
+ theme: string,
350
+ from: number,
351
+ to: number,
352
+ ): (HighlightRun[] | undefined)[] {
264
353
  const oldLines: string[] = []
265
354
  const newLines: string[] = []
266
355
  const oldAt: number[] = []
@@ -284,11 +373,29 @@ export function highlightForRows(
284
373
  newAt.push(-1)
285
374
  }
286
375
  }
287
- const oldTok = highlightFile(oldLines, lang, theme)
288
- const newTok = highlightFile(newLines, lang, theme)
289
- return rows.map((row, i) => {
290
- if (row.kind === 'del') return oldTok?.[oldAt[i]!] ?? [{ text: row.text, color: undefined }]
291
- if (row.kind === 'add' || row.kind === 'context') return newTok?.[newAt[i]!] ?? [{ text: row.text, color: undefined }]
292
- return [{ text: row.text, color: undefined }]
293
- })
376
+ const first = Math.max(0, Math.trunc(from))
377
+ const last = Math.min(rows.length, Math.trunc(to))
378
+ const span = (at: readonly number[]): { from: number; to: number } => {
379
+ let lo = -1
380
+ let hi = -1
381
+ for (let i = first; i < last; i += 1) {
382
+ const j = at[i]!
383
+ if (j < 0) continue
384
+ if (lo < 0) lo = j
385
+ hi = j
386
+ }
387
+ return lo < 0 ? { from: 0, to: 0 } : { from: lo, to: hi + 1 }
388
+ }
389
+ const oldSpan = span(oldAt)
390
+ const newSpan = span(newAt)
391
+ const oldTok = highlightWindow(oldLines, lang, theme, oldSpan.from, oldSpan.to)
392
+ const newTok = highlightWindow(newLines, lang, theme, newSpan.from, newSpan.to)
393
+ const out: (HighlightRun[] | undefined)[] = new Array<HighlightRun[] | undefined>(rows.length)
394
+ for (let i = first; i < last; i += 1) {
395
+ const row = rows[i]!
396
+ if (row.kind === 'del') out[i] = oldTok?.[oldAt[i]!] ?? [{ text: row.text, color: undefined }]
397
+ else if (row.kind === 'add' || row.kind === 'context') out[i] = newTok?.[newAt[i]!] ?? [{ text: row.text, color: undefined }]
398
+ else out[i] = [{ text: row.text, color: undefined }]
399
+ }
400
+ return out
294
401
  }
@@ -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 : ''