@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/CHANGELOG.md +19 -0
- package/CHANGELOG_EN.md +20 -0
- package/lib/client.js +662 -418
- package/lib/index.js +48 -28
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.module.css +7 -0
- package/src/client/GitWorkbenchPanel.tsx +138 -26
- package/src/client/diff-nav.ts +28 -0
- package/src/client/highlight.ts +114 -7
- package/src/client/index.ts +10 -2
- package/src/client/row-window.ts +132 -0
- package/src/client/use-change-nav.ts +19 -9
- package/src/index.ts +46 -25
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
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
|
-
|
|
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
|
|
477
|
-
*
|
|
478
|
-
*
|
|
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.
|
|
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,
|
|
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
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
() =>
|
|
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
|
-
|
|
5067
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
() =>
|
|
5311
|
-
[
|
|
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
|
-
() =>
|
|
5315
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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>()
|
package/src/client/diff-nav.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/highlight.ts
CHANGED
|
@@ -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
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
{
|
|
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 : ''
|