@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
|
@@ -29,8 +29,15 @@ export interface ChangeNav {
|
|
|
29
29
|
/**
|
|
30
30
|
* @param scrollRef - the element that scrolls, and the one whose subtree
|
|
31
31
|
* carries the `data-block` marks.
|
|
32
|
+
* @param tops - where the blocks are, when the caller can say. A WINDOWED pane
|
|
33
|
+
* must supply this: it renders only the rows near the viewport, so the block
|
|
34
|
+
* the reader is walking to usually has no DOM to measure. Omitted, the
|
|
35
|
+
* positions are measured off the `data-block` marks as before.
|
|
32
36
|
*/
|
|
33
|
-
export function useChangeNav(
|
|
37
|
+
export function useChangeNav(
|
|
38
|
+
scrollRef: MutableRefObject<HTMLDivElement | null>,
|
|
39
|
+
tops?: () => readonly BlockTop[],
|
|
40
|
+
): ChangeNav {
|
|
34
41
|
/** What the last press landed on. A ref, not state: it exists to make the
|
|
35
42
|
* NEXT press correct, and nothing on screen reads it. */
|
|
36
43
|
const navMemory = useRef<NavMemory | null>(null)
|
|
@@ -38,12 +45,15 @@ export function useChangeNav(scrollRef: MutableRefObject<HTMLDivElement | null>)
|
|
|
38
45
|
/**
|
|
39
46
|
* Every change block's position inside the scrolled content.
|
|
40
47
|
*
|
|
41
|
-
*
|
|
42
|
-
* which rows changed, not how many
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
48
|
+
* The fallback, for a pane that renders all of its rows. Measuring is the
|
|
49
|
+
* more general answer — a model knows which rows changed, not how many
|
|
50
|
+
* pixels down the page they sit, and the rows are never the only thing in
|
|
51
|
+
* the scroller. One layout read per PRESS is cheap; nothing here runs on
|
|
52
|
+
* scroll.
|
|
53
|
+
*
|
|
54
|
+
* It stops being available the moment a pane renders only part of its rows,
|
|
55
|
+
* which is why `tops` exists: what is not in the DOM cannot be measured, and
|
|
56
|
+
* a walk that skips every change outside the viewport is worse than no walk.
|
|
47
57
|
*
|
|
48
58
|
* `offsetTop` is deliberately not used: it is relative to whichever ancestor
|
|
49
59
|
* happens to be positioned, which no rule in the stylesheet guarantees.
|
|
@@ -69,8 +79,8 @@ export function useChangeNav(scrollRef: MutableRefObject<HTMLDivElement | null>)
|
|
|
69
79
|
const goToChange = (direction: 1 | -1): void => {
|
|
70
80
|
const scroller = scrollRef.current
|
|
71
81
|
if (scroller === null) return
|
|
72
|
-
const
|
|
73
|
-
const target = stepToBlock(
|
|
82
|
+
const measured = tops === undefined ? blockTops() : tops()
|
|
83
|
+
const target = stepToBlock(measured, anchorFrom(measured, scroller.scrollTop, navMemory.current), direction)
|
|
74
84
|
if (target === null) return
|
|
75
85
|
scroller.scrollTop = scrollTopFor(target.top)
|
|
76
86
|
// Read back rather than storing what was asked for: the browser clamps at
|
package/src/index.ts
CHANGED
|
@@ -91,8 +91,7 @@ export type { WriteResult }
|
|
|
91
91
|
const DIFF_CHAR_CAP = 400_000
|
|
92
92
|
/** Untracked files larger than this are listed + counted but never diffed. */
|
|
93
93
|
const UNTRACKED_FILE_BYTE_CAP = 1_000_000
|
|
94
|
-
|
|
95
|
-
const UNTRACKED_TOTAL_CHAR_CAP = 160_000
|
|
94
|
+
|
|
96
95
|
/** Files with a NUL byte in the first 8k are treated as binary. */
|
|
97
96
|
const BINARY_SNIFF_BYTES = 8_000
|
|
98
97
|
/** Context radius that makes `git diff` emit ONE hunk covering the whole file —
|
|
@@ -413,13 +412,21 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
413
412
|
async stats(worktreePath: string | undefined, signal: AbortSignal): Promise<WorkbenchStats> {
|
|
414
413
|
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
|
|
415
414
|
|
|
416
|
-
//
|
|
415
|
+
// Three independent reads of the same worktree. Running them together is
|
|
417
416
|
// safe: git takes .git/index.lock only to write back a refreshed index and
|
|
418
417
|
// skips that write when it cannot get the lock, so the reports stay correct.
|
|
419
|
-
|
|
418
|
+
//
|
|
419
|
+
// `git diff HEAD` is NOT among them any more. This call is polled — every
|
|
420
|
+
// 3 seconds while an agent is running — and the full patch is the most
|
|
421
|
+
// expensive thing in it by a wide margin: measured on a worktree with
|
|
422
|
+
// 90,000 changed lines it took 595ms and produced 7.43MB, of which the
|
|
423
|
+
// 400,000-character clip below then discarded 94.6% before it ever reached
|
|
424
|
+
// the browser. The tree and the counters need only `status` and
|
|
425
|
+
// `--numstat`, both of which stay around 110-140ms at that size, and the
|
|
426
|
+
// pane already fetches the file it is actually showing through `fileDiff`.
|
|
427
|
+
const [statusInfo, numstat, revInfo] = await Promise.all([
|
|
420
428
|
this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
|
|
421
429
|
this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
|
|
422
|
-
this.git(cwd, ['diff', 'HEAD'], signal),
|
|
423
430
|
this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
|
|
424
431
|
])
|
|
425
432
|
if (statusInfo.exitCode !== 0) {
|
|
@@ -430,25 +437,18 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
430
437
|
const counts = parseNumstat(numstat.stdout)
|
|
431
438
|
const files = parseStatus(statusInfo.stdout, counts)
|
|
432
439
|
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
440
|
+
// Every untracked file needs a line count and a binary flag for the tree,
|
|
441
|
+
// and both come off the raw buffer with no utf8 decode. The second pass
|
|
442
|
+
// that used to follow — decoding some of them and synthesizing new-file
|
|
443
|
+
// segments into the payload — is gone with the payload itself; `fileDiff`
|
|
444
|
+
// synthesizes the one segment the reader has actually opened.
|
|
437
445
|
const untracked = files.filter(file => file.status === 'untracked')
|
|
438
446
|
const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path))
|
|
439
447
|
|
|
440
|
-
let budget = UNTRACKED_TOTAL_CHAR_CAP
|
|
441
|
-
let untrackedDiff = ''
|
|
442
448
|
for (const [index, file] of untracked.entries()) {
|
|
443
449
|
const measure = measured[index]
|
|
444
450
|
file.addedLines = measure.lineCount
|
|
445
451
|
file.binary = measure.binary
|
|
446
|
-
if (budget <= 0 || !measure.diffable) continue
|
|
447
|
-
const segment = await untrackedSegment(cwd, file.path)
|
|
448
|
-
if (segment === null) continue
|
|
449
|
-
const text = clipDiff(segment, budget, '…[untracked diff truncated]')
|
|
450
|
-
untrackedDiff += `${text}\n`
|
|
451
|
-
budget -= text.length
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
let addedLines = 0
|
|
@@ -481,14 +481,13 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
481
481
|
}
|
|
482
482
|
const { ahead, behind } = parseBranch(statusInfo.stdout)
|
|
483
483
|
|
|
484
|
-
let combined = diff.stdout
|
|
485
|
-
if (untrackedDiff.length > 0) combined += `\n${untrackedDiff}`
|
|
486
|
-
combined = clipDiff(combined, DIFF_CHAR_CAP, '…[diff truncated]')
|
|
487
484
|
|
|
488
485
|
return {
|
|
489
486
|
worktreePath: cwd, branch, ahead, behind, detached,
|
|
490
487
|
addedLines, deletedLines, addedFiles, deletedFiles, modifiedFiles,
|
|
491
|
-
|
|
488
|
+
// No bundled patch: every per-file diff is fetched on demand. See the
|
|
489
|
+
// reads above for what that saves and why it is affordable.
|
|
490
|
+
files, diff: '',
|
|
492
491
|
// No log here: this call is polled every 15s, and the history list follows
|
|
493
492
|
// a ref this one knows nothing about. `commits` serves it instead.
|
|
494
493
|
commits: [],
|
|
@@ -496,14 +495,36 @@ export class GitWorkbenchService extends TypertRemoteService {
|
|
|
496
495
|
}
|
|
497
496
|
|
|
498
497
|
/**
|
|
499
|
-
* One file's diff on demand
|
|
500
|
-
*
|
|
501
|
-
*
|
|
498
|
+
* One file's diff on demand, for whichever view is asking.
|
|
499
|
+
*
|
|
500
|
+
* Three questions, because the drawer's three tabs are asking three different
|
|
501
|
+
* things about the same path and only the caller knows which:
|
|
502
|
+
*
|
|
503
|
+
* - with `commit`, that commit's change to the file;
|
|
504
|
+
* - with `base` and `head`, what differs between two refs — the Compare
|
|
505
|
+
* tab, which until now had no way to ask at all and showed a file with no
|
|
506
|
+
* detail whenever the bundled payload did not carry it;
|
|
507
|
+
* - with neither, the working tree against HEAD.
|
|
508
|
+
*
|
|
509
|
+
* The range answer is deliberately NOT cached, for the same reason
|
|
510
|
+
* `compareRefs` is not: a ref name is a moving pointer, unlike a commit hash.
|
|
511
|
+
*
|
|
512
|
+
* Plain-identifier params, signal last.
|
|
502
513
|
*/
|
|
503
514
|
@Remote('fileDiff')
|
|
504
|
-
async fileDiff(worktreePath: string, path: string, commit: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
|
|
515
|
+
async fileDiff(worktreePath: string, path: string, commit: string | undefined, base: string | undefined, head: string | undefined, signal: AbortSignal): Promise<{ readonly diff: string }> {
|
|
505
516
|
const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd()
|
|
506
517
|
if (typeof path !== 'string' || path.length === 0) return { diff: '' }
|
|
518
|
+
if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
|
|
519
|
+
if (!isRefName(base) || !isRefName(head)) return { diff: '' }
|
|
520
|
+
const ranged = await this.git(cwd, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal)
|
|
521
|
+
if (ranged.exitCode === 0) return { diff: ranged.stdout }
|
|
522
|
+
// Unrelated histories have no merge base for `A...B` to diff from; the
|
|
523
|
+
// two-tip diff still answers what differs, exactly as `compareRefs` does.
|
|
524
|
+
if (!isNoMergeBaseError(ranged.stderr)) return { diff: '' }
|
|
525
|
+
const tips = await this.git(cwd, ['diff', '--no-renames', base, head, '--', path], signal)
|
|
526
|
+
return { diff: tips.exitCode === 0 ? tips.stdout : '' }
|
|
527
|
+
}
|
|
507
528
|
if (typeof commit === 'string' && commit.length > 0) {
|
|
508
529
|
if (!COMMIT_HASH.test(commit)) return { diff: '' }
|
|
509
530
|
const key = cacheKey(cwd, commit, path)
|