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