@young1lin/dsh-ui-gitworkbench 0.1.7 → 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.
- package/CHANGELOG.md +13 -0
- package/CHANGELOG_EN.md +14 -0
- package/lib/client.js +567 -354
- package/lib/index.js +48 -28
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.module.css +4 -0
- package/src/client/GitWorkbenchPanel.tsx +116 -20
- package/src/client/diff-nav.ts +28 -0
- package/src/client/highlight.ts +56 -0
- 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.8",
|
|
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,10 @@
|
|
|
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; }
|
|
1809
1813
|
.sideCodeSame { color: var(--gs-fg-muted); }
|
|
1810
1814
|
.sideCodeAdd { background: var(--gs-add-line); }
|
|
1811
1815
|
.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, highlightForRows, 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
|
|
@@ -5212,7 +5214,16 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5212
5214
|
const colsRef = useRef<HTMLDivElement>(null)
|
|
5213
5215
|
/** The pane's one vertical scroller — what "next change" moves. */
|
|
5214
5216
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
5215
|
-
|
|
5217
|
+
/** Where the rows are, filled in below once they exist. A ref, because the
|
|
5218
|
+
* walk is set up here and the rows are decided further down; reading it
|
|
5219
|
+
* only when a key is pressed is what lets the two live apart. */
|
|
5220
|
+
const rowsForNav = useRef<readonly number[]>([])
|
|
5221
|
+
const { goToChange } = useChangeNav(
|
|
5222
|
+
scrollRef,
|
|
5223
|
+
// Derived, not measured: the pane renders only the rows near the viewport
|
|
5224
|
+
// now, so the block being walked to usually has no element at all.
|
|
5225
|
+
useCallback(() => blockTopsFromRows(rowsForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
|
|
5226
|
+
)
|
|
5216
5227
|
|
|
5217
5228
|
const [sides, setSides] = useState<FileSides | null>(null)
|
|
5218
5229
|
// Set when the RPC itself failed — most plausibly a host half older than
|
|
@@ -5306,13 +5317,25 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5306
5317
|
// `undefined` and renders the plain text for it; what crashes is indexing
|
|
5307
5318
|
// the array itself, and `strict` is off in tsconfig, so the compiler will
|
|
5308
5319
|
// not say so.
|
|
5320
|
+
// Only the rows the reader can see reach the DOM, and only they are re-lexed
|
|
5321
|
+
// line by line. Declared here because both the render and the highlighting
|
|
5322
|
+
// below are bounded by it.
|
|
5323
|
+
const win = useRowWindow(scrollRef, rows.length)
|
|
5324
|
+
//
|
|
5325
|
+
// Two passes with two lifetimes. The whole-file pass runs once per file and
|
|
5326
|
+
// is what knows about block comments and template literals; the per-line
|
|
5327
|
+
// re-lex — one Shiki call each, and the reason a 4,000-line file froze the
|
|
5328
|
+
// pane for 2.8 seconds — runs only over the rows in the window, and so again
|
|
5329
|
+
// whenever the reader scrolls.
|
|
5330
|
+
const leftLines = useMemo(() => rows.map(row => row.left === null ? '' : row.left.text), [rows])
|
|
5331
|
+
const rightLines = useMemo(() => rows.map(row => row.right === null ? '' : row.right.text), [rows])
|
|
5309
5332
|
const leftSyntax = useMemo(
|
|
5310
|
-
() =>
|
|
5311
|
-
[
|
|
5333
|
+
() => highlightWindow(leftLines, lang, shikiTheme, win.start, win.end),
|
|
5334
|
+
[leftLines, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
5312
5335
|
)
|
|
5313
5336
|
const rightSyntax = useMemo(
|
|
5314
|
-
() =>
|
|
5315
|
-
[
|
|
5337
|
+
() => highlightWindow(rightLines, lang, shikiTheme, win.start, win.end),
|
|
5338
|
+
[rightLines, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
5316
5339
|
)
|
|
5317
5340
|
|
|
5318
5341
|
/** The editor half of the pane, present only on the unstaged layer. */
|
|
@@ -5370,6 +5393,14 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5370
5393
|
// editor. Each entry keeps its index into `rows` for its syntax tokens.
|
|
5371
5394
|
const leftRows = useMemo(() => rows.map((row, i) => ({ row, i })).filter(entry => entry.row.left !== null), [rows])
|
|
5372
5395
|
|
|
5396
|
+
// Only the rows the reader can see reach the DOM. Two windows because the
|
|
5397
|
+
// two columns render two different row lists while the editor is armed: the
|
|
5398
|
+
// right side is a buffer, and the left side is then the index side DENSE,
|
|
5399
|
+
// one row per index line rather than one per aligned row.
|
|
5400
|
+
const leftWin = useRowWindow(scrollRef, leftRows.length)
|
|
5401
|
+
// Kept current for the change walk set up at the top of this component.
|
|
5402
|
+
rowsForNav.current = useMemo(() => rows.map(row => row.block), [rows])
|
|
5403
|
+
|
|
5373
5404
|
// Arming drops the caret straight into the buffer: the click that armed the
|
|
5374
5405
|
// editor said "I want to type here", and a second click to focus is a tax.
|
|
5375
5406
|
|
|
@@ -5736,7 +5767,10 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5736
5767
|
one row per index line, no diff holes — because the right
|
|
5737
5768
|
column is a buffer whose line count diverges from the diff
|
|
5738
5769
|
the moment a keystroke lands. */
|
|
5739
|
-
|
|
5770
|
+
<>
|
|
5771
|
+
<RowSpacer height={leftWin.padTop} />
|
|
5772
|
+
{leftRows.slice(leftWin.start, leftWin.end).map((entry, kk) => {
|
|
5773
|
+
const k = leftWin.start + kk
|
|
5740
5774
|
const { row, i } = entry
|
|
5741
5775
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
5742
5776
|
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
@@ -5749,9 +5783,14 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5749
5783
|
</span>
|
|
5750
5784
|
</Fragment>
|
|
5751
5785
|
)
|
|
5752
|
-
})
|
|
5786
|
+
})}
|
|
5787
|
+
<RowSpacer height={leftWin.padBottom} />
|
|
5788
|
+
</>
|
|
5753
5789
|
) : (
|
|
5754
|
-
|
|
5790
|
+
<>
|
|
5791
|
+
<RowSpacer height={win.padTop} />
|
|
5792
|
+
{rows.slice(win.start, win.end).map((row, k) => {
|
|
5793
|
+
const i = win.start + k
|
|
5755
5794
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
5756
5795
|
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5757
5796
|
// The block's action bar rides in this column only for a row
|
|
@@ -5767,7 +5806,9 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5767
5806
|
</span>
|
|
5768
5807
|
</Fragment>
|
|
5769
5808
|
)
|
|
5770
|
-
})
|
|
5809
|
+
})}
|
|
5810
|
+
<RowSpacer height={win.padBottom} />
|
|
5811
|
+
</>
|
|
5771
5812
|
)}
|
|
5772
5813
|
</div>
|
|
5773
5814
|
</div>
|
|
@@ -5785,7 +5826,9 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5785
5826
|
/>
|
|
5786
5827
|
) : (
|
|
5787
5828
|
<div className={css.sideColGrid}>
|
|
5788
|
-
{
|
|
5829
|
+
<RowSpacer height={win.padTop} />
|
|
5830
|
+
{rows.slice(win.start, win.end).map((row, k) => {
|
|
5831
|
+
const i = win.start + k
|
|
5789
5832
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
5790
5833
|
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5791
5834
|
const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
|
|
@@ -5803,6 +5846,7 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5803
5846
|
</Fragment>
|
|
5804
5847
|
)
|
|
5805
5848
|
})}
|
|
5849
|
+
<RowSpacer height={win.padBottom} />
|
|
5806
5850
|
</div>
|
|
5807
5851
|
)}
|
|
5808
5852
|
</div>
|
|
@@ -5896,6 +5940,58 @@ function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] |
|
|
|
5896
5940
|
|
|
5897
5941
|
/* ---------- shared helpers ---------- */
|
|
5898
5942
|
|
|
5943
|
+
/**
|
|
5944
|
+
* The rows a diff actually has to put in the DOM, tracked against its scroller.
|
|
5945
|
+
*
|
|
5946
|
+
* Rendering a whole file cost the pane 3.9 seconds of main thread and a
|
|
5947
|
+
* 3.6-second frozen frame at 4,000 lines — for a one-line change, because the
|
|
5948
|
+
* side-by-side view draws every line of the file whether it changed or not.
|
|
5949
|
+
* This is the fix that removes the length from the cost rather than capping it.
|
|
5950
|
+
*
|
|
5951
|
+
* The window is held in state rather than the raw scroll offset so a scroll
|
|
5952
|
+
* that does not move it renders nothing: `start` only changes once a whole row
|
|
5953
|
+
* has passed under the viewport's edge.
|
|
5954
|
+
*
|
|
5955
|
+
* @param scrollRef - the element that scrolls the rows.
|
|
5956
|
+
* @param rowCount - how many rows the diff has.
|
|
5957
|
+
* @returns the rows to render and the spacer heights standing in for the rest.
|
|
5958
|
+
*/
|
|
5959
|
+
function useRowWindow(scrollRef: { current: HTMLElement | null }, rowCount: number): RowWindow {
|
|
5960
|
+
const [win, setWin] = useState<RowWindow>(() => rowWindow(0, 0, rowCount))
|
|
5961
|
+
useEffect(() => {
|
|
5962
|
+
const el = scrollRef.current
|
|
5963
|
+
if (el === null) return
|
|
5964
|
+
const read = (): void => {
|
|
5965
|
+
const next = rowWindow(el.scrollTop, el.clientHeight, rowCount)
|
|
5966
|
+
setWin(prev => prev.start === next.start && prev.end === next.end ? prev : next)
|
|
5967
|
+
}
|
|
5968
|
+
read()
|
|
5969
|
+
// Passive: this listener never calls preventDefault, and saying so keeps
|
|
5970
|
+
// it off the scroll's critical path.
|
|
5971
|
+
el.addEventListener('scroll', read, { passive: true })
|
|
5972
|
+
// The drawer resizes without the page doing so — a dragged edge, the
|
|
5973
|
+
// maximize button — and a taller pane needs more rows.
|
|
5974
|
+
const observer = new ResizeObserver(read)
|
|
5975
|
+
observer.observe(el)
|
|
5976
|
+
return () => {
|
|
5977
|
+
el.removeEventListener('scroll', read)
|
|
5978
|
+
observer.disconnect()
|
|
5979
|
+
}
|
|
5980
|
+
}, [scrollRef, rowCount])
|
|
5981
|
+
return win
|
|
5982
|
+
}
|
|
5983
|
+
|
|
5984
|
+
/**
|
|
5985
|
+
* The spacer standing in for the rows above or below the window.
|
|
5986
|
+
*
|
|
5987
|
+
* It spans every column of the grid, so a blame gutter does not change it.
|
|
5988
|
+
* @param height - px of rows it stands in for; nothing is rendered for 0.
|
|
5989
|
+
*/
|
|
5990
|
+
function RowSpacer({ height }: { height: number }): ReactNode {
|
|
5991
|
+
if (height <= 0) return null
|
|
5992
|
+
return <span className={css.sideSpacer} style={{ height: `${height}px` }} aria-hidden="true" />
|
|
5993
|
+
}
|
|
5994
|
+
|
|
5899
5995
|
/** Split a combined `git diff` into path -> its segment text. */
|
|
5900
5996
|
function splitDiff(diff: string): Map<string, string> {
|
|
5901
5997
|
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('#')
|
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 : ''
|
|
@@ -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
|
+
}
|