@young1lin/dsh-ui-gitworkbench 0.1.6 → 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/lib/index.js CHANGED
@@ -100,8 +100,6 @@ import { bindingsPath, findRegisteredWorktree, isRefName, loadBindings, parseWor
100
100
  const DIFF_CHAR_CAP = 400_000;
101
101
  /** Untracked files larger than this are listed + counted but never diffed. */
102
102
  const UNTRACKED_FILE_BYTE_CAP = 1_000_000;
103
- /** At most this many bytes of synthesized untracked diff ride along in `stats`. */
104
- const UNTRACKED_TOTAL_CHAR_CAP = 160_000;
105
103
  /** Files with a NUL byte in the first 8k are treated as binary. */
106
104
  const BINARY_SNIFF_BYTES = 8_000;
107
105
  /** Context radius that makes `git diff` emit ONE hunk covering the whole file —
@@ -391,13 +389,21 @@ let GitWorkbenchService = (() => {
391
389
  /** Working-tree change stats for a worktree (plain-identifier params; signal last — SRC requirements). */
392
390
  async stats(worktreePath, signal) {
393
391
  const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
394
- // Four independent reads of the same worktree. Running them together is
392
+ // Three independent reads of the same worktree. Running them together is
395
393
  // safe: git takes .git/index.lock only to write back a refreshed index and
396
394
  // skips that write when it cannot get the lock, so the reports stay correct.
397
- const [statusInfo, numstat, diff, revInfo] = await Promise.all([
395
+ //
396
+ // `git diff HEAD` is NOT among them any more. This call is polled — every
397
+ // 3 seconds while an agent is running — and the full patch is the most
398
+ // expensive thing in it by a wide margin: measured on a worktree with
399
+ // 90,000 changed lines it took 595ms and produced 7.43MB, of which the
400
+ // 400,000-character clip below then discarded 94.6% before it ever reached
401
+ // the browser. The tree and the counters need only `status` and
402
+ // `--numstat`, both of which stay around 110-140ms at that size, and the
403
+ // pane already fetches the file it is actually showing through `fileDiff`.
404
+ const [statusInfo, numstat, revInfo] = await Promise.all([
398
405
  this.git(cwd, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'], signal),
399
406
  this.git(cwd, ['diff', 'HEAD', '--numstat'], signal),
400
- this.git(cwd, ['diff', 'HEAD'], signal),
401
407
  this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'], signal),
402
408
  ]);
403
409
  if (statusInfo.exitCode !== 0) {
@@ -406,26 +412,17 @@ let GitWorkbenchService = (() => {
406
412
  }
407
413
  const counts = parseNumstat(numstat.stdout);
408
414
  const files = parseStatus(statusInfo.stdout, counts);
409
- // Untracked files in two passes, because the two halves cost wildly
410
- // different amounts. EVERY untracked file needs a line count and a binary
411
- // flag, which come off the raw buffer with no utf8 decode; only the handful
412
- // that fit the payload budget pay for a decode and a synthesized segment.
415
+ // Every untracked file needs a line count and a binary flag for the tree,
416
+ // and both come off the raw buffer with no utf8 decode. The second pass
417
+ // that used to follow decoding some of them and synthesizing new-file
418
+ // segments into the payload is gone with the payload itself; `fileDiff`
419
+ // synthesizes the one segment the reader has actually opened.
413
420
  const untracked = files.filter(file => file.status === 'untracked');
414
421
  const measured = await mapPooled(untracked, UNTRACKED_READ_CONCURRENCY, file => measureUntracked(cwd, file.path));
415
- let budget = UNTRACKED_TOTAL_CHAR_CAP;
416
- let untrackedDiff = '';
417
422
  for (const [index, file] of untracked.entries()) {
418
423
  const measure = measured[index];
419
424
  file.addedLines = measure.lineCount;
420
425
  file.binary = measure.binary;
421
- if (budget <= 0 || !measure.diffable)
422
- continue;
423
- const segment = await untrackedSegment(cwd, file.path);
424
- if (segment === null)
425
- continue;
426
- const text = clipDiff(segment, budget, '…[untracked diff truncated]');
427
- untrackedDiff += `${text}\n`;
428
- budget -= text.length;
429
426
  }
430
427
  let addedLines = 0;
431
428
  let deletedLines = 0;
@@ -459,28 +456,51 @@ let GitWorkbenchService = (() => {
459
456
  }
460
457
  }
461
458
  const { ahead, behind } = parseBranch(statusInfo.stdout);
462
- let combined = diff.stdout;
463
- if (untrackedDiff.length > 0)
464
- combined += `\n${untrackedDiff}`;
465
- combined = clipDiff(combined, DIFF_CHAR_CAP, '…[diff truncated]');
466
459
  return {
467
460
  worktreePath: cwd, branch, ahead, behind, detached,
468
461
  addedLines, deletedLines, addedFiles, deletedFiles, modifiedFiles,
469
- files, diff: combined,
462
+ // No bundled patch: every per-file diff is fetched on demand. See the
463
+ // reads above for what that saves and why it is affordable.
464
+ files, diff: '',
470
465
  // No log here: this call is polled every 15s, and the history list follows
471
466
  // a ref this one knows nothing about. `commits` serves it instead.
472
467
  commits: [],
473
468
  };
474
469
  }
475
470
  /**
476
- * One file's diff on demand. With `commit` the diff is that commit's change to
477
- * the file; without it, the working tree against HEAD (plain-identifier params;
478
- * signal last).
471
+ * One file's diff on demand, for whichever view is asking.
472
+ *
473
+ * Three questions, because the drawer's three tabs are asking three different
474
+ * things about the same path and only the caller knows which:
475
+ *
476
+ * - with `commit`, that commit's change to the file;
477
+ * - with `base` and `head`, what differs between two refs — the Compare
478
+ * tab, which until now had no way to ask at all and showed a file with no
479
+ * detail whenever the bundled payload did not carry it;
480
+ * - with neither, the working tree against HEAD.
481
+ *
482
+ * The range answer is deliberately NOT cached, for the same reason
483
+ * `compareRefs` is not: a ref name is a moving pointer, unlike a commit hash.
484
+ *
485
+ * Plain-identifier params, signal last.
479
486
  */
480
- async fileDiff(worktreePath, path, commit, signal) {
487
+ async fileDiff(worktreePath, path, commit, base, head, signal) {
481
488
  const cwd = typeof worktreePath === 'string' && worktreePath.length > 0 ? worktreePath : process.cwd();
482
489
  if (typeof path !== 'string' || path.length === 0)
483
490
  return { diff: '' };
491
+ if (typeof base === 'string' && base.length > 0 && typeof head === 'string' && head.length > 0) {
492
+ if (!isRefName(base) || !isRefName(head))
493
+ return { diff: '' };
494
+ const ranged = await this.git(cwd, ['diff', '--no-renames', `${base}...${head}`, '--', path], signal);
495
+ if (ranged.exitCode === 0)
496
+ return { diff: ranged.stdout };
497
+ // Unrelated histories have no merge base for `A...B` to diff from; the
498
+ // two-tip diff still answers what differs, exactly as `compareRefs` does.
499
+ if (!isNoMergeBaseError(ranged.stderr))
500
+ return { diff: '' };
501
+ const tips = await this.git(cwd, ['diff', '--no-renames', base, head, '--', path], signal);
502
+ return { diff: tips.exitCode === 0 ? tips.stdout : '' };
503
+ }
484
504
  if (typeof commit === 'string' && commit.length > 0) {
485
505
  if (!COMMIT_HASH.test(commit))
486
506
  return { diff: '' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@young1lin/dsh-ui-gitworkbench",
3
- "version": "0.1.6",
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",
@@ -1278,7 +1278,11 @@
1278
1278
  the compact control it hosts, so a pane with tools and a pane without one
1279
1279
  still line up across the divider. */
1280
1280
  .paneHead {
1281
- display: flex; align-items: center; gap: 8px;
1281
+ /* Wraps rather than overflows. Dragged narrow the pane cannot hold a title,
1282
+ a funnel and a search box on one line, and what it used to do was clip the
1283
+ search box at the pane's edge — a control half off the screen, with no
1284
+ sign that the rest of it existed. */
1285
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
1282
1286
  flex: none;
1283
1287
  min-height: 37px; box-sizing: border-box;
1284
1288
  padding: 6px var(--gs-gutter-pane);
@@ -1320,10 +1324,12 @@
1320
1324
  .commitLine {
1321
1325
  display: flex; align-items: stretch;
1322
1326
  flex: none;
1323
- /* Must equal GRAPH_ROW_H in GitWorkbenchPanel.tsx, or the lanes drawn per
1324
- row stop meeting across the seam between rows. Guarded by
1325
- tests/commit-row-height.test.ts. */
1326
- height: 28px;
1327
+ /* Published by the panel from `COMMIT_ROW_H`, which the lane graph also draws
1328
+ each row's segment at one source, because a row and its segment that
1329
+ disagree leave the lanes short of the seam between rows. The two
1330
+ arrangements want different rows, which is why this is a property and not
1331
+ a number. Guarded by tests/commit-row-height.test.ts. */
1332
+ height: var(--gs-commit-row);
1327
1333
  }
1328
1334
  .graphCell { flex: none; display: block; }
1329
1335
  /* An inset row, like dsh's own session rows: 12px radius, filled when active. */
@@ -1343,6 +1349,20 @@
1343
1349
  overflow: hidden;
1344
1350
  transition: background 120ms ease, border-color 120ms ease;
1345
1351
  }
1352
+ /* Beside the diff the row turns into two lines: a pane of about 340px has no
1353
+ width for the hash, the author, the date AND the subject on one, and the
1354
+ subject is the part that would have given way. Above the diff it stays one
1355
+ line — see the row's two shapes in `CommitRow`. */
1356
+ .commitsPane[data-layout="columns"] .commit {
1357
+ flex-direction: column; align-items: stretch; justify-content: center; gap: 2px;
1358
+ }
1359
+ /* The subject line must not absorb the row's spare height, or the two lines
1360
+ stop sitting together in the middle of the row. */
1361
+ .commitsPane[data-layout="columns"] .commitSubjectRow { flex: none; }
1362
+ .commitTop {
1363
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
1364
+ min-width: 0;
1365
+ }
1346
1366
  .commit:hover { background: var(--gs-raise); }
1347
1367
  .commitActive { border-color: var(--gs-accent-border); background: var(--gs-accent-bg); }
1348
1368
  /* Pushed to the right end of the row, and never squeezed: the subject is what
@@ -1786,6 +1806,10 @@
1786
1806
  .sideNumAdd { background: var(--gs-add-num, var(--gs-add-line)); }
1787
1807
  .sideNumDel { background: var(--gs-del-num, var(--gs-del-line)); }
1788
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; }
1789
1813
  .sideCodeSame { color: var(--gs-fg-muted); }
1790
1814
  .sideCodeAdd { background: var(--gs-add-line); }
1791
1815
  .sideCodeDel { background: var(--gs-del-line); }
@@ -2093,7 +2117,7 @@
2093
2117
  * its own padding, radius and font-size, and the same "small secondary button"
2094
2118
  * had drifted to four heights and three radii across one screen.
2095
2119
  */
2096
- .btn, .miniBtn, .treeIcon, .commitCopy, .scopeBtn, .refButton, .funnelButton, .funnelPreset {
2120
+ .btn, .miniBtn, .treeIcon, .commitCopy, .scopeBtn, .refButton, .funnelButton, .funnelPreset, .layoutButton {
2097
2121
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
2098
2122
  box-sizing: border-box;
2099
2123
  flex: none;
@@ -2111,14 +2135,14 @@
2111
2135
  transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
2112
2136
  }
2113
2137
  .btn:hover, .miniBtn:hover, .treeIcon:hover, .commitCopy:hover,
2114
- .scopeBtn:hover, .refButton:hover, .funnelButton:hover, .funnelPreset:hover {
2138
+ .scopeBtn:hover, .refButton:hover, .funnelButton:hover, .funnelPreset:hover, .layoutButton:hover {
2115
2139
  background: var(--gs-raise);
2116
2140
  color: var(--gs-fg);
2117
2141
  border-color: var(--gs-fg-fainter);
2118
2142
  }
2119
2143
  .btn:focus-visible, .miniBtn:focus-visible, .treeIcon:focus-visible,
2120
2144
  .commitCopy:focus-visible, .scopeBtn:focus-visible, .refButton:focus-visible,
2121
- .funnelPreset:focus-visible {
2145
+ .funnelPreset:focus-visible, .layoutButton:focus-visible {
2122
2146
  outline: 2px solid var(--gs-accent);
2123
2147
  outline-offset: 1px;
2124
2148
  }
@@ -2131,7 +2155,7 @@
2131
2155
 
2132
2156
  /* Pane tools give their vertical space back to the diff; the date presets sit
2133
2157
  inside a 320px popover, so they take the compact height too. */
2134
- .treeIcon, .commitCopy, .funnelPreset {
2158
+ .treeIcon, .commitCopy, .funnelPreset, .layoutButton {
2135
2159
  height: var(--gs-h-compact);
2136
2160
  padding: var(--gs-pad-compact);
2137
2161
  font-size: var(--gs-t-dense);
@@ -2141,6 +2165,19 @@
2141
2165
  the pane drags down to, two more text buttons cannot share a row with the
2142
2166
  count — and these two are the pair a tree header always carries. */
2143
2167
  .treeIcon { width: var(--gs-h-compact); padding: 0; }
2168
+ /* The arrangement switch: two square icon buttons at the end of the commit
2169
+ list's head, side by side with no gap so they read as one control. Icon-only
2170
+ for the same reason the tree's are — the head already carries a title, a
2171
+ funnel and a search box, and at the 190px the pane drags down to there is no
2172
+ room for two more words. */
2173
+ /* Pushed to the far end of History's toolbar row, away from the ref picker:
2174
+ one changes WHAT is listed, the other only where it is drawn. */
2175
+ .layoutSwitch { display: inline-flex; flex: none; gap: 2px; margin-left: auto; }
2176
+ .layoutButton { width: var(--gs-h-compact); padding: 0; }
2177
+ /* Qualified, like `.treeIconOn`: the shared vocabulary sets `color` at the
2178
+ same specificity a few rules above. */
2179
+ .layoutButton.layoutButtonOn { color: var(--gs-accent); border-color: var(--gs-accent); }
2180
+ .layoutGlyph { display: block; }
2144
2181
  /* Qualified, like `.funnelButtonActive`: this sits inside the shared button
2145
2182
  vocabulary, which sets `color` at the same specificity a few lines above. */
2146
2183
  .treeIcon.treeIconOn { color: var(--gs-accent); border-color: var(--gs-accent); }