dsh-multi-folder 0.2.4 → 0.3.1
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/README.md +5 -4
- package/README.zh.md +5 -4
- package/docs/design.md +45 -5
- package/lib/client.js +324 -21
- package/lib/index.js +137 -4
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ A Multi-folder button appears in the session header, and a second entry appears
|
|
|
49
49
|
|
|
50
50
|
| Action | Behavior |
|
|
51
51
|
| ------ | -------- |
|
|
52
|
-
| Add directory | Opens the
|
|
52
|
+
| Add directory | Opens the plugin's own directory browser (path field, one level of child directories, optional new folder) |
|
|
53
53
|
| Remove / refresh | Applies immediately |
|
|
54
54
|
| Switch session | The panel auto-switches to that session's directories |
|
|
55
55
|
| Reopen panel | Uses the per-session cache — no redundant command rows |
|
|
@@ -82,7 +82,8 @@ When a shell run ends in such a denial and references a configured secondary dir
|
|
|
82
82
|
- **Prompt injection** — one ordered `systemPrompt` section with a text provider evaluated per assembly, rendering only for sessions whose workspace has configured directories.
|
|
83
83
|
- **Notifications** — a pending notice armed by the command handler (only on actual change) is consumed at the next boundary by either the `agent/pre-step` waterfall (prepend into the entering message batch) or the `tools/post-execute` waterfall (attach as `additionalContexts`), whichever fires first — the framework's native plugin-sourced `notice` context.
|
|
84
84
|
- **Configuration & security boundary** — per-workspace config lives in a host-owned store outside every agent sandbox root (`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`). Direct `write`/`edit` attempts against the config file are rejected with an explicit message — **the agent can never self-grant directories; configuration is user-managed by design**. See [SECURITY.md](SECURITY.md).
|
|
85
|
-
- **Sessionless remote API** — a `multiFolder` namespace registered through `ctx.typert.register` (hand-written `src-json` descriptors) plus a plain-object service provided as `multiFolder`. Its `list`/`add`/`remove`/`set` methods are keyed by workspace **path** and share one validated core with the `/multi-folder` command, so the creation page can configure directories before any session exists.
|
|
85
|
+
- **Sessionless remote API** — a `multiFolder` namespace registered through `ctx.typert.register` (hand-written `src-json` descriptors) plus a plain-object service provided as `multiFolder`. Its `list`/`add`/`remove`/`set` methods are keyed by workspace **path** and share one validated core with the `/multi-folder` command, so the creation page can configure directories before any session exists. `browse`/`makeDir` ride the same namespace: they serve the plugin's own directory browser and never touch the configuration store.
|
|
86
|
+
- **Owned directory browser** — "Add directory" is drawn by this plugin and served by `browse`/`makeDir`, so it behaves identically in every deployment: the host's native chooser composition, the browse composition (LAN or remote clients, desktop shells) and shells that compose no picker at all. Listing rides the host `fs` seam (`fs.resolve` + `fs.listDir`).
|
|
86
87
|
- **Client** — a hand-maintained factory bundle (`window.__ModuleLoader__.load`), no build toolchain required. The panel drives the host through two channels: the Remote BFF (`ctx.remote.commands.execute`) for sessions, and the shared `/api` RPC channel (`ctx.connection.rpc.call`) for the sessionless endpoints.
|
|
87
88
|
|
|
88
89
|
## Project layout
|
|
@@ -90,8 +91,8 @@ When a shell run ends in such a denial and references a configured secondary dir
|
|
|
90
91
|
| Path | Purpose |
|
|
91
92
|
| ---- | ------- |
|
|
92
93
|
| `cordis.patch.yml` | Profile patch layer inserting the `dsh-multi-folder` row |
|
|
93
|
-
| `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
|
|
94
|
-
| `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel + session-creation page entry (input-dock chip / upstream hero chip / fixed fallback launcher) |
|
|
94
|
+
| `lib/index.js` | Host plugin: config store, tool-pipeline interception, prompt injection, dual-channel notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API (configuration plus `browse`/`makeDir` for the owned browser) |
|
|
95
|
+
| `lib/client.js` | Client plugin (factory bundle): session-header button + overlay panel + session-creation page entry (input-dock chip / upstream hero chip / fixed fallback launcher) + the owned directory browser behind "Add directory" |
|
|
95
96
|
| `test/` | Runtime-free behavior tests (see Development) |
|
|
96
97
|
| `docs/` | Design and analysis documents |
|
|
97
98
|
|
package/README.zh.md
CHANGED
|
@@ -49,7 +49,7 @@ dsh plugin --profile web add dsh-multi-folder
|
|
|
49
49
|
|
|
50
50
|
| 操作 | 行为 |
|
|
51
51
|
| ---- | ---- |
|
|
52
|
-
| 添加目录 |
|
|
52
|
+
| 添加目录 | 打开插件自带的目录浏览器(路径输入框 + 一级子目录列表 + 可新建文件夹) |
|
|
53
53
|
| 移除 / 刷新 | 立即生效 |
|
|
54
54
|
| 切换会话 | 面板自动切换为该会话的副工作目录 |
|
|
55
55
|
| 重新打开面板 | 使用会话级缓存,不产生冗余命令行 |
|
|
@@ -82,7 +82,8 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
|
|
|
82
82
|
- **提示词注入**——一个有序 `systemPrompt` 段落,text provider 每次组装按会话求值,仅为配置了副目录的会话渲染。
|
|
83
83
|
- **通知**——命令处理器仅在目录集合实际变化时置位 pending notice;`agent/pre-step`(前置注入进入批次)与 `tools/post-execute`(附加为 `additionalContexts`)两个通道中先触发者消费——均使用框架原生的插件来源 `notice` 上下文。
|
|
84
84
|
- **配置与安全边界**——per-workspace 配置存储于 Agent 沙箱之外的宿主自有目录(`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`)。对配置文件的任何直接 `write`/`edit` 都会收到显式拒绝——**Agent 永远无法自我授予目录,配置权仅属于用户**。详见 [SECURITY.md](SECURITY.md)。
|
|
85
|
-
- **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder`
|
|
85
|
+
- **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。`browse`/`makeDir` 属于同一命名空间:它们只服务插件自带的目录浏览器,不触碰配置存储。
|
|
86
|
+
- **自带目录浏览器**——「添加目录」由插件自己绘制、经 `browse`/`makeDir` 提供服务,因此在任何部署下行为一致:宿主使用原生选择器的组合、使用 browse 后端的组合(局域网/远程客户端、桌面壳),以及完全没有选择器的壳。列目录走宿主 `fs` seam(`fs.resolve` + `fs.listDir`)。
|
|
86
87
|
- **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经两条通道驱动宿主:会话内走 Remote BFF(`ctx.remote.commands.execute`),无会话端点走共享 `/api` RPC 通道(`ctx.connection.rpc.call`)。
|
|
87
88
|
|
|
88
89
|
## 目录结构
|
|
@@ -90,8 +91,8 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
|
|
|
90
91
|
| 路径 | 作用 |
|
|
91
92
|
| ---- | ---- |
|
|
92
93
|
| `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
|
|
93
|
-
| `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API |
|
|
94
|
-
| `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(输入框上方的 dock 胶囊 / 上游 hero chip /
|
|
94
|
+
| `lib/index.js` | 宿主插件:配置存储、工具流水线拦截、提示词注入、双通道通知、`/multi-folder` 命令、无会话 `multiFolder/*` 远程 API(配置端点 + 自带浏览器用的 `browse`/`makeDir`) |
|
|
95
|
+
| `lib/client.js` | 客户端插件(factory bundle):会话头部按钮 + 覆盖层面板 + 会话创建页入口(输入框上方的 dock 胶囊 / 上游 hero chip / 右下角兜底浮动按钮)+「添加目录」背后的自带目录浏览器 |
|
|
95
96
|
| `test/` | 免 DSH 运行时的行为测试(见开发) |
|
|
96
97
|
| `docs/` | 设计与分析文档 |
|
|
97
98
|
|
package/docs/design.md
CHANGED
|
@@ -14,8 +14,8 @@ agent informed. No new tools are added.
|
|
|
14
14
|
|
|
15
15
|
| Half | File | Role |
|
|
16
16
|
| ---- | ---- | ---- |
|
|
17
|
-
| Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API |
|
|
18
|
-
| Client | `lib/client.js` | Session-header button + overlay panel; session-creation page entry (input-dock chip, upstream hero chip, or fixed fallback launcher — one at a time), all driving the host through the Remote BFF / shared RPC channel |
|
|
17
|
+
| Host | `lib/index.js` | Config store, tool-pipeline interception, prompt section, notifications, `/multi-folder` command, sessionless `multiFolder/*` remote API (configuration plus the browser's `browse`/`makeDir`) |
|
|
18
|
+
| Client | `lib/client.js` | Session-header button + overlay panel; session-creation page entry (input-dock chip, upstream hero chip, or fixed fallback launcher — one at a time), all driving the host through the Remote BFF / shared RPC channel; the owned directory browser behind "Add directory" |
|
|
19
19
|
|
|
20
20
|
The package declares both faces: `dsh.bundle.patch` (the host row inserted by
|
|
21
21
|
`cordis.patch.yml`) and `dsh.client` (the web bundle at `exports["./client"]`).
|
|
@@ -120,7 +120,7 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
|
|
|
120
120
|
`ctx.inject(['typert'], (t) => t.typert.register(REMOTE_CONTRIBUTION))` —
|
|
121
121
|
the sanctioned manual path documented by `dsh-typert-loader` ("Manual
|
|
122
122
|
`ctx.typert.register()` remains available for contributions that do not use
|
|
123
|
-
a `./typert` artifact"). All
|
|
123
|
+
a `./typert` artifact"). All six descriptors use `src-json` codecs (no zod
|
|
124
124
|
schemas needed) with `invocation: { kind: 'direct' }`:
|
|
125
125
|
|
|
126
126
|
| Endpoint | Parameters (wire) | Result |
|
|
@@ -129,6 +129,11 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
|
|
|
129
129
|
| `multiFolder/add` | `workspace`, `path` | `{ workspace, dirs, changed }` |
|
|
130
130
|
| `multiFolder/remove` | `workspace`, `path` | `{ workspace, dirs, changed }` |
|
|
131
131
|
| `multiFolder/set` | `workspace`, `dirs` | `{ workspace, dirs, changed }` |
|
|
132
|
+
| `multiFolder/browse` | `path` | `{ path, parent, home, entries, truncated }` |
|
|
133
|
+
| `multiFolder/makeDir` | `parent`, `name` | `{ path, parent }` |
|
|
134
|
+
|
|
135
|
+
The four configuration endpoints are keyed by workspace; the last two serve
|
|
136
|
+
the plugin's own directory browser and are keyed by path instead.
|
|
132
137
|
|
|
133
138
|
The workspace argument is a **path**, not a session id; the client derives
|
|
134
139
|
it from the workspaces store (`WorkspaceView.path`). Business errors throw
|
|
@@ -160,6 +165,17 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
|
|
|
160
165
|
- the next boundary consumes it — `agent/pre-step` prepends it to the entering
|
|
161
166
|
message batch, or `tools/post-execute` attaches it as `additionalContexts` —
|
|
162
167
|
whichever fires first. No turn is ever interrupted.
|
|
168
|
+
- the notice's `source` declares a **producer-owned kind**
|
|
169
|
+
(`kind: 'plugin:dsh-multi-folder'`, `form: 'notice'`, `summary`). Session
|
|
170
|
+
format v4 admitted only the retired catch-all wrapper `kind: 'plugin'` for
|
|
171
|
+
nothing: `sessionFormatCatalog.encodeCurrentEvent` — which the JSONL writer
|
|
172
|
+
runs on every appended event — throws
|
|
173
|
+
`format v4 message requires a producer-owned source kind` for it, failing the
|
|
174
|
+
run the moment a notice is logged. `plugin:<plugin>` is the spelling the
|
|
175
|
+
format's v3→v4 migration itself produces for a non-first-party producer
|
|
176
|
+
(`session-format-v3-to-v4/src/sources.ts`), so it is also what this plugin's
|
|
177
|
+
older logs migrate to. The `workdir` diagnosis attached at a tool-call
|
|
178
|
+
boundary is built by the same function and fixed with it.
|
|
163
179
|
|
|
164
180
|
## Client
|
|
165
181
|
|
|
@@ -208,6 +224,25 @@ window.__ModuleLoader__.load({
|
|
|
208
224
|
'multiFolder/<op>', { args })` against the sessionless remote endpoints.
|
|
209
225
|
The panel runs in either mode according to how it was opened; mutations
|
|
210
226
|
and refreshes route per mode, and both modes share the same row/error UI.
|
|
227
|
+
- Owned directory browser ("Add directory"): the plugin draws the picking
|
|
228
|
+
interaction itself and serves it from `multiFolder/browse` +
|
|
229
|
+
`multiFolder/makeDir` over the shared RPC channel, instead of asking the host
|
|
230
|
+
for a picker. One interaction therefore covers every deployment, which no
|
|
231
|
+
host picker does: `uiWorkspace.pickDirectory()` is native-only (the host
|
|
232
|
+
answers `directory-picker/unavailable` when it composed the browse backend —
|
|
233
|
+
a LAN bind, a remote client, a desktop shell), its
|
|
234
|
+
`listDirectory`/`createDirectory` twins are refused under the native
|
|
235
|
+
composition, and the shipped in-app browser is reachable only by the shell's
|
|
236
|
+
own workspace surfaces (its `directoryFlow` holes are declared and driven by
|
|
237
|
+
ui-workspace, not by plugins). Listing rides the **`fs` seam**
|
|
238
|
+
(`fs.resolve` + `fs.listDir`), which every composition provides; only
|
|
239
|
+
directories are returned, hidden entries are flagged, the level is capped at
|
|
240
|
+
1000 with a `truncated` flag, and paths must be fully qualified. Creation
|
|
241
|
+
mirrors the shipped browse backend (`dsh-host-directory-picker-browse`) by
|
|
242
|
+
calling Node's `mkdir` on a validated single segment, because the `fs` seam
|
|
243
|
+
exposes no creation primitive. Neither endpoint touches the configuration
|
|
244
|
+
store: choosing a level still commits through the mode's own channel
|
|
245
|
+
(`/multi-folder add` in a session, `multiFolder/add` on the creation page).
|
|
211
246
|
- Session switch: a `React.useEffect` on `sessionId` re-points the open panel
|
|
212
247
|
to the current session (reusing the per-session cache) — this also folds a
|
|
213
248
|
workspace-mode panel back into session mode once the first message creates
|
|
@@ -346,10 +381,15 @@ window.__ModuleLoader__.load({
|
|
|
346
381
|
`test/smoke-host.mjs`, `test/intercept.mjs`, and `test/smoke-client.mjs` run without
|
|
347
382
|
the DSH runtime using mock services and a React shim. They cover interception,
|
|
348
383
|
canonicalization, the config guard, both notification channels, notice
|
|
349
|
-
gating, command flows, the panel's session-switch/caching behavior, the
|
|
384
|
+
gating and the producer-owned notice source kind v4 admits, command flows, the panel's session-switch/caching behavior, the
|
|
350
385
|
sessionless remote contribution shape and behavior (list/add/set/remove,
|
|
351
386
|
idempotence, sanitization, error prefixing, cross-channel cache coherence),
|
|
352
|
-
|
|
387
|
+
the owned browser's host half (`browse` filtering/sorting/hidden flags, the
|
|
388
|
+
fully-qualified-path fence, `makeDir` segment validation against a real
|
|
389
|
+
temporary directory, and that neither endpoint writes the config store),
|
|
390
|
+
and the hero/workspace-mode client flows — including the browser's own client
|
|
391
|
+
flow (open at the panel workspace, enter a level, commit through the mode's
|
|
392
|
+
channel, create a child and enter it, and return without a remote call). The client test's `slots.inject` mock
|
|
353
393
|
is declaration-aware like the real service (a wait fires only while its slot is
|
|
354
394
|
declared, and a collapse disposes the registration), so it covers all three
|
|
355
395
|
session-creation seats: the dock chip on an rc.6-style shell (registration
|
package/lib/client.js
CHANGED
|
@@ -27,6 +27,18 @@
|
|
|
27
27
|
* (`ctx.connection.rpc.call('/api', endpoint, { args })`), keyed by workspace
|
|
28
28
|
* path instead of sessionId.
|
|
29
29
|
*
|
|
30
|
+
* "Add directory" never asks the host for a picker: the plugin draws its own
|
|
31
|
+
* browser (an editable path field, one level of child directories, an optional
|
|
32
|
+
* "new folder") on the `multiFolder/browse` + `multiFolder/makeDir` endpoints
|
|
33
|
+
* over the shared RPC channel. One interaction serves every deployment, which
|
|
34
|
+
* the host's own picker cannot: `uiWorkspace.pickDirectory()` is native-only
|
|
35
|
+
* (it answers `directory-picker/unavailable` whenever the host composed the
|
|
36
|
+
* browse backend — a LAN bind, a remote client, a desktop shell), while its
|
|
37
|
+
* `listDirectory`/`createDirectory` twins are refused under the native
|
|
38
|
+
* composition. Listing this way also stays clear of the DSH Windows native
|
|
39
|
+
* chooser worker, whose crash surfaces as `directory picker failed: … worker
|
|
40
|
+
* exited …`.
|
|
41
|
+
*
|
|
30
42
|
* Layout shape: an entry in the session header action row, one chip row above
|
|
31
43
|
* the composer card on the session-creation page, and a frame-wide overlay
|
|
32
44
|
* panel. Every surface reads one tiny module-scoped store; opening the panel
|
|
@@ -74,6 +86,16 @@ window.__ModuleLoader__.load({
|
|
|
74
86
|
heroClaims: [],
|
|
75
87
|
/** Which surface owns the open panel: 'overlay' | 'dock' | 'extras'. */
|
|
76
88
|
anchor: 'overlay',
|
|
89
|
+
/** The owned directory browser (the "Add directory" interaction). */
|
|
90
|
+
browseOpen: false,
|
|
91
|
+
/** The level on display: { path, parent, home, entries, truncated } | null. */
|
|
92
|
+
browse: null,
|
|
93
|
+
browseBusy: false,
|
|
94
|
+
browseError: null,
|
|
95
|
+
/** Editable path field, seeded from the level on display. */
|
|
96
|
+
browseDraft: '',
|
|
97
|
+
/** "new folder" name draft. */
|
|
98
|
+
newDirName: '',
|
|
77
99
|
/** Bumped on every workspaceCache write so chips re-read their count. */
|
|
78
100
|
cacheRev: 0,
|
|
79
101
|
};
|
|
@@ -186,6 +208,19 @@ window.__ModuleLoader__.load({
|
|
|
186
208
|
'panel.remove': '移除',
|
|
187
209
|
'panel.refresh': '刷新',
|
|
188
210
|
'panel.footnote': 'Agent 的主工作目录不变;在 Workspace Write 模式下,Agent 对上述目录拥有与主工作目录同等的读写与命令执行权限。配置变更会在下一条消息或工具调用结束时通知 Agent。',
|
|
211
|
+
'browse.title': '选择目录',
|
|
212
|
+
'browse.pathLabel': '目录路径',
|
|
213
|
+
'browse.go': '前往',
|
|
214
|
+
'browse.up': '↑ 上级',
|
|
215
|
+
'browse.use': '选择此目录',
|
|
216
|
+
'browse.back': '← 返回',
|
|
217
|
+
'browse.loading': '读取中…',
|
|
218
|
+
'browse.empty': '此目录下没有子目录。',
|
|
219
|
+
'browse.truncated': '仅显示前 {count} 个子目录。',
|
|
220
|
+
'browse.enter': '进入此目录',
|
|
221
|
+
'browse.newDirPlaceholder': '新建文件夹名称',
|
|
222
|
+
'browse.newDirCreate': '创建',
|
|
223
|
+
'browse.hint': '浏览宿主机文件系统;选中的目录将作为副工作目录添加。',
|
|
189
224
|
};
|
|
190
225
|
/** English dictionary — checked complete against the zh key set. */
|
|
191
226
|
var enDict = {
|
|
@@ -210,17 +245,29 @@ window.__ModuleLoader__.load({
|
|
|
210
245
|
'panel.remove': 'Remove',
|
|
211
246
|
'panel.refresh': 'Refresh',
|
|
212
247
|
'panel.footnote': "The agent's primary working directory stays unchanged; under Workspace Write mode the agent has the same read/write and command-execution rights on the listed directories as on the primary workspace. Configuration changes are announced at the next message or tool-call boundary.",
|
|
248
|
+
'browse.title': 'Choose a directory',
|
|
249
|
+
'browse.pathLabel': 'Directory path',
|
|
250
|
+
'browse.go': 'Go',
|
|
251
|
+
'browse.up': '↑ Up',
|
|
252
|
+
'browse.use': 'Use this directory',
|
|
253
|
+
'browse.back': '← Back',
|
|
254
|
+
'browse.loading': 'Loading…',
|
|
255
|
+
'browse.empty': 'No subdirectories here.',
|
|
256
|
+
'browse.truncated': 'Showing the first {count} subdirectories.',
|
|
257
|
+
'browse.enter': 'Enter this directory',
|
|
258
|
+
'browse.newDirPlaceholder': 'New folder name',
|
|
259
|
+
'browse.newDirCreate': 'Create',
|
|
260
|
+
'browse.hint': 'Browses the host filesystem; the chosen directory is added as a secondary working directory.',
|
|
213
261
|
};
|
|
214
262
|
|
|
215
263
|
// ------------------------------------------------------------- plugin
|
|
216
264
|
var name = 'dsh-multi-folder';
|
|
217
|
-
var inject = ['remote', 'remote.commands', 'slots', 'workspaces', '
|
|
265
|
+
var inject = ['remote', 'remote.commands', 'slots', 'workspaces', 'connection', 'sessions', 'locale'];
|
|
218
266
|
|
|
219
267
|
function apply(ctx) {
|
|
220
268
|
var slots = ctx.slots;
|
|
221
269
|
var remote = ctx.remote;
|
|
222
270
|
var workspaces = ctx.workspaces;
|
|
223
|
-
var uiWorkspace = ctx.uiWorkspace;
|
|
224
271
|
var connection = ctx.connection;
|
|
225
272
|
var sessions = ctx.sessions;
|
|
226
273
|
var locale = ctx.locale;
|
|
@@ -324,6 +371,7 @@ window.__ModuleLoader__.load({
|
|
|
324
371
|
* renders it as its own popover, everything else uses the overlay. */
|
|
325
372
|
function openForWorkspace(workspacePath, anchor) {
|
|
326
373
|
var seat = anchor || 'overlay';
|
|
374
|
+
patch(browserReset());
|
|
327
375
|
if (!workspacePath) {
|
|
328
376
|
patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null, anchor: seat });
|
|
329
377
|
return;
|
|
@@ -398,28 +446,94 @@ window.__ModuleLoader__.load({
|
|
|
398
446
|
});
|
|
399
447
|
}
|
|
400
448
|
|
|
449
|
+
// -------------------------------------------- owned directory browser
|
|
450
|
+
// "Add directory" is served entirely by this plugin (host item 8): the
|
|
451
|
+
// browser reads levels over `multiFolder/browse` and may create one child
|
|
452
|
+
// through `multiFolder/makeDir`, so the interaction never depends on the
|
|
453
|
+
// host's composed directory picker — the native chooser, the browse
|
|
454
|
+
// backend and a shell with no picker at all all behave the same here.
|
|
455
|
+
|
|
456
|
+
/** Read one level into the store. A blank path asks the host for its
|
|
457
|
+
* home directory, so the browser always has somewhere to start. */
|
|
458
|
+
function browseTo(path) {
|
|
459
|
+
patch({ browseBusy: true, browseError: null });
|
|
460
|
+
return remoteCall('multiFolder/browse', { path: typeof path === 'string' ? path : '' }).then(function (value) {
|
|
461
|
+
var level = value && typeof value === 'object'
|
|
462
|
+
? value
|
|
463
|
+
: { path: '', parent: null, home: '', entries: [], truncated: false };
|
|
464
|
+
patch({
|
|
465
|
+
browseBusy: false,
|
|
466
|
+
browse: level,
|
|
467
|
+
browseDraft: level.path,
|
|
468
|
+
browseError: null,
|
|
469
|
+
newDirName: '',
|
|
470
|
+
});
|
|
471
|
+
return level;
|
|
472
|
+
}).catch(function (e) {
|
|
473
|
+
patch({ browseBusy: false, browseError: String(e && e.message ? e.message : e) });
|
|
474
|
+
return null;
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Open the browser at the panel's workspace (or the host home when the
|
|
479
|
+
* panel has no workspace path yet) and read its first level. */
|
|
401
480
|
function addDirectory() {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
:
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
481
|
+
var snapshot = getSnapshot();
|
|
482
|
+
patch({
|
|
483
|
+
browseOpen: true,
|
|
484
|
+
browse: null,
|
|
485
|
+
browseBusy: false,
|
|
486
|
+
browseError: null,
|
|
487
|
+
browseDraft: snapshot.workspace || '',
|
|
488
|
+
newDirName: '',
|
|
489
|
+
});
|
|
490
|
+
browseTo(snapshot.workspace || '');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Leave the browser without adding anything. */
|
|
494
|
+
function closeBrowser() {
|
|
495
|
+
patch(browserReset());
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The store fields that put the panel back on its directory list. Any
|
|
499
|
+
* fresh open of the panel (header button, chip, launcher) starts there,
|
|
500
|
+
* so a browser left open by a dismissed panel never comes back stale. */
|
|
501
|
+
function browserReset() {
|
|
502
|
+
return { browseOpen: false, browse: null, browseBusy: false, browseError: null, newDirName: '' };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Adopt one path as a secondary working directory through the channel
|
|
506
|
+
* the panel already uses: the `/multi-folder add` command inside a
|
|
507
|
+
* session, the sessionless `multiFolder/add` endpoint on the
|
|
508
|
+
* session-creation page. The browser stays open when the Host refused,
|
|
509
|
+
* so the error sits beside the path the user was looking at. */
|
|
510
|
+
function commitBrowsePath(path) {
|
|
511
|
+
var snapshot = getSnapshot();
|
|
512
|
+
var settled;
|
|
513
|
+
if (snapshot.sessionId) {
|
|
514
|
+
settled = mutate(snapshot.sessionId, '/multi-folder add "' + String(path).replace(/"/g, '\\"') + '"');
|
|
515
|
+
} else if (snapshot.workspace) {
|
|
516
|
+
settled = mutateWorkspace(snapshot.workspace, 'multiFolder/add', { path: path });
|
|
517
|
+
} else {
|
|
518
|
+
return Promise.resolve();
|
|
412
519
|
}
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
520
|
+
return Promise.resolve(settled).then(function () {
|
|
521
|
+
if (!getSnapshot().error) closeBrowser();
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Create one child directory and enter it. */
|
|
526
|
+
function createBrowseDir() {
|
|
527
|
+
var snapshot = getSnapshot();
|
|
528
|
+
var name = String(snapshot.newDirName || '').trim();
|
|
529
|
+
if (!snapshot.browse || name === '') return Promise.resolve();
|
|
530
|
+
patch({ browseBusy: true, browseError: null });
|
|
531
|
+
return remoteCall('multiFolder/makeDir', { parent: snapshot.browse.path, name: name }).then(function (created) {
|
|
532
|
+
patch({ browseBusy: false, newDirName: '' });
|
|
533
|
+
return browseTo(created && created.path ? created.path : snapshot.browse.path);
|
|
421
534
|
}).catch(function (e) {
|
|
422
|
-
patch({
|
|
535
|
+
patch({ browseBusy: false, browseError: String(e && e.message ? e.message : e) });
|
|
536
|
+
return null;
|
|
423
537
|
});
|
|
424
538
|
}
|
|
425
539
|
|
|
@@ -428,6 +542,7 @@ window.__ModuleLoader__.load({
|
|
|
428
542
|
function openFor(sessionId) {
|
|
429
543
|
if (!sessionId) return;
|
|
430
544
|
var cached = sessionCache[sessionId];
|
|
545
|
+
patch(browserReset());
|
|
431
546
|
patch({
|
|
432
547
|
open: true,
|
|
433
548
|
mode: 'session',
|
|
@@ -491,6 +606,191 @@ window.__ModuleLoader__.load({
|
|
|
491
606
|
return btn;
|
|
492
607
|
}
|
|
493
608
|
|
|
609
|
+
/** The owned browser, rendered in place of the directory list: an
|
|
610
|
+
* editable path field, up/use/back actions, one level of child
|
|
611
|
+
* directories, and the optional "new folder" row. Returned as an ARRAY
|
|
612
|
+
* for the same reason `panelBody` is. */
|
|
613
|
+
function browseBody(store, t) {
|
|
614
|
+
var level = store.browse;
|
|
615
|
+
var busy = !!store.busy || !!store.browseBusy;
|
|
616
|
+
var entries = level && Array.isArray(level.entries) ? level.entries : null;
|
|
617
|
+
var inputStyle = {
|
|
618
|
+
flex: 1,
|
|
619
|
+
minWidth: 0,
|
|
620
|
+
padding: '4px 8px',
|
|
621
|
+
borderRadius: 6,
|
|
622
|
+
border: '1px solid ' + TOKEN.border,
|
|
623
|
+
background: 'transparent',
|
|
624
|
+
color: TOKEN.ink,
|
|
625
|
+
fontSize: 12,
|
|
626
|
+
fontFamily: 'inherit',
|
|
627
|
+
};
|
|
628
|
+
var smallButton = function (extra) {
|
|
629
|
+
return Object.assign({
|
|
630
|
+
padding: '4px 10px',
|
|
631
|
+
borderRadius: 6,
|
|
632
|
+
border: '1px solid ' + TOKEN.border,
|
|
633
|
+
background: 'transparent',
|
|
634
|
+
color: TOKEN.ink,
|
|
635
|
+
fontSize: 12,
|
|
636
|
+
fontFamily: 'inherit',
|
|
637
|
+
}, extra || {});
|
|
638
|
+
};
|
|
639
|
+
var rows = entries === null ? null : entries.map(function (entry, index) {
|
|
640
|
+
return React.createElement(
|
|
641
|
+
'button',
|
|
642
|
+
{
|
|
643
|
+
key: index,
|
|
644
|
+
type: 'button',
|
|
645
|
+
title: t('browse.enter'),
|
|
646
|
+
disabled: busy,
|
|
647
|
+
onClick: function () { browseTo(entry.path); },
|
|
648
|
+
style: {
|
|
649
|
+
display: 'block',
|
|
650
|
+
width: '100%',
|
|
651
|
+
textAlign: 'left',
|
|
652
|
+
padding: '5px 8px',
|
|
653
|
+
marginBottom: 4,
|
|
654
|
+
borderRadius: 6,
|
|
655
|
+
border: '1px solid transparent',
|
|
656
|
+
background: TOKEN.subtle,
|
|
657
|
+
color: entry.hidden ? TOKEN.inkMuted : TOKEN.ink,
|
|
658
|
+
cursor: busy ? 'default' : 'pointer',
|
|
659
|
+
fontFamily: 'inherit',
|
|
660
|
+
fontSize: 12,
|
|
661
|
+
overflow: 'hidden',
|
|
662
|
+
textOverflow: 'ellipsis',
|
|
663
|
+
whiteSpace: 'nowrap',
|
|
664
|
+
opacity: busy ? 0.6 : 1,
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
'📁 ' + entry.name,
|
|
668
|
+
);
|
|
669
|
+
});
|
|
670
|
+
return [
|
|
671
|
+
React.createElement(
|
|
672
|
+
'div',
|
|
673
|
+
{ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
|
|
674
|
+
React.createElement('div', { style: { fontWeight: 600, fontSize: 14, color: TOKEN.ink } }, t('browse.title')),
|
|
675
|
+
React.createElement(
|
|
676
|
+
'button',
|
|
677
|
+
{
|
|
678
|
+
type: 'button',
|
|
679
|
+
title: t('title.close'),
|
|
680
|
+
onClick: function () { closeBrowser(); patch({ open: false }); },
|
|
681
|
+
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
|
|
682
|
+
},
|
|
683
|
+
'✕',
|
|
684
|
+
),
|
|
685
|
+
),
|
|
686
|
+
React.createElement(
|
|
687
|
+
'div',
|
|
688
|
+
{ style: { display: 'flex', gap: 6, marginBottom: 6 } },
|
|
689
|
+
React.createElement('input', {
|
|
690
|
+
type: 'text',
|
|
691
|
+
value: store.browseDraft,
|
|
692
|
+
placeholder: t('browse.pathLabel'),
|
|
693
|
+
spellCheck: false,
|
|
694
|
+
onChange: function (event) { patch({ browseDraft: event.target.value }); },
|
|
695
|
+
onKeyDown: function (event) { if (event.key === 'Enter') browseTo(store.browseDraft); },
|
|
696
|
+
style: inputStyle,
|
|
697
|
+
}),
|
|
698
|
+
React.createElement(
|
|
699
|
+
'button',
|
|
700
|
+
{
|
|
701
|
+
type: 'button',
|
|
702
|
+
disabled: busy,
|
|
703
|
+
onClick: function () { browseTo(store.browseDraft); },
|
|
704
|
+
style: smallButton({ background: TOKEN.fill, cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1 }),
|
|
705
|
+
},
|
|
706
|
+
t('browse.go'),
|
|
707
|
+
),
|
|
708
|
+
),
|
|
709
|
+
React.createElement(
|
|
710
|
+
'div',
|
|
711
|
+
{ style: { display: 'flex', gap: 6, marginBottom: 8 } },
|
|
712
|
+
React.createElement(
|
|
713
|
+
'button',
|
|
714
|
+
{
|
|
715
|
+
type: 'button',
|
|
716
|
+
disabled: busy || !level || !level.parent,
|
|
717
|
+
onClick: function () { if (level && level.parent) browseTo(level.parent); },
|
|
718
|
+
style: smallButton({ cursor: busy || !level || !level.parent ? 'default' : 'pointer', opacity: busy || !level || !level.parent ? 0.5 : 1 }),
|
|
719
|
+
},
|
|
720
|
+
t('browse.up'),
|
|
721
|
+
),
|
|
722
|
+
React.createElement(
|
|
723
|
+
'button',
|
|
724
|
+
{
|
|
725
|
+
type: 'button',
|
|
726
|
+
disabled: busy || !level,
|
|
727
|
+
onClick: function () { if (level) commitBrowsePath(level.path); },
|
|
728
|
+
style: smallButton({ flex: 1, border: '1px solid ' + TOKEN.accent, background: TOKEN.fill, cursor: busy || !level ? 'default' : 'pointer', opacity: busy || !level ? 0.6 : 1 }),
|
|
729
|
+
},
|
|
730
|
+
t('browse.use'),
|
|
731
|
+
),
|
|
732
|
+
React.createElement(
|
|
733
|
+
'button',
|
|
734
|
+
{
|
|
735
|
+
type: 'button',
|
|
736
|
+
onClick: function () { closeBrowser(); },
|
|
737
|
+
style: smallButton({ border: '1px solid transparent', color: TOKEN.inkMuted, cursor: 'pointer' }),
|
|
738
|
+
},
|
|
739
|
+
t('browse.back'),
|
|
740
|
+
),
|
|
741
|
+
),
|
|
742
|
+
store.browseError
|
|
743
|
+
? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.danger, whiteSpace: 'pre-wrap', fontSize: 12 } }, String(store.browseError))
|
|
744
|
+
: null,
|
|
745
|
+
React.createElement(
|
|
746
|
+
'div',
|
|
747
|
+
{ style: { maxHeight: 200, overflowY: 'auto', marginBottom: 6 } },
|
|
748
|
+
rows === null
|
|
749
|
+
? (store.browseError
|
|
750
|
+
? null
|
|
751
|
+
: React.createElement('div', { style: { color: TOKEN.inkMuted, fontSize: 12 } }, t('browse.loading')))
|
|
752
|
+
: (rows.length > 0
|
|
753
|
+
? rows
|
|
754
|
+
: React.createElement('div', { style: { color: TOKEN.inkMuted, fontSize: 12 } }, t('browse.empty'))),
|
|
755
|
+
),
|
|
756
|
+
level && level.truncated
|
|
757
|
+
? React.createElement(
|
|
758
|
+
'div',
|
|
759
|
+
{ style: { marginBottom: 6, color: TOKEN.inkMuted, fontSize: 11 } },
|
|
760
|
+
t('browse.truncated', { count: entries ? entries.length : 0 }),
|
|
761
|
+
)
|
|
762
|
+
: null,
|
|
763
|
+
React.createElement(
|
|
764
|
+
'div',
|
|
765
|
+
{ style: { display: 'flex', gap: 6, marginBottom: 8 } },
|
|
766
|
+
React.createElement('input', {
|
|
767
|
+
type: 'text',
|
|
768
|
+
value: store.newDirName,
|
|
769
|
+
placeholder: t('browse.newDirPlaceholder'),
|
|
770
|
+
spellCheck: false,
|
|
771
|
+
onChange: function (event) { patch({ newDirName: event.target.value }); },
|
|
772
|
+
onKeyDown: function (event) { if (event.key === 'Enter') createBrowseDir(); },
|
|
773
|
+
style: inputStyle,
|
|
774
|
+
}),
|
|
775
|
+
React.createElement(
|
|
776
|
+
'button',
|
|
777
|
+
{
|
|
778
|
+
type: 'button',
|
|
779
|
+
disabled: busy || String(store.newDirName || '').trim() === '',
|
|
780
|
+
onClick: function () { createBrowseDir(); },
|
|
781
|
+
style: smallButton({ background: TOKEN.fill, cursor: busy ? 'default' : 'pointer', opacity: busy || String(store.newDirName || '').trim() === '' ? 0.5 : 1 }),
|
|
782
|
+
},
|
|
783
|
+
t('browse.newDirCreate'),
|
|
784
|
+
),
|
|
785
|
+
),
|
|
786
|
+
React.createElement(
|
|
787
|
+
'div',
|
|
788
|
+
{ style: { fontSize: 11, lineHeight: 1.5, color: TOKEN.inkMuted } },
|
|
789
|
+
t('browse.hint'),
|
|
790
|
+
),
|
|
791
|
+
];
|
|
792
|
+
}
|
|
793
|
+
|
|
494
794
|
// Panel body ---------------------------------------------------------
|
|
495
795
|
/** The panel's children, shared verbatim by the overlay panel and the
|
|
496
796
|
* chip popover. Returned as an ARRAY so each wrapper can spread it as
|
|
@@ -498,6 +798,9 @@ window.__ModuleLoader__.load({
|
|
|
498
798
|
function panelBody(store, t) {
|
|
499
799
|
var sessionMode = store.mode === 'session';
|
|
500
800
|
var usable = sessionMode || !!store.workspace;
|
|
801
|
+
// The owned browser replaces the directory list while it is open; the
|
|
802
|
+
// panel header and its placement stay the same.
|
|
803
|
+
if (store.browseOpen) return browseBody(store, t);
|
|
501
804
|
var rows = (store.dirs || []).map(function (dir, index) {
|
|
502
805
|
return React.createElement(
|
|
503
806
|
'div',
|
package/lib/index.js
CHANGED
|
@@ -56,16 +56,32 @@
|
|
|
56
56
|
* — where no session exists yet — can read and edit the configuration
|
|
57
57
|
* directly. The `/multi-folder` command and the remote methods share
|
|
58
58
|
* one core so validation, canonicalization, and the config guard are
|
|
59
|
-
* identical on both channels.
|
|
59
|
+
* identical on both channels. `browse` and `makeDir` ride the same
|
|
60
|
+
* namespace: they serve the plugin's own directory browser (see 8) rather
|
|
61
|
+
* than the configuration store.
|
|
60
62
|
* 7. Failure diagnosis: a `pwsh`/`bash` run that ends in an OS-level
|
|
61
63
|
* `Permission denied` touching a secondary working directory (the ACL
|
|
62
64
|
* runner confines each process tree to ONE writable root, so `git -C
|
|
63
65
|
* <secondary>` launched from the primary workspace cannot write the
|
|
64
66
|
* repo) gets a workdir-fix hint attached as an additional context at
|
|
65
67
|
* the `tools/post-execute` boundary.
|
|
68
|
+
* 8. Owned directory browser: "Add directory" opens a browser drawn by the
|
|
69
|
+
* client half and served by `browse`/`makeDir` above. Listing rides the
|
|
70
|
+
* `fs` seam (`fs.listDir`), which — unlike the host's directory-picker
|
|
71
|
+
* seam — is composed in EVERY deployment, so one interaction covers the
|
|
72
|
+
* native-chooser composition, the browse composition (LAN / remote
|
|
73
|
+
* clients / desktop shells) and shells that compose no picker at all.
|
|
74
|
+
* The framework offers no plugin-facing alternative: `uiWorkspace`'s
|
|
75
|
+
* `pickDirectory()` is native-only (it answers `directory-picker/unavailable`
|
|
76
|
+
* under the browse composition), its `listDirectory`/`createDirectory`
|
|
77
|
+
* twins are browse-only, and the shipped in-app browser is reachable only
|
|
78
|
+
* by the shell's own workspace surfaces. Directory creation mirrors the
|
|
79
|
+
* shipped browse backend, which uses Node's `mkdir` (the fs seam has no
|
|
80
|
+
* creation primitive).
|
|
66
81
|
*/
|
|
67
82
|
|
|
68
|
-
import {
|
|
83
|
+
import { mkdir } from 'node:fs/promises'
|
|
84
|
+
import { join, posix, win32 } from 'node:path'
|
|
69
85
|
import os from 'node:os'
|
|
70
86
|
|
|
71
87
|
export const name = 'dsh-multi-folder'
|
|
@@ -248,6 +264,97 @@ export function apply(ctx) {
|
|
|
248
264
|
return { workspace: ws, dirs: [...next], changed }
|
|
249
265
|
}
|
|
250
266
|
|
|
267
|
+
// ------------------------------------------------- owned directory picker
|
|
268
|
+
// The plugin owns its picking interaction (see the file header, item 8):
|
|
269
|
+
// one in-app browser over these two endpoints, so "Add directory" behaves
|
|
270
|
+
// identically under every host picker composition instead of depending on
|
|
271
|
+
// the native-only `uiWorkspace.pickDirectory()` service.
|
|
272
|
+
|
|
273
|
+
/** Complete-result bound of one listing level (mirrors the shipped browse backend). */
|
|
274
|
+
const MAX_BROWSE_ENTRIES = 1000
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether a path names one fixed filesystem location regardless of process
|
|
278
|
+
* state: POSIX-absolute on POSIX; on Windows only drive-qualified (`C:\…`)
|
|
279
|
+
* or complete UNC (`\\server\share…`) forms. Rooted drive-less forms
|
|
280
|
+
* (`\foo`, `/foo`) are `isAbsolute` yet still resolve against the host
|
|
281
|
+
* process's current drive, so they are refused rather than rebased.
|
|
282
|
+
*/
|
|
283
|
+
const fullyQualified = (path) =>
|
|
284
|
+
process.platform === 'win32'
|
|
285
|
+
? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
|
|
286
|
+
: posix.isAbsolute(path)
|
|
287
|
+
|
|
288
|
+
/** The path flavour one fully qualified path belongs to (flavour, never the host platform). */
|
|
289
|
+
const pathApiFor = (path) => (/^[A-Za-z]:[\\/]|^\\\\/.test(path) ? win32 : posix)
|
|
290
|
+
|
|
291
|
+
/** The parent level of one absolute path, or null at a filesystem root. */
|
|
292
|
+
const parentOf = (path) => {
|
|
293
|
+
const parent = pathApiFor(path).dirname(path)
|
|
294
|
+
return parent === path ? null : parent
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* List the direct subdirectories of one level for the plugin's browser.
|
|
299
|
+
* An absent/blank path lists the host user's home directory. Only
|
|
300
|
+
* directories are returned (a file is not addable as a secondary working
|
|
301
|
+
* directory), hidden entries are flagged for the client to style, and the
|
|
302
|
+
* level is capped with a `truncated` flag.
|
|
303
|
+
* @param path - fully qualified directory path, or nothing for the home directory.
|
|
304
|
+
* @returns the level's canonical path, its parent, the home directory, the child directories, and the cap state.
|
|
305
|
+
*/
|
|
306
|
+
const coreBrowse = async (path) => {
|
|
307
|
+
const requested = typeof path === 'string' && path.trim().length > 0 ? path.trim() : os.homedir()
|
|
308
|
+
if (!fullyQualified(requested)) {
|
|
309
|
+
throw new Error('browse requires a fully qualified path, got "' + requested + '"')
|
|
310
|
+
}
|
|
311
|
+
const target = await fs.resolve(requested)
|
|
312
|
+
const absolute = fs.processPath(target)
|
|
313
|
+
let children
|
|
314
|
+
try {
|
|
315
|
+
children = await fs.listDir(target)
|
|
316
|
+
} catch (e) {
|
|
317
|
+
throw new Error('cannot list "' + absolute + '": ' + String(e && e.message ? e.message : e))
|
|
318
|
+
}
|
|
319
|
+
const api = pathApiFor(absolute)
|
|
320
|
+
const entries = []
|
|
321
|
+
for (const child of children ?? []) {
|
|
322
|
+
if (child === null || child === undefined || child.type !== 'directory') continue
|
|
323
|
+
const entryName = String(child.name)
|
|
324
|
+
entries.push({ name: entryName, path: api.join(absolute, entryName), hidden: entryName.startsWith('.') })
|
|
325
|
+
}
|
|
326
|
+
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
327
|
+
const truncated = entries.length > MAX_BROWSE_ENTRIES
|
|
328
|
+
if (truncated) entries.length = MAX_BROWSE_ENTRIES
|
|
329
|
+
return { path: absolute, parent: parentOf(absolute), home: os.homedir(), entries, truncated }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Create one child directory for the plugin's browser. Mirrors the shipped
|
|
334
|
+
* browse backend: the parent must be fully qualified, the name one path
|
|
335
|
+
* segment, and the creation non-recursive (the browser shows the parent, so
|
|
336
|
+
* a missing level is a real failure, not a level to invent).
|
|
337
|
+
* @param parent - fully qualified existing parent directory.
|
|
338
|
+
* @param name - single path segment name.
|
|
339
|
+
* @returns the created directory's canonical path.
|
|
340
|
+
*/
|
|
341
|
+
const coreMakeDir = async (parent, name) => {
|
|
342
|
+
if (typeof parent !== 'string' || !fullyQualified(parent)) {
|
|
343
|
+
throw new Error('makeDir requires a fully qualified parent path')
|
|
344
|
+
}
|
|
345
|
+
if (typeof name !== 'string' || name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
|
|
346
|
+
throw new Error('makeDir requires a single path segment name')
|
|
347
|
+
}
|
|
348
|
+
const target = pathApiFor(parent).join(parent, name)
|
|
349
|
+
try {
|
|
350
|
+
await mkdir(target)
|
|
351
|
+
} catch (e) {
|
|
352
|
+
if (e && e.code === 'EEXIST') throw new Error('"' + target + '" already exists')
|
|
353
|
+
throw new Error('cannot create "' + target + '": ' + String(e && e.message ? e.message : e))
|
|
354
|
+
}
|
|
355
|
+
return { path: target, parent }
|
|
356
|
+
}
|
|
357
|
+
|
|
251
358
|
// ----------------------------------------------- sessionless remote API
|
|
252
359
|
// `multiFolder/*` endpoints over the Typert gateway. Hand-written
|
|
253
360
|
// `src-json` descriptors registered through ctx.typert.register (the
|
|
@@ -287,6 +394,20 @@ export function apply(ctx) {
|
|
|
287
394
|
throw new Error(remoteErrorMessage(e))
|
|
288
395
|
}
|
|
289
396
|
},
|
|
397
|
+
async browse(path) {
|
|
398
|
+
try {
|
|
399
|
+
return await coreBrowse(path)
|
|
400
|
+
} catch (e) {
|
|
401
|
+
throw new Error(remoteErrorMessage(e))
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
async makeDir(parent, name) {
|
|
405
|
+
try {
|
|
406
|
+
return await coreMakeDir(parent, name)
|
|
407
|
+
} catch (e) {
|
|
408
|
+
throw new Error(remoteErrorMessage(e))
|
|
409
|
+
}
|
|
410
|
+
},
|
|
290
411
|
}
|
|
291
412
|
Object.defineProperty(multiFolderApi, 'typertRemote', {
|
|
292
413
|
value: Object.freeze({
|
|
@@ -316,17 +437,29 @@ export function apply(ctx) {
|
|
|
316
437
|
remoteInvocation('add', ['workspace', 'path']),
|
|
317
438
|
remoteInvocation('remove', ['workspace', 'path']),
|
|
318
439
|
remoteInvocation('set', ['workspace', 'dirs']),
|
|
440
|
+
remoteInvocation('browse', ['path']),
|
|
441
|
+
remoteInvocation('makeDir', ['parent', 'name']),
|
|
319
442
|
],
|
|
320
443
|
}
|
|
321
444
|
|
|
322
445
|
// -------------------------------------------------------- notice channel
|
|
446
|
+
// A message's `source.kind` must be a PRODUCER-OWNED kind. Session format v4
|
|
447
|
+
// retired the catch-all `{ kind: 'plugin', plugin: X }` wrapper: the durable
|
|
448
|
+
// log's encoder refuses it with
|
|
449
|
+
// `format v4 message requires a producer-owned source kind`, which surfaces
|
|
450
|
+
// as a failed run the moment a notice is appended. First-party producers name
|
|
451
|
+
// themselves (`plan-mode`, `tool-jobs`, `model-selection`); the format's
|
|
452
|
+
// v3→v4 migration maps every other plugin's legacy wrapper to
|
|
453
|
+
// `plugin:<plugin>` (`session-format-v3-to-v4/sources.ts`), so an external
|
|
454
|
+
// plugin's canonical spelling is that same namespaced kind — and our own
|
|
455
|
+
// earlier logs, which used the retired wrapper, migrate to exactly it.
|
|
456
|
+
const NOTICE_SOURCE_KIND = 'plugin:' + name
|
|
323
457
|
const noticeMessage = (text) => ({
|
|
324
458
|
id: 'mf-note-' + (++noteSeq),
|
|
325
459
|
role: 'user',
|
|
326
460
|
content: [{ type: 'text', text }],
|
|
327
461
|
source: {
|
|
328
|
-
kind:
|
|
329
|
-
plugin: name,
|
|
462
|
+
kind: NOTICE_SOURCE_KIND,
|
|
330
463
|
form: 'notice',
|
|
331
464
|
summary: String(text).split('\n')[0].slice(0, 120),
|
|
332
465
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multi-folder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "DeepSeek Harness plugin: secondary working directories for a project. The agent keeps the primary workspace as cwd, gains equal write/exec permissions on configured secondary directories under workspace-write mode, and is notified of configuration changes at the next message boundary. Configurable from the session header AND from the session-creation page (before the first message) through a sessionless multiFolder remote API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
"compatibility": {
|
|
43
43
|
"node": ">=20",
|
|
44
44
|
"dshReleases": {
|
|
45
|
+
"0.2.0-rc.1": "compatible",
|
|
46
|
+
"0.1.7-rc.2": "compatible",
|
|
45
47
|
"0.1.6-alpha.1": "compatible",
|
|
46
48
|
"0.1.2-alpha.5": "compatible",
|
|
47
49
|
"0.1.2-alpha.4": "compatible",
|