@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/CHANGELOG.md +23 -0
- package/CHANGELOG_EN.md +24 -0
- package/lib/client.js +836 -413
- package/lib/index.js +48 -28
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.module.css +46 -9
- package/src/client/GitWorkbenchPanel.tsx +366 -98
- package/src/client/diff-nav.ts +28 -0
- package/src/client/highlight.ts +56 -0
- package/src/client/history-layout.ts +52 -0
- package/src/client/index.ts +10 -2
- package/src/client/locales.ts +8 -0
- 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",
|
|
@@ -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
|
-
|
|
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
|
-
/*
|
|
1324
|
-
row
|
|
1325
|
-
|
|
1326
|
-
|
|
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); }
|