@young1lin/dsh-ui-gitworkbench 0.1.14 → 0.1.16
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 +23 -0
- package/README.md +29 -3
- package/README_EN.md +1 -1
- package/lib/client.js +1512 -470
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +346 -117
- package/lib/path-lock.js +54 -0
- package/lib/repo-root.js +60 -0
- package/lib/worktree.js +83 -0
- package/lib/write-checked.js +1 -1
- package/package.json +5 -5
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +116 -121
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +100 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -0
- package/src/client/diff-nav.ts +4 -1
- package/src/client/dir-tree.ts +31 -1
- package/src/client/file-rows.ts +40 -0
- package/src/client/h-rail.ts +70 -0
- package/src/client/ignored-cache.ts +193 -0
- package/src/client/index.ts +22 -3
- package/src/client/locales.ts +12 -2
- package/src/client/row-heights.ts +225 -0
- package/src/client/styles/changes.css +33 -2
- package/src/client/styles/controls.css +5 -0
- package/src/client/styles/files.css +5 -0
- package/src/client/styles/rails.css +72 -0
- package/src/client/use-row-window.ts +7 -3
- package/src/client/use-variable-row-window.ts +210 -0
- package/src/dir-listing.ts +47 -0
- package/src/fs-remove.ts +5 -36
- package/src/index.ts +372 -118
- package/src/path-lock.ts +56 -0
- package/src/repo-root.ts +66 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +97 -0
- package/src/write-checked.ts +1 -1
package/lib/path-lock.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a client-supplied path becomes an absolute path on disk.
|
|
3
|
+
*
|
|
4
|
+
* git needs no such lock: whatever pathspec it is handed, it will not read or
|
|
5
|
+
* write outside the repository, which is why `isSafePathArg` only has to keep
|
|
6
|
+
* a path from being mistaken for an option. The moment a path leaves git and
|
|
7
|
+
* reaches `readFile`, `stat` or `readdir`, that backstop is gone — `join(root,
|
|
8
|
+
* '../../../etc/passwd')` is just a path, and the browser is the least trusted
|
|
9
|
+
* source of paths this plugin has.
|
|
10
|
+
*
|
|
11
|
+
* So every filesystem call in the host resolves through here, and the RPCs
|
|
12
|
+
* that do it are pinned by `host-rooted-paths.test.ts` so a new one cannot
|
|
13
|
+
* quietly join a client string onto the root instead.
|
|
14
|
+
*
|
|
15
|
+
* What this does NOT defend against, deliberately and for the same reason
|
|
16
|
+
* `fs-remove.ts` says so: `resolve` is lexical, so a SYMLINK inside the
|
|
17
|
+
* worktree that points outward still resolves inside and is followed. Closing
|
|
18
|
+
* that means a `realpath` per segment on every read of every file, for a case
|
|
19
|
+
* git itself does not defend against and that presupposes write access to the
|
|
20
|
+
* repository the reader already opened.
|
|
21
|
+
*
|
|
22
|
+
* @module @young1lin/dsh-ui-gitworkbench/path-lock
|
|
23
|
+
*/
|
|
24
|
+
import { resolve, sep } from 'node:path';
|
|
25
|
+
import { isSafeRelativePath } from './discard-ops.js';
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a repo-relative path against the worktree root, refusing to leave it.
|
|
28
|
+
*
|
|
29
|
+
* Two locks, not one. {@link isSafeRelativePath} rejects the traversal
|
|
30
|
+
* SPELLINGS — absolute paths, drive letters, UNC prefixes, NUL bytes, any `..`
|
|
31
|
+
* segment including one buried mid-path. The `startsWith` below re-checks the
|
|
32
|
+
* RESOLVED path, which is the form the filesystem acts on, so a path that
|
|
33
|
+
* survives the first check by being spelled unusually still has to land inside
|
|
34
|
+
* the root to be acted on.
|
|
35
|
+
*
|
|
36
|
+
* @param root - the worktree directory, absolute.
|
|
37
|
+
* @param relative - repo-relative path from the client or from git's output.
|
|
38
|
+
* @returns the absolute path to act on.
|
|
39
|
+
* @throws if the path is not a safe relative path, resolves outside the root,
|
|
40
|
+
* or IS the root.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveInside(root, relative) {
|
|
43
|
+
if (!isSafeRelativePath(relative)) {
|
|
44
|
+
throw new Error(`unsafe path argument: ${JSON.stringify(relative)}`);
|
|
45
|
+
}
|
|
46
|
+
const base = resolve(root);
|
|
47
|
+
const target = resolve(base, relative);
|
|
48
|
+
if (target === base)
|
|
49
|
+
throw new Error('refusing to act on the worktree root itself');
|
|
50
|
+
if (!target.startsWith(base + sep)) {
|
|
51
|
+
throw new Error(`path escapes the worktree: ${JSON.stringify(relative)}`);
|
|
52
|
+
}
|
|
53
|
+
return target;
|
|
54
|
+
}
|
package/lib/repo-root.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the drawer runs git: at the repository ROOT, not at the directory the
|
|
3
|
+
* session happened to open.
|
|
4
|
+
*
|
|
5
|
+
* Every path the drawer carries — the tree's entries, the diff's pathspecs,
|
|
6
|
+
* the editor's save target — is REPOSITORY-RELATIVE, because that is what
|
|
7
|
+
* `git status --porcelain` and `git diff --numstat` print wherever they run.
|
|
8
|
+
* Pathspecs and `:path` revisions are the opposite: git resolves them against
|
|
9
|
+
* the process cwd. The two coincide only when the session opened the root
|
|
10
|
+
* itself. A session opened at a subdirectory (a monorepo's `server/`, say)
|
|
11
|
+
* gets a drawer that LISTS the right files and then quietly fails to do
|
|
12
|
+
* anything with them: `git diff HEAD -- server/main.go` run from `server/`
|
|
13
|
+
* looks for `server/server/main.go`, matches nothing, and exits 0 with empty
|
|
14
|
+
* output — a changed file that opens to a blank pane, a stage tick that dies
|
|
15
|
+
* with "pathspec did not match", a blame that cannot find the path in HEAD.
|
|
16
|
+
*
|
|
17
|
+
* Resolving the root once and running everything there makes every
|
|
18
|
+
* repository-relative spelling correct by construction, whatever directory the
|
|
19
|
+
* session opened. The resolve is a real git spawn per RPC entry — folded into
|
|
20
|
+
* the parallel batch where a poll pays for it — and deliberately NOT cached:
|
|
21
|
+
* re-resolving per call is what lets `git init` run inside a subdirectory
|
|
22
|
+
* mid-session and be picked up by the next poll, and a cache would trade that
|
|
23
|
+
* for a spawn that already runs concurrently with the reads it precedes.
|
|
24
|
+
*
|
|
25
|
+
* Pure and git-injected (the `write-checked` pattern) so vitest can pin both
|
|
26
|
+
* halves: the resolution itself, and the git behaviors the whole arrangement
|
|
27
|
+
* rests on.
|
|
28
|
+
*
|
|
29
|
+
* @module @young1lin/dsh-ui-gitworkbench/repo-root
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The repository root of a directory, or null when git knows none.
|
|
33
|
+
*
|
|
34
|
+
* `--show-toplevel` is the discovery git itself uses, so what it returns is by
|
|
35
|
+
* definition where the porcelain paths of commands run in that directory are
|
|
36
|
+
* rooted — including a linked worktree's own root when the directory sits in
|
|
37
|
+
* one. Output is normalized to forward slashes so `join()` behaves the same on
|
|
38
|
+
* every platform (git for Windows already prints them that way).
|
|
39
|
+
* @param git - how to run git.
|
|
40
|
+
* @param cwd - any directory inside the repository.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveRepoRoot(git, cwd) {
|
|
43
|
+
const out = await git(cwd, ['rev-parse', '--show-toplevel']);
|
|
44
|
+
if (out.exitCode !== 0)
|
|
45
|
+
return null;
|
|
46
|
+
return out.stdout.trim().replace(/\\/g, '/') || null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The directory to run git in for a session's workspace: its repository root,
|
|
50
|
+
* or the directory itself when it is not inside a repository.
|
|
51
|
+
*
|
|
52
|
+
* The fallback keeps the failure honest: outside a repository the caller's own
|
|
53
|
+
* git run fails exactly as it did before this module existed, and that error —
|
|
54
|
+
* not a resolution error — is what the reader should see.
|
|
55
|
+
* @param git - how to run git.
|
|
56
|
+
* @param cwd - the directory the session opened.
|
|
57
|
+
*/
|
|
58
|
+
export async function rootedDir(git, cwd) {
|
|
59
|
+
return (await resolveRepoRoot(git, cwd)) ?? cwd;
|
|
60
|
+
}
|
package/lib/worktree.js
CHANGED
|
@@ -141,6 +141,89 @@ export function isRefName(ref) {
|
|
|
141
141
|
export function worktreeDir(repoRoot, name) {
|
|
142
142
|
return `${repoRoot.replace(/\/+$/, '')}/.agents/worktrees/${name}`;
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Deepest ancestor chain walked before the lookup gives up. Real delegation
|
|
146
|
+
* nests two or three levels; the cap exists so a corrupt lineage (a cycle the
|
|
147
|
+
* guard below somehow missed, a pathologically deep chain) costs a bounded
|
|
148
|
+
* number of lookups instead of walking forever.
|
|
149
|
+
*/
|
|
150
|
+
const LINEAGE_HOP_CAP = 8;
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the binding a session effectively works under: its own, else the
|
|
153
|
+
* nearest ancestor's.
|
|
154
|
+
*
|
|
155
|
+
* A subagent session never gets a binding of its own — `worktree_enter` is
|
|
156
|
+
* called by the session that wants the worktree — but it works wherever its
|
|
157
|
+
* parent conversation works: the standing prompt, the chip, and `worktree_exit`'s
|
|
158
|
+
* diagnostics all answer "which worktree is THIS session in" through here. The
|
|
159
|
+
* walk is re-resolved on every read, so a session exiting its worktree changes
|
|
160
|
+
* only its own binding: descendants lend the next bound ancestor up the chain
|
|
161
|
+
* on their next read (possibly none — the common case — possibly a grandparent's,
|
|
162
|
+
* which is still the conversation tree they work in) and nothing dangles.
|
|
163
|
+
*
|
|
164
|
+
* Own wins over inherited on purpose: a session that enters a worktree of its
|
|
165
|
+
* own is deliberately somewhere else than its parent.
|
|
166
|
+
* @param sessionId - the session whose effective binding is wanted.
|
|
167
|
+
* @param parentOf - session id → parent session id, as `agent/session-start`
|
|
168
|
+
* delivered it (subagent headers name their parent).
|
|
169
|
+
* @param bindingOf - binding lookup (the bindings file, or the prompt mirror).
|
|
170
|
+
* @returns the effective binding, or undefined when neither the session nor any
|
|
171
|
+
* ancestor (within the hop cap) is bound.
|
|
172
|
+
*/
|
|
173
|
+
export function resolveEffectiveBinding(sessionId, parentOf, bindingOf) {
|
|
174
|
+
const own = bindingOf(sessionId);
|
|
175
|
+
if (own !== undefined)
|
|
176
|
+
return { binding: own, inherited: false };
|
|
177
|
+
const seen = new Set([sessionId]);
|
|
178
|
+
let ancestor = parentOf.get(sessionId);
|
|
179
|
+
for (let hops = 0; ancestor !== undefined && hops < LINEAGE_HOP_CAP; hops += 1) {
|
|
180
|
+
if (seen.has(ancestor))
|
|
181
|
+
return undefined;
|
|
182
|
+
seen.add(ancestor);
|
|
183
|
+
const binding = bindingOf(ancestor);
|
|
184
|
+
if (binding !== undefined)
|
|
185
|
+
return { binding, inherited: true };
|
|
186
|
+
ancestor = parentOf.get(ancestor);
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The session's parent edge, read off a dsh session header: the id of the
|
|
192
|
+
* session this one was delegated by, or undefined for a top-level session (or
|
|
193
|
+
* a malformed empty value). Both `parentOf` feeds — the `agent/session-start`
|
|
194
|
+
* listener and the prompt-time self-heal — go through here, so their input
|
|
195
|
+
* guards cannot drift apart.
|
|
196
|
+
*/
|
|
197
|
+
export function lineageEdgeOf(header) {
|
|
198
|
+
const parent = header?.parentSession;
|
|
199
|
+
return typeof parent === 'string' && parent.length > 0 ? parent : undefined;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The standing notice for a session's effective binding — the text the
|
|
203
|
+
* `worktree:binding` prompt context returns. Both variants carry the same two
|
|
204
|
+
* operational rules; what differs is who holds the binding, and the inherited
|
|
205
|
+
* variant must NOT offer `worktree_exit` (the caller cannot unbind a parent's
|
|
206
|
+
* binding — the exit would fail, and the model should not be told to try).
|
|
207
|
+
* @param name - worktree name (also the directory under `.agents/worktrees/`).
|
|
208
|
+
* @param branch - branch checked out there, when known.
|
|
209
|
+
* @param inherited - whether an ancestor, not this session, holds the binding.
|
|
210
|
+
*/
|
|
211
|
+
export function bindingNotice(name, branch, inherited) {
|
|
212
|
+
const rel = `.agents/worktrees/${name}`;
|
|
213
|
+
const branchNote = branch === undefined ? '' : ` (branch ${branch})`;
|
|
214
|
+
const opening = inherited
|
|
215
|
+
? `This session works in git worktree "${name}"${branchNote}, entered by its parent session.`
|
|
216
|
+
: `This session is bound to git worktree "${name}"${branchNote}.`;
|
|
217
|
+
const closing = inherited
|
|
218
|
+
? 'A path without that prefix acts on the MAIN worktree, not the worktree this conversation works in. '
|
|
219
|
+
+ '(The binding belongs to the parent session; worktree_exit here would not unbind it.)'
|
|
220
|
+
: 'A path without that prefix acts on the MAIN worktree, not the bound one. Call worktree_exit to unbind.';
|
|
221
|
+
return `${opening}\n`
|
|
222
|
+
+ 'The session working directory is still the repository root, so the binding is a convention you must apply yourself:\n'
|
|
223
|
+
+ `- shell commands: pass workdir "${rel}"\n`
|
|
224
|
+
+ `- file tools: prefix every path with ${rel}/\n`
|
|
225
|
+
+ closing;
|
|
226
|
+
}
|
|
144
227
|
export function parseWorktreeList(porcelain) {
|
|
145
228
|
const out = [];
|
|
146
229
|
let path = '';
|
package/lib/write-checked.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { randomBytes } from 'node:crypto';
|
|
33
33
|
import { renameWithRetry } from './atomic-json.js';
|
|
34
|
-
import { resolveInside } from './
|
|
34
|
+
import { resolveInside } from './path-lock.js';
|
|
35
35
|
import { decodesAsUtf8, isSafePathArg } from './git-ops.js';
|
|
36
36
|
/**
|
|
37
37
|
* Run one checked write end to end. Never throws: every failure, including an
|
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.16",
|
|
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",
|
|
@@ -66,10 +66,10 @@
|
|
|
66
66
|
"packageManager": "pnpm@10.20.0",
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
69
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
71
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
72
|
-
"@deepseek-ai/dsh-typert-protocol": "^0.1.
|
|
69
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2",
|
|
73
73
|
"react": "^18.2.0"
|
|
74
74
|
},
|
|
75
75
|
"peerDependenciesMeta": {
|
|
@@ -16,6 +16,11 @@ const CHROME_GLYPH = {
|
|
|
16
16
|
// and at 14px the only thing telling them apart is the arrow count.
|
|
17
17
|
refresh: 'M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z',
|
|
18
18
|
close: 'M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z',
|
|
19
|
+
// Drawn here rather than borrowed: Bootstrap has no wrap glyph, and the
|
|
20
|
+
// three text rules with a return arrow are what every editor spells this
|
|
21
|
+
// with. Same 16 viewBox and single fill as the rest, so the row still reads
|
|
22
|
+
// as one set.
|
|
23
|
+
wrap: 'M1 3.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5zM1 7.5a.5.5 0 0 1 .5-.5h11a2.5 2.5 0 0 1 0 5h-2.293l1.147 1.146a.5.5 0 0 1-.708.708l-2-2a.5.5 0 0 1 0-.708l2-2a.5.5 0 0 1 .708.708L10.207 11H12.5a1.5 1.5 0 0 0 0-3h-11a.5.5 0 0 1-.5-.5zM1 12.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5z',
|
|
19
24
|
} as const
|
|
20
25
|
|
|
21
26
|
export function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
|
|
@@ -66,6 +66,9 @@ const paintFacet = Facet.define<PaintFn | null, PaintFn | null>({
|
|
|
66
66
|
combine: values => values.length > 0 ? values[0]! : null,
|
|
67
67
|
})
|
|
68
68
|
const paintCompartment = new Compartment()
|
|
69
|
+
/** Soft wrap, in and out without rebuilding the view — the caret, the undo
|
|
70
|
+
* stack and the selection all survive the toggle. */
|
|
71
|
+
const wrapCompartment = new Compartment()
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* How long after the last keystroke the editor recomputes what it paints.
|
|
@@ -422,7 +425,7 @@ const paneTheme = EditorView.theme({
|
|
|
422
425
|
...SEARCH_PANEL_THEME,
|
|
423
426
|
})
|
|
424
427
|
|
|
425
|
-
export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel, onSave, blame, notCommitted, readOnly, onBlameClick }: {
|
|
428
|
+
export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel, onSave, blame, notCommitted, readOnly, onBlameClick, wrap }: {
|
|
426
429
|
/** The pane's buffer. The view is written to only when this really differs. */
|
|
427
430
|
value: string
|
|
428
431
|
/** The other side's whole text — the index side, for the unstaged layer this
|
|
@@ -450,6 +453,11 @@ export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel
|
|
|
450
453
|
readOnly?: boolean
|
|
451
454
|
/** A click in the blame gutter, with the 1-based line number. */
|
|
452
455
|
onBlameClick?: (line: number) => void
|
|
456
|
+
/** Soft wrap. CodeMirror owns variable line heights natively — its height
|
|
457
|
+
* oracle measures wrapped lines and its own viewport walk stays correct —
|
|
458
|
+
* so this is the whole change here, unlike the diff panes, whose windowing
|
|
459
|
+
* assumes a fixed row height. */
|
|
460
|
+
wrap?: boolean
|
|
453
461
|
}): ReactNode {
|
|
454
462
|
/**
|
|
455
463
|
* Editable, and SAID to be editable, from one boolean.
|
|
@@ -486,6 +494,7 @@ export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel
|
|
|
486
494
|
searchCount,
|
|
487
495
|
highlightActiveLine(),
|
|
488
496
|
paintCompartment.of(paintFacet.of(paint)),
|
|
497
|
+
wrapCompartment.of(wrap === true ? EditorView.lineWrapping : []),
|
|
489
498
|
painter,
|
|
490
499
|
blameField,
|
|
491
500
|
blameCompartment.of([]),
|
|
@@ -565,5 +574,14 @@ export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel
|
|
|
565
574
|
current.dispatch({ effects: paintCompartment.reconfigure(paintFacet.of(paint)) })
|
|
566
575
|
}, [paint])
|
|
567
576
|
|
|
577
|
+
// Wrap in or out. Through the compartment rather than a rebuild: the reader
|
|
578
|
+
// toggles this to look at the line they are already on, and a rebuilt view
|
|
579
|
+
// would drop the caret and the undo stack to show it to them.
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
const current = view.current
|
|
582
|
+
if (current === null) return
|
|
583
|
+
current.dispatch({ effects: wrapCompartment.reconfigure(wrap === true ? EditorView.lineWrapping : []) })
|
|
584
|
+
}, [wrap])
|
|
585
|
+
|
|
568
586
|
return <div ref={host} className={css.cmHost} data-editable={editable ? '' : undefined} />
|
|
569
587
|
}
|