dsh-multi-folder 0.3.2 → 0.3.3
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 +7 -5
- package/README.zh.md +7 -5
- package/SECURITY.md +21 -19
- package/docs/design.md +23 -20
- package/lib/client.js +131 -32
- package/lib/index.js +285 -14
- package/lib/native-picker.ps1 +290 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -50,7 +50,8 @@ A Multi-folder button appears in the session header, and a second entry appears
|
|
|
50
50
|
|
|
51
51
|
| Action | Behavior |
|
|
52
52
|
| ------ | -------- |
|
|
53
|
-
| Add directory |
|
|
53
|
+
| Add directory | Uses the host's native picker, then its OS dialog on failure; a `browse` capability opens the client browser directly (path field, one level of child directories, optional new folder) |
|
|
54
|
+
| Open in file manager | Opens the host's file manager (Explorer / Finder) at a configured directory |
|
|
54
55
|
| Remove / refresh | Applies immediately |
|
|
55
56
|
| Switch session | The panel auto-switches to that session's directories |
|
|
56
57
|
| Reopen panel | Uses the per-session cache — no redundant command rows |
|
|
@@ -98,8 +99,8 @@ When a shell run ends in such a denial and references a configured secondary dir
|
|
|
98
99
|
- **Prompt injection** — one ordered `systemPrompt` section with a text provider evaluated per assembly, rendering only for sessions whose workspace has configured directories.
|
|
99
100
|
- **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.
|
|
100
101
|
- **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).
|
|
101
|
-
- **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`
|
|
102
|
-
- **
|
|
102
|
+
- **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` serve the plugin's directory browser; `pick`/`reveal` select or open a host directory.
|
|
103
|
+
- **Directory picking** — `multiFolder/pick` uses the host's `native` picker, then the OS dialog if it fails. A `browse` capability opens the client browser directly. Closing the panel cancels the request; cancelling a dialog adds nothing. The Windows helper is `lib/native-picker.ps1`.
|
|
103
104
|
- **`@` discovery** — a second reader of that same `fs` seam: `multiFolder/listFiles` indexes the configured directories (breadth-first, canonical-path deduplicated so a junction cannot re-enter the walk, generated/vendor basenames excluded, capped and cached per workspace with a short TTL) and the client half registers a companion `@` source over it. The shipped single-root provider is not modified, and the shipped files/sessions group is not disturbed: the trigger registry keys sources by `(trigger, name)` and renders one group each.
|
|
104
105
|
- **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.
|
|
105
106
|
|
|
@@ -108,8 +109,9 @@ When a shell run ends in such a denial and references a configured secondary dir
|
|
|
108
109
|
| Path | Purpose |
|
|
109
110
|
| ---- | ------- |
|
|
110
111
|
| `cordis.patch.yml` | Profile patch layer inserting the `dsh-multi-folder` row |
|
|
111
|
-
| `lib/index.js` | Host plugin:
|
|
112
|
-
| `lib/client.js` | Client
|
|
112
|
+
| `lib/index.js` | Host plugin: configuration, tool interception, remote API, directory picker and file manager |
|
|
113
|
+
| `lib/client.js` | Client panel, directory browser and companion `@` source |
|
|
114
|
+
| `lib/native-picker.ps1` | Windows folder-dialog helper |
|
|
113
115
|
| `test/` | Runtime-free behavior tests (see Development) |
|
|
114
116
|
| `docs/` | Design and analysis documents |
|
|
115
117
|
|
package/README.zh.md
CHANGED
|
@@ -50,7 +50,8 @@ dsh plugin --profile web add dsh-multi-folder
|
|
|
50
50
|
|
|
51
51
|
| 操作 | 行为 |
|
|
52
52
|
| ---- | ---- |
|
|
53
|
-
| 添加目录 |
|
|
53
|
+
| 添加目录 | `native` 使用系统文件夹对话框,失败后尝试宿主对话框;`browse` 直接打开插件自带浏览器(路径输入框 + 一级子目录列表 + 可新建文件夹) |
|
|
54
|
+
| 在文件管理器中打开 | 在宿主机器的文件管理器(资源管理器 / Finder)中打开某个已配置目录 |
|
|
54
55
|
| 移除 / 刷新 | 立即生效 |
|
|
55
56
|
| 切换会话 | 面板自动切换为该会话的副工作目录 |
|
|
56
57
|
| 重新打开面板 | 使用会话级缓存,不产生冗余命令行 |
|
|
@@ -98,8 +99,8 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
|
|
|
98
99
|
- **提示词注入**——一个有序 `systemPrompt` 段落,text provider 每次组装按会话求值,仅为配置了副目录的会话渲染。
|
|
99
100
|
- **通知**——命令处理器仅在目录集合实际变化时置位 pending notice;`agent/pre-step`(前置注入进入批次)与 `tools/post-execute`(附加为 `additionalContexts`)两个通道中先触发者消费——均使用框架原生的插件来源 `notice` 上下文。
|
|
100
101
|
- **配置与安全边界**——per-workspace 配置存储于 Agent 沙箱之外的宿主自有目录(`<DSH_HOME>/storages/multi-folder/<workspace-key>.json`)。对配置文件的任何直接 `write`/`edit` 都会收到显式拒绝——**Agent 永远无法自我授予目录,配置权仅属于用户**。详见 [SECURITY.md](SECURITY.md)。
|
|
101
|
-
- **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。`browse`/`makeDir`
|
|
102
|
-
-
|
|
102
|
+
- **无会话远程 API**——经 `ctx.typert.register` 注册 `multiFolder` 命名空间(手写 `src-json` 描述符),并以普通对象服务 `multiFolder` 提供;`list`/`add`/`remove`/`set` 以工作区**路径**为键,与 `/multi-folder` 命令共享同一套校验核心,因此会话尚未建立时创建页也能直接配置。`browse`/`makeDir` 服务于自带浏览器;`pick`/`reveal` 负责选择或打开宿主目录。
|
|
103
|
+
- **目录选择**——`multiFolder/pick` 先调用宿主的 `native` 选择器,失败后尝试系统对话框;`browse` 能力直接打开客户端浏览器。关闭面板会取消请求;取消系统对话框不会添加目录。Windows 助手为 `lib/native-picker.ps1`。
|
|
103
104
|
- **`@` 发现**——同一 `fs` seam 的第二种用法:`multiFolder/listFiles` 为已配置目录建立索引(广度优先、按规范化路径去重以免 junction 绕回、排除生成物/依赖目录名、按工作区限量并短 TTL 缓存),客户端再据此注册一个并列的 `@` source。自带 provider 不做任何修改,自带分组也不受影响:触发器注册表以 `(trigger, name)` 为键,每个 source 各渲染一个分组。
|
|
104
105
|
- **客户端**——手写维护的 factory bundle(`window.__ModuleLoader__.load`),无需构建工具链;面板经两条通道驱动宿主:会话内走 Remote BFF(`ctx.remote.commands.execute`),无会话端点走共享 `/api` RPC 通道(`ctx.connection.rpc.call`)。
|
|
105
106
|
|
|
@@ -108,8 +109,9 @@ Agent 无需任何额外操作:`read` / `glob` / `grep` 随处可用;`write`
|
|
|
108
109
|
| 路径 | 作用 |
|
|
109
110
|
| ---- | ---- |
|
|
110
111
|
| `cordis.patch.yml` | profile patch 层,插入 `dsh-multi-folder` 行 |
|
|
111
|
-
| `lib/index.js` |
|
|
112
|
-
| `lib/client.js` |
|
|
112
|
+
| `lib/index.js` | 宿主插件:配置、工具拦截、远程 API、目录选择器和文件管理器 |
|
|
113
|
+
| `lib/client.js` | 客户端面板、自带浏览器和并列 `@` 来源 |
|
|
114
|
+
| `lib/native-picker.ps1` | Windows 文件夹选择器助手 |
|
|
113
115
|
| `test/` | 免 DSH 运行时的行为测试(见开发) |
|
|
114
116
|
| `docs/` | 设计与分析文档 |
|
|
115
117
|
|
package/SECURITY.md
CHANGED
|
@@ -32,6 +32,27 @@ granted the session. The design enforces four boundaries:
|
|
|
32
32
|
bypasses confinement (as it already does for the primary workspace, by the user's
|
|
33
33
|
explicit choice).
|
|
34
34
|
|
|
35
|
+
### Host-side UI actions (`pick` / `reveal`)
|
|
36
|
+
|
|
37
|
+
Two sessionless endpoints ask the **host machine** to show UI instead of reading or
|
|
38
|
+
writing a path:
|
|
39
|
+
|
|
40
|
+
- `multiFolder/pick` asks the host's composed directory picker first, and only a
|
|
41
|
+
`native` composition (a loopback, attended, non-SSH host — the framework's own
|
|
42
|
+
`directory-picker-auto` decision) may fall back to this plugin's own OS dialog:
|
|
43
|
+
`lib/native-picker.ps1` on Windows, `osascript` on macOS. A `browse` composition
|
|
44
|
+
never reaches a dialog — the client is sent to the browser this plugin draws.
|
|
45
|
+
- `multiFolder/reveal` opens one **existing directory** in the host file manager
|
|
46
|
+
(`explorer.exe` / `open` / `xdg-open`). It refuses anything that is not an existing
|
|
47
|
+
directory, so a stale entry cannot launch a file.
|
|
48
|
+
|
|
49
|
+
Both ride the same trusted browser→host RPC channel as every other `multiFolder/*`
|
|
50
|
+
endpoint (the `connection` Host/Origin fence plus browser authentication) and are
|
|
51
|
+
never exposed as agent tools. They act on the host by design — the host owns the
|
|
52
|
+
filesystem the session is configured against — which is exactly why a remote client
|
|
53
|
+
must never be routed to them: the UI would appear on a display nobody clicked from.
|
|
54
|
+
The `native`-only gate above is what enforces that.
|
|
55
|
+
|
|
35
56
|
### What is deliberately out of scope
|
|
36
57
|
|
|
37
58
|
- In a `danger-full-access` session the agent can already touch the whole filesystem;
|
|
@@ -39,22 +60,3 @@ granted the session. The design enforces four boundaries:
|
|
|
39
60
|
- The agent can *read* the configuration file (reads are not policy-fenced in the DSH
|
|
40
61
|
filesystem backend). Reading reveals nothing the system prompt does not already list
|
|
41
62
|
for that session.
|
|
42
|
-
|
|
43
|
-
## Reporting a vulnerability
|
|
44
|
-
|
|
45
|
-
If you believe you have found a security issue in this plugin, please report it
|
|
46
|
-
privately by opening a GitHub Security Advisory on the repository instead of a public
|
|
47
|
-
issue. Please include:
|
|
48
|
-
|
|
49
|
-
- the affected version,
|
|
50
|
-
- a minimal reproduction,
|
|
51
|
-
- the expected vs. observed behavior.
|
|
52
|
-
|
|
53
|
-
We will acknowledge the report within 7 days and aim to publish a fix (or a
|
|
54
|
-
documented mitigation) before public disclosure.
|
|
55
|
-
|
|
56
|
-
## Supported versions
|
|
57
|
-
|
|
58
|
-
| Version | Supported |
|
|
59
|
-
| ------- | --------- |
|
|
60
|
-
| 0.1.x | ✅ |
|
package/docs/design.md
CHANGED
|
@@ -140,7 +140,7 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
|
|
|
140
140
|
`ctx.inject(['typert'], (t) => t.typert.register(REMOTE_CONTRIBUTION))` —
|
|
141
141
|
the sanctioned manual path documented by `dsh-typert-loader` ("Manual
|
|
142
142
|
`ctx.typert.register()` remains available for contributions that do not use
|
|
143
|
-
a `./typert` artifact"). All
|
|
143
|
+
a `./typert` artifact"). All nine descriptors use `src-json` codecs (no zod
|
|
144
144
|
schemas needed) with `invocation: { kind: 'direct' }`:
|
|
145
145
|
|
|
146
146
|
| Endpoint | Parameters (wire) | Result |
|
|
@@ -152,6 +152,8 @@ opens its own **sessionless** endpoints on the shared `/api` RPC channel:
|
|
|
152
152
|
| `multiFolder/browse` | `path` | `{ path, parent, home, entries, truncated }` |
|
|
153
153
|
| `multiFolder/makeDir` | `parent`, `name` | `{ path, parent }` |
|
|
154
154
|
| `multiFolder/listFiles` | `workspace`, `query` | `{ workspace, dirs, candidates, truncated }` |
|
|
155
|
+
| `multiFolder/pick` | `cwd` (plus transport cancellation) | `{ path, via }` |
|
|
156
|
+
| `multiFolder/reveal` | `path` | `{ path, via }` |
|
|
155
157
|
|
|
156
158
|
The four configuration endpoints are keyed by workspace; `browse`/`makeDir`
|
|
157
159
|
serve the plugin's own directory browser and are keyed by path instead;
|
|
@@ -330,25 +332,26 @@ window.__ModuleLoader__.load({
|
|
|
330
332
|
'multiFolder/<op>', { args })` against the sessionless remote endpoints.
|
|
331
333
|
The panel runs in either mode according to how it was opened; mutations
|
|
332
334
|
and refreshes route per mode, and both modes share the same row/error UI.
|
|
333
|
-
-
|
|
334
|
-
|
|
335
|
-
`
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
335
|
+
- Directory picking: `multiFolder/pick` calls the host's `native` picker with
|
|
336
|
+
the RPC abort signal, then tries a host OS dialog if that picker fails.
|
|
337
|
+
A `browse` capability returns `unavailable` immediately, so remote clients
|
|
338
|
+
open the plugin's browser instead of a dialog on the host. A completed dialog
|
|
339
|
+
returns a path or `null` on cancellation; closing the panel aborts the request.
|
|
340
|
+
- The owned browser's listing rides the **`fs` seam** (`fs.resolve` +
|
|
341
|
+
`fs.listDir`), which every composition provides; only directories are
|
|
342
|
+
returned, hidden entries are flagged, the level is capped at 1000 with a
|
|
343
|
+
`truncated` flag, and paths must be fully qualified. Creation mirrors the
|
|
344
|
+
shipped browse backend (`dsh-host-directory-picker-browse`) by calling Node's
|
|
345
|
+
`mkdir` on a validated single segment, because the `fs` seam exposes no
|
|
346
|
+
creation primitive. Neither endpoint touches the configuration store: choosing
|
|
347
|
+
a level still commits through the mode's own channel (`/multi-folder add` in a
|
|
348
|
+
session, `multiFolder/add` on the creation page).
|
|
349
|
+
- On Windows, `lib/native-picker.ps1` uses `IFileOpenDialog` on an STA thread,
|
|
350
|
+
sets per-monitor DPI awareness (a build without that thread API still shows the
|
|
351
|
+
dialog), assists it to the foreground for a few seconds, and closes an
|
|
352
|
+
unanswered dialog on a deadline. It exits 0 for a selection or dismissal and
|
|
353
|
+
nonzero when `Show()` fails. `multiFolder/reveal` checks `fs.stat` before
|
|
354
|
+
opening an existing directory with the host file manager.
|
|
352
355
|
- `@` source: registered through `ctx.inject(['inputTriggers'], …)` (see the
|
|
353
356
|
`@` discovery section for the full decision table). It resolves the addressed
|
|
354
357
|
session's workspace from the `sessions` snapshot (`byId[sessionId].cwd`), calls
|
package/lib/client.js
CHANGED
|
@@ -27,17 +27,14 @@
|
|
|
27
27
|
* (`ctx.connection.rpc.call('/api', endpoint, { args })`), keyed by workspace
|
|
28
28
|
* path instead of sessionId.
|
|
29
29
|
*
|
|
30
|
-
* "Add directory"
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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 …`.
|
|
30
|
+
* "Add directory" calls `multiFolder/pick`. A `native` host picker may fall back
|
|
31
|
+
* to an OS dialog; a `browse` capability opens this plugin's browser through
|
|
32
|
+
* `multiFolder/browse` and `multiFolder/makeDir`. Closing the panel cancels a
|
|
33
|
+
* pending pick. Dismissing a system dialog adds nothing.
|
|
34
|
+
*
|
|
35
|
+
* Every configured row also carries "open in the host file manager"
|
|
36
|
+
* (`multiFolder/reveal`), which puts the directory in Explorer/Finder where the
|
|
37
|
+
* user's own shell integrations live.
|
|
41
38
|
*
|
|
42
39
|
* `@` discovery for the configured directories. The shipped `@` menu is
|
|
43
40
|
* SINGLE-ROOT: `ui-reference` feeds it from `remote.fileReferences.list`, whose
|
|
@@ -107,6 +104,10 @@ window.__ModuleLoader__.load({
|
|
|
107
104
|
browseDraft: '',
|
|
108
105
|
/** "new folder" name draft. */
|
|
109
106
|
newDirName: '',
|
|
107
|
+
/** A system pick is in flight. */
|
|
108
|
+
pickBusy: false,
|
|
109
|
+
/** Muted reason shown when the system picker FAILED and the browser took over. */
|
|
110
|
+
notice: null,
|
|
110
111
|
/** Bumped on every workspaceCache write so chips re-read their count. */
|
|
111
112
|
cacheRev: 0,
|
|
112
113
|
};
|
|
@@ -232,6 +233,10 @@ window.__ModuleLoader__.load({
|
|
|
232
233
|
'browse.newDirPlaceholder': '新建文件夹名称',
|
|
233
234
|
'browse.newDirCreate': '创建',
|
|
234
235
|
'browse.hint': '浏览宿主机文件系统;选中的目录将作为副工作目录添加。',
|
|
236
|
+
'panel.addBusy': '等待系统对话框…',
|
|
237
|
+
'panel.addBrowse': '改用内置浏览器选择…',
|
|
238
|
+
'panel.reveal': '在资源管理器中打开(宿主机)',
|
|
239
|
+
'panel.pickFailed': '系统目录选择器调用失败,已改用内置浏览器:{message}',
|
|
235
240
|
};
|
|
236
241
|
/** English dictionary — checked complete against the zh key set. */
|
|
237
242
|
var enDict = {
|
|
@@ -269,6 +274,10 @@ window.__ModuleLoader__.load({
|
|
|
269
274
|
'browse.newDirPlaceholder': 'New folder name',
|
|
270
275
|
'browse.newDirCreate': 'Create',
|
|
271
276
|
'browse.hint': 'Browses the host filesystem; the chosen directory is added as a secondary working directory.',
|
|
277
|
+
'panel.addBusy': 'Waiting for the system dialog…',
|
|
278
|
+
'panel.addBrowse': 'Use the built-in browser instead…',
|
|
279
|
+
'panel.reveal': 'Open in the host file manager',
|
|
280
|
+
'panel.pickFailed': 'The system picker failed; using the built-in browser: {message}',
|
|
272
281
|
};
|
|
273
282
|
|
|
274
283
|
// ------------------------------------------------------------- plugin
|
|
@@ -297,16 +306,37 @@ window.__ModuleLoader__.load({
|
|
|
297
306
|
* the `t` seat the renderer synthesizes from the declared
|
|
298
307
|
* `locale:` namespace (which also re-renders them on locale switch). */
|
|
299
308
|
var t = locale.bind(NS);
|
|
309
|
+
var activePick = null;
|
|
310
|
+
|
|
311
|
+
function cancelPick() {
|
|
312
|
+
if (!activePick) return;
|
|
313
|
+
activePick.abort();
|
|
314
|
+
activePick = null;
|
|
315
|
+
patch({ pickBusy: false });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function closePanel() {
|
|
319
|
+
cancelPick();
|
|
320
|
+
patch({ open: false });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
ctx.effect(function () {
|
|
324
|
+
return function () {
|
|
325
|
+
var controller = activePick;
|
|
326
|
+
activePick = null;
|
|
327
|
+
if (controller) controller.abort();
|
|
328
|
+
};
|
|
329
|
+
}, 'dsh-multi-folder: cancel picker on unload');
|
|
300
330
|
|
|
301
331
|
// -------------------------------------------------- sessionless RPC
|
|
302
332
|
/** Call one `multiFolder/*` endpoint over the shared /api channel.
|
|
303
333
|
* The Host gateway answers with the same `{ ok, value }` envelope as
|
|
304
334
|
* the command remote; business errors surface as thrown Errors. */
|
|
305
|
-
function remoteCall(endpoint, args) {
|
|
335
|
+
function remoteCall(endpoint, args, signal) {
|
|
306
336
|
if (!connection || !connection.rpc || typeof connection.rpc.call !== 'function') {
|
|
307
337
|
return Promise.reject(new Error('multi-folder: the shared RPC channel (connection service) is unavailable'));
|
|
308
338
|
}
|
|
309
|
-
return connection.rpc.call('/api', endpoint, { args: args }).then(function (envelope) {
|
|
339
|
+
return connection.rpc.call('/api', endpoint, { args: args }, signal).then(function (envelope) {
|
|
310
340
|
if (!envelope || envelope.ok !== true) {
|
|
311
341
|
var message = envelope && envelope.error !== undefined
|
|
312
342
|
? String(envelope.error.message !== undefined ? envelope.error.message : envelope.error)
|
|
@@ -382,6 +412,7 @@ window.__ModuleLoader__.load({
|
|
|
382
412
|
* renders it as its own popover, everything else uses the overlay. */
|
|
383
413
|
function openForWorkspace(workspacePath, anchor) {
|
|
384
414
|
var seat = anchor || 'overlay';
|
|
415
|
+
cancelPick();
|
|
385
416
|
patch(browserReset());
|
|
386
417
|
if (!workspacePath) {
|
|
387
418
|
patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null, anchor: seat });
|
|
@@ -458,11 +489,8 @@ window.__ModuleLoader__.load({
|
|
|
458
489
|
}
|
|
459
490
|
|
|
460
491
|
// -------------------------------------------- owned directory browser
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
// through `multiFolder/makeDir`, so the interaction never depends on the
|
|
464
|
-
// host's composed directory picker — the native chooser, the browse
|
|
465
|
-
// backend and a shell with no picker at all all behave the same here.
|
|
492
|
+
// The owned browser reads levels over `multiFolder/browse` and creates
|
|
493
|
+
// directories through `multiFolder/makeDir`.
|
|
466
494
|
|
|
467
495
|
/** Read one level into the store. A blank path asks the host for its
|
|
468
496
|
* home directory, so the browser always has somewhere to start. */
|
|
@@ -486,9 +514,11 @@ window.__ModuleLoader__.load({
|
|
|
486
514
|
});
|
|
487
515
|
}
|
|
488
516
|
|
|
489
|
-
/** Open the browser at the panel's workspace (or the host home when
|
|
490
|
-
* panel has no workspace path yet) and read its first level.
|
|
491
|
-
|
|
517
|
+
/** Open the OWNED browser at the panel's workspace (or the host home when
|
|
518
|
+
* the panel has no workspace path yet) and read its first level. Reachable
|
|
519
|
+
* from the panel at all times, and the automatic destination whenever the
|
|
520
|
+
* system picker cannot answer. */
|
|
521
|
+
function openOwnedBrowser() {
|
|
492
522
|
var snapshot = getSnapshot();
|
|
493
523
|
patch({
|
|
494
524
|
browseOpen: true,
|
|
@@ -501,6 +531,35 @@ window.__ModuleLoader__.load({
|
|
|
501
531
|
browseTo(snapshot.workspace || '');
|
|
502
532
|
}
|
|
503
533
|
|
|
534
|
+
/** Pick a directory, or open the owned browser when no system picker answers. */
|
|
535
|
+
function addDirectory() {
|
|
536
|
+
var snapshot = getSnapshot();
|
|
537
|
+
if (snapshot.pickBusy) return Promise.resolve();
|
|
538
|
+
var controller = new AbortController();
|
|
539
|
+
activePick = controller;
|
|
540
|
+
patch({ pickBusy: true, error: null, notice: null });
|
|
541
|
+
return remoteCall('multiFolder/pick', { cwd: snapshot.workspace || '' }, controller.signal).then(
|
|
542
|
+
function (value) {
|
|
543
|
+
if (activePick !== controller) return undefined;
|
|
544
|
+
activePick = null;
|
|
545
|
+
patch({ pickBusy: false });
|
|
546
|
+
var picked = value && typeof value.path === 'string' && value.path !== '' ? value.path : null;
|
|
547
|
+
if (picked) return commitBrowsePath(picked);
|
|
548
|
+
if (value && (value.via === 'host-native' || value.via === 'os-dialog')) return undefined;
|
|
549
|
+
return openOwnedBrowser();
|
|
550
|
+
},
|
|
551
|
+
function (error) {
|
|
552
|
+
if (activePick !== controller) return undefined;
|
|
553
|
+
activePick = null;
|
|
554
|
+
patch({
|
|
555
|
+
pickBusy: false,
|
|
556
|
+
notice: t('panel.pickFailed', { message: String(error && error.message ? error.message : error) }),
|
|
557
|
+
});
|
|
558
|
+
return openOwnedBrowser();
|
|
559
|
+
},
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
504
563
|
/** Leave the browser without adding anything. */
|
|
505
564
|
function closeBrowser() {
|
|
506
565
|
patch(browserReset());
|
|
@@ -510,7 +569,7 @@ window.__ModuleLoader__.load({
|
|
|
510
569
|
* fresh open of the panel (header button, chip, launcher) starts there,
|
|
511
570
|
* so a browser left open by a dismissed panel never comes back stale. */
|
|
512
571
|
function browserReset() {
|
|
513
|
-
return { browseOpen: false, browse: null, browseBusy: false, browseError: null, newDirName: '' };
|
|
572
|
+
return { browseOpen: false, browse: null, browseBusy: false, browseError: null, newDirName: '', notice: null };
|
|
514
573
|
}
|
|
515
574
|
|
|
516
575
|
/** Adopt one path as a secondary working directory through the channel
|
|
@@ -553,6 +612,7 @@ window.__ModuleLoader__.load({
|
|
|
553
612
|
function openFor(sessionId) {
|
|
554
613
|
if (!sessionId) return;
|
|
555
614
|
var cached = sessionCache[sessionId];
|
|
615
|
+
cancelPick();
|
|
556
616
|
patch(browserReset());
|
|
557
617
|
patch({
|
|
558
618
|
open: true,
|
|
@@ -594,7 +654,7 @@ window.__ModuleLoader__.load({
|
|
|
594
654
|
var current = getSnapshot();
|
|
595
655
|
var isOpen = current.open && current.sessionId === sessionId;
|
|
596
656
|
if (isOpen) {
|
|
597
|
-
|
|
657
|
+
closePanel();
|
|
598
658
|
} else {
|
|
599
659
|
openFor(sessionId);
|
|
600
660
|
}
|
|
@@ -688,7 +748,7 @@ window.__ModuleLoader__.load({
|
|
|
688
748
|
{
|
|
689
749
|
type: 'button',
|
|
690
750
|
title: t('title.close'),
|
|
691
|
-
onClick: function () {
|
|
751
|
+
onClick: function () { closePanel(); },
|
|
692
752
|
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
|
|
693
753
|
},
|
|
694
754
|
'✕',
|
|
@@ -753,6 +813,9 @@ window.__ModuleLoader__.load({
|
|
|
753
813
|
store.browseError
|
|
754
814
|
? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.danger, whiteSpace: 'pre-wrap', fontSize: 12 } }, String(store.browseError))
|
|
755
815
|
: null,
|
|
816
|
+
store.notice
|
|
817
|
+
? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.inkMuted, whiteSpace: 'pre-wrap', fontSize: 11 } }, String(store.notice))
|
|
818
|
+
: null,
|
|
756
819
|
React.createElement(
|
|
757
820
|
'div',
|
|
758
821
|
{ style: { maxHeight: 200, overflowY: 'auto', marginBottom: 6 } },
|
|
@@ -835,6 +898,22 @@ window.__ModuleLoader__.load({
|
|
|
835
898
|
},
|
|
836
899
|
dir,
|
|
837
900
|
),
|
|
901
|
+
React.createElement(
|
|
902
|
+
'button',
|
|
903
|
+
{
|
|
904
|
+
type: 'button',
|
|
905
|
+
title: t('panel.reveal'),
|
|
906
|
+
disabled: !usable,
|
|
907
|
+
onClick: function () {
|
|
908
|
+
patch({ error: null });
|
|
909
|
+
remoteCall('multiFolder/reveal', { path: dir }).catch(function (e) {
|
|
910
|
+
patch({ error: String(e && e.message ? e.message : e) });
|
|
911
|
+
});
|
|
912
|
+
},
|
|
913
|
+
style: { padding: '2px 6px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
|
|
914
|
+
},
|
|
915
|
+
'📂',
|
|
916
|
+
),
|
|
838
917
|
React.createElement(
|
|
839
918
|
'button',
|
|
840
919
|
{
|
|
@@ -864,7 +943,7 @@ window.__ModuleLoader__.load({
|
|
|
864
943
|
{
|
|
865
944
|
type: 'button',
|
|
866
945
|
title: t('title.close'),
|
|
867
|
-
onClick: function () {
|
|
946
|
+
onClick: function () { closePanel(); },
|
|
868
947
|
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
|
|
869
948
|
},
|
|
870
949
|
'✕',
|
|
@@ -896,7 +975,7 @@ window.__ModuleLoader__.load({
|
|
|
896
975
|
'button',
|
|
897
976
|
{
|
|
898
977
|
type: 'button',
|
|
899
|
-
disabled: !!store.busy || !usable,
|
|
978
|
+
disabled: !!store.busy || !!store.pickBusy || !usable,
|
|
900
979
|
onClick: function () { addDirectory(); },
|
|
901
980
|
style: {
|
|
902
981
|
flex: 1,
|
|
@@ -905,11 +984,11 @@ window.__ModuleLoader__.load({
|
|
|
905
984
|
border: '1px solid ' + TOKEN.border,
|
|
906
985
|
background: TOKEN.fill,
|
|
907
986
|
color: TOKEN.ink,
|
|
908
|
-
cursor: store.busy || !usable ? 'default' : 'pointer',
|
|
909
|
-
opacity: store.busy || !usable ? 0.6 : 1,
|
|
987
|
+
cursor: store.busy || store.pickBusy || !usable ? 'default' : 'pointer',
|
|
988
|
+
opacity: store.busy || store.pickBusy || !usable ? 0.6 : 1,
|
|
910
989
|
},
|
|
911
990
|
},
|
|
912
|
-
store.busy ? t('panel.adding') : t('panel.add'),
|
|
991
|
+
store.busy ? t('panel.adding') : (store.pickBusy ? t('panel.addBusy') : t('panel.add')),
|
|
913
992
|
),
|
|
914
993
|
React.createElement(
|
|
915
994
|
'button',
|
|
@@ -936,6 +1015,26 @@ window.__ModuleLoader__.load({
|
|
|
936
1015
|
t('panel.refresh'),
|
|
937
1016
|
),
|
|
938
1017
|
),
|
|
1018
|
+
React.createElement(
|
|
1019
|
+
'button',
|
|
1020
|
+
{
|
|
1021
|
+
type: 'button',
|
|
1022
|
+
disabled: !!store.busy || !!store.pickBusy || !usable,
|
|
1023
|
+
onClick: function () { openOwnedBrowser(); },
|
|
1024
|
+
style: {
|
|
1025
|
+
marginBottom: 6,
|
|
1026
|
+
padding: 0,
|
|
1027
|
+
border: '1px solid transparent',
|
|
1028
|
+
background: 'transparent',
|
|
1029
|
+
color: TOKEN.inkMuted,
|
|
1030
|
+
fontSize: 11,
|
|
1031
|
+
textAlign: 'left',
|
|
1032
|
+
cursor: store.busy || store.pickBusy || !usable ? 'default' : 'pointer',
|
|
1033
|
+
opacity: store.busy || store.pickBusy || !usable ? 0.5 : 1,
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
t('panel.addBrowse'),
|
|
1037
|
+
),
|
|
939
1038
|
React.createElement(
|
|
940
1039
|
'div',
|
|
941
1040
|
{
|
|
@@ -1030,7 +1129,7 @@ window.__ModuleLoader__.load({
|
|
|
1030
1129
|
/** Click-outside catcher for an open popover. */
|
|
1031
1130
|
function Backdrop() {
|
|
1032
1131
|
return React.createElement('div', {
|
|
1033
|
-
onClick: function () {
|
|
1132
|
+
onClick: function () { closePanel(); },
|
|
1034
1133
|
style: { position: 'fixed', inset: 0, zIndex: 30 },
|
|
1035
1134
|
});
|
|
1036
1135
|
}
|
|
@@ -1201,7 +1300,7 @@ window.__ModuleLoader__.load({
|
|
|
1201
1300
|
label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
|
|
1202
1301
|
onClick: function () {
|
|
1203
1302
|
if (open) {
|
|
1204
|
-
|
|
1303
|
+
closePanel();
|
|
1205
1304
|
} else {
|
|
1206
1305
|
openForWorkspace(workspacePath, 'dock');
|
|
1207
1306
|
}
|
|
@@ -1315,7 +1414,7 @@ window.__ModuleLoader__.load({
|
|
|
1315
1414
|
label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
|
|
1316
1415
|
onClick: function () {
|
|
1317
1416
|
if (open) {
|
|
1318
|
-
|
|
1417
|
+
closePanel();
|
|
1319
1418
|
} else {
|
|
1320
1419
|
openForWorkspace(workspacePath, 'extras');
|
|
1321
1420
|
}
|
package/lib/index.js
CHANGED
|
@@ -68,19 +68,10 @@
|
|
|
68
68
|
* <secondary>` launched from the primary workspace cannot write the
|
|
69
69
|
* repo) gets a workdir-fix hint attached as an additional context at
|
|
70
70
|
* the `tools/post-execute` boundary.
|
|
71
|
-
* 8.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* native-chooser composition, the browse composition (LAN / remote
|
|
76
|
-
* clients / desktop shells) and shells that compose no picker at all.
|
|
77
|
-
* The framework offers no plugin-facing alternative: `uiWorkspace`'s
|
|
78
|
-
* `pickDirectory()` is native-only (it answers `directory-picker/unavailable`
|
|
79
|
-
* under the browse composition), its `listDirectory`/`createDirectory`
|
|
80
|
-
* twins are browse-only, and the shipped in-app browser is reachable only
|
|
81
|
-
* by the shell's own workspace surfaces. Directory creation mirrors the
|
|
82
|
-
* shipped browse backend, which uses Node's `mkdir` (the fs seam has no
|
|
83
|
-
* creation primitive).
|
|
71
|
+
* 8. Directory picking: `pick` uses the host's `native` capability, then an
|
|
72
|
+
* OS dialog on failure. A `browse` capability goes straight to the client
|
|
73
|
+
* browser served by `browse`/`makeDir`; panel closure cancels the request.
|
|
74
|
+
* `reveal` opens an existing directory in the host file manager.
|
|
84
75
|
* 9. `@` file discovery for the configured directories (see the
|
|
85
76
|
* "secondary-directory file discovery" section). The shipped `@`
|
|
86
77
|
* file-reference menu is single-root — its provider walks the session cwd
|
|
@@ -92,7 +83,9 @@
|
|
|
92
83
|
*/
|
|
93
84
|
|
|
94
85
|
import { mkdir } from 'node:fs/promises'
|
|
95
|
-
import {
|
|
86
|
+
import { spawn } from 'node:child_process'
|
|
87
|
+
import { join, dirname, posix, win32 } from 'node:path'
|
|
88
|
+
import { fileURLToPath } from 'node:url'
|
|
96
89
|
import os from 'node:os'
|
|
97
90
|
|
|
98
91
|
export const name = 'dsh-multi-folder'
|
|
@@ -395,6 +388,238 @@ export function apply(ctx) {
|
|
|
395
388
|
return { path: target, parent }
|
|
396
389
|
}
|
|
397
390
|
|
|
391
|
+
// ---------------------------------------------------- native folder picker
|
|
392
|
+
|
|
393
|
+
/** How long one native selection may stay open before it is dismissed. */
|
|
394
|
+
const NATIVE_PICK_TIMEOUT_MS = 5 * 60 * 1000
|
|
395
|
+
const pickerScript = () => join(dirname(fileURLToPath(import.meta.url)), 'native-picker.ps1')
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Ask the host's composed `directoryPicker` service — and only when it
|
|
399
|
+
* composed the NATIVE backend.
|
|
400
|
+
* @returns { path, via: 'host-native' } with a null path when the user
|
|
401
|
+
* cancelled, `{ path: null, via: 'unavailable' }` whenever the host must not
|
|
402
|
+
* be shown an OS chooser (the browse backend, an unknown kind, a capability
|
|
403
|
+
* that cannot even be read), or null when no picker is composed at all — the
|
|
404
|
+
* caller may then try the OS dialog of the host machine.
|
|
405
|
+
*/
|
|
406
|
+
const pickViaHostService = async (signal) => {
|
|
407
|
+
let picker
|
|
408
|
+
try {
|
|
409
|
+
picker = ctx.get('directoryPicker')
|
|
410
|
+
} catch {
|
|
411
|
+
picker = undefined
|
|
412
|
+
}
|
|
413
|
+
if (!picker || typeof picker.capability !== 'function') return null
|
|
414
|
+
let capability
|
|
415
|
+
try {
|
|
416
|
+
capability = picker.capability()
|
|
417
|
+
} catch {
|
|
418
|
+
return { path: null, via: 'unavailable' }
|
|
419
|
+
}
|
|
420
|
+
if (!capability || capability.kind !== 'native') return { path: null, via: 'unavailable' }
|
|
421
|
+
if (typeof capability.pick !== 'function') return null
|
|
422
|
+
const selected = await capability.pick(signal)
|
|
423
|
+
return { path: typeof selected === 'string' && fullyQualified(selected) ? selected : null, via: 'host-native' }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Run one helper process to completion.
|
|
428
|
+
* @returns { code, stdout, stderr }, { spawnError } when it never started, or
|
|
429
|
+
* { aborted } when the caller's signal stopped it.
|
|
430
|
+
*/
|
|
431
|
+
const runPickerProcess = (command, args, timeoutMs, stopWhen, signal) =>
|
|
432
|
+
new Promise((resolve) => {
|
|
433
|
+
if (signal?.aborted) {
|
|
434
|
+
resolve({ aborted: true })
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
let child
|
|
438
|
+
try {
|
|
439
|
+
child = spawn(command, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
440
|
+
} catch (e) {
|
|
441
|
+
resolve({ spawnError: e })
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
let stdout = ''
|
|
445
|
+
let stderr = ''
|
|
446
|
+
let settled = false
|
|
447
|
+
const done = (outcome) => {
|
|
448
|
+
if (settled) return
|
|
449
|
+
settled = true
|
|
450
|
+
clearTimeout(killer)
|
|
451
|
+
signal?.removeEventListener('abort', abort)
|
|
452
|
+
resolve(outcome)
|
|
453
|
+
}
|
|
454
|
+
const abort = () => {
|
|
455
|
+
try { child.kill() } catch { /* already gone */ }
|
|
456
|
+
done({ aborted: true })
|
|
457
|
+
}
|
|
458
|
+
const killer = setTimeout(() => {
|
|
459
|
+
try { child.kill() } catch { /* already gone */ }
|
|
460
|
+
done({ killed: true, stdout, stderr })
|
|
461
|
+
}, timeoutMs + 5000)
|
|
462
|
+
if (typeof killer.unref === 'function') killer.unref()
|
|
463
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
464
|
+
if (signal?.aborted) abort()
|
|
465
|
+
child.stdout?.setEncoding('utf8')
|
|
466
|
+
child.stderr?.setEncoding('utf8')
|
|
467
|
+
const consume = (chunk) => {
|
|
468
|
+
stdout += chunk
|
|
469
|
+
if (stopWhen && !settled && stopWhen(stdout)) {
|
|
470
|
+
try { child.kill() } catch { /* already gone */ }
|
|
471
|
+
done({ code: null, stdout, stderr, early: true })
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (child.stdout) child.stdout.on('data', consume)
|
|
475
|
+
if (child.stderr) child.stderr.on('data', (chunk) => { stderr += chunk })
|
|
476
|
+
child.on('error', (error) => done({ spawnError: error }))
|
|
477
|
+
child.on('close', (code) => done({ code, stdout, stderr }))
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
/** The last non-empty line of one helper's output, trimmed, or ''. */
|
|
481
|
+
const lastLine = (text) => {
|
|
482
|
+
const lines = String(text || '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0)
|
|
483
|
+
return lines.length > 0 ? lines[lines.length - 1] : ''
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const printedPath = (output) => {
|
|
487
|
+
if (!/\r?\n$/.test(String(output))) return null
|
|
488
|
+
const last = lastLine(output)
|
|
489
|
+
return last !== '' && fullyQualified(last) ? last : null
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The path a helper had printed by the time it exited — the same read as
|
|
494
|
+
* `printedPath`, without the demand that the line had already ended: a helper
|
|
495
|
+
* stopped right after writing its answer still selected something, and reading
|
|
496
|
+
* that as a dismissal would add nothing, silently.
|
|
497
|
+
*/
|
|
498
|
+
const pickerResultPath = (outcome) => {
|
|
499
|
+
if (!outcome || outcome.spawnError) return null
|
|
500
|
+
const last = lastLine(outcome.stdout)
|
|
501
|
+
return last !== '' && fullyQualified(last) ? last : null
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Re-raise the caller's abort reason when the helper was stopped by it — an
|
|
506
|
+
* aborted outcome carries no exit code, and reading that absence as a helper
|
|
507
|
+
* failure would report a cancelled dialog as a broken one.
|
|
508
|
+
*/
|
|
509
|
+
const raiseIfAborted = (outcome, signal) => {
|
|
510
|
+
if (!outcome.aborted) return
|
|
511
|
+
signal?.throwIfAborted()
|
|
512
|
+
throw new Error('the folder dialog was cancelled')
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Open the HOST machine's own folder chooser. Windows drives the shipped
|
|
517
|
+
* `native-picker.ps1` — the Vista+ common item dialog with FOS_PICKFOLDERS,
|
|
518
|
+
* i.e. the same dialog other applications show, which shell integrations hook
|
|
519
|
+
* (the legacy `FolderBrowserDialog` tree is a different control that they do
|
|
520
|
+
* not) — and macOS asks Finder through `osascript`.
|
|
521
|
+
* @returns { path, via: 'os-dialog' }, or null on a platform with no OS
|
|
522
|
+
* dialog to open — the caller then keeps the plugin's own browser.
|
|
523
|
+
*/
|
|
524
|
+
const pickViaOsDialog = async (initial, signal) => {
|
|
525
|
+
const start = typeof initial === 'string' && fullyQualified(initial) ? initial : ''
|
|
526
|
+
if (process.platform === 'win32') {
|
|
527
|
+
const args = [
|
|
528
|
+
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
529
|
+
'-File', pickerScript(),
|
|
530
|
+
'-TimeoutMs', String(NATIVE_PICK_TIMEOUT_MS),
|
|
531
|
+
]
|
|
532
|
+
if (start !== '') args.push('-InitialDirectory', start)
|
|
533
|
+
let outcome = await runPickerProcess('pwsh', args, NATIVE_PICK_TIMEOUT_MS, printedPath, signal)
|
|
534
|
+
if (outcome.spawnError && outcome.spawnError.code === 'ENOENT') {
|
|
535
|
+
// Windows PowerShell is always present; pwsh is only usually present.
|
|
536
|
+
outcome = await runPickerProcess('powershell.exe', args, NATIVE_PICK_TIMEOUT_MS, printedPath, signal)
|
|
537
|
+
}
|
|
538
|
+
raiseIfAborted(outcome, signal)
|
|
539
|
+
signal?.throwIfAborted()
|
|
540
|
+
if (outcome.spawnError) throw outcome.spawnError
|
|
541
|
+
const selected = printedPath(outcome.stdout) || pickerResultPath(outcome)
|
|
542
|
+
const settled = { path: typeof selected === 'string' && fullyQualified(selected) ? selected : null, via: 'os-dialog' }
|
|
543
|
+
// `early` = the answer arrived and the helper is merely shutting down;
|
|
544
|
+
// `killed` = its own deadline dismissed the dialog and the process
|
|
545
|
+
// outlived the grace period. Neither is a failure, and NEITHER carries an
|
|
546
|
+
// exit code to judge (both report `code: null`).
|
|
547
|
+
if (outcome.early || outcome.killed) return settled
|
|
548
|
+
// The helper exits 0 for BOTH "selected" and "dismissed" (it prints a path
|
|
549
|
+
// or nothing), so any other exit means the dialog never ran — an
|
|
550
|
+
// ExecutionPolicy block, a failed Add-Type. Reporting that as a plain
|
|
551
|
+
// cancellation would leave the user watching nothing happen at all, which
|
|
552
|
+
// is why it is raised for the caller to fall back on.
|
|
553
|
+
if (outcome.code !== 0) {
|
|
554
|
+
const why = lastLine(outcome.stderr)
|
|
555
|
+
throw new Error('the folder dialog helper failed (exit ' + outcome.code + ')' + (why ? ': ' + why : ''))
|
|
556
|
+
}
|
|
557
|
+
return settled
|
|
558
|
+
}
|
|
559
|
+
if (process.platform === 'darwin') {
|
|
560
|
+
// No prompt: Finder supplies its own localized one, exactly like the
|
|
561
|
+
// Windows dialog supplies its own localized title.
|
|
562
|
+
const outcome = await runPickerProcess('osascript', ['-e', 'POSIX path of (choose folder)'], NATIVE_PICK_TIMEOUT_MS, printedPath, signal)
|
|
563
|
+
raiseIfAborted(outcome, signal)
|
|
564
|
+
signal?.throwIfAborted()
|
|
565
|
+
if (outcome.spawnError) throw outcome.spawnError
|
|
566
|
+
const selected = printedPath(outcome.stdout) || pickerResultPath(outcome)
|
|
567
|
+
const settled = { path: typeof selected === 'string' && fullyQualified(selected) ? selected : null, via: 'os-dialog' }
|
|
568
|
+
// An early or killed outcome carries no exit code of its own, and a
|
|
569
|
+
// dismissal is the ordinary result in both cases.
|
|
570
|
+
if (outcome.early || outcome.killed) return settled
|
|
571
|
+
// AppleScript reports a user cancellation as execution error -128; every
|
|
572
|
+
// other non-zero exit is a real failure and must not be mistaken for one.
|
|
573
|
+
if (outcome.code !== 0 && !/-128|User canceled/i.test(String(outcome.stderr || ''))) {
|
|
574
|
+
const why = lastLine(outcome.stderr)
|
|
575
|
+
throw new Error('the macOS folder chooser failed (exit ' + outcome.code + ')' + (why ? ': ' + why : ''))
|
|
576
|
+
}
|
|
577
|
+
return settled
|
|
578
|
+
}
|
|
579
|
+
return null
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Open the host machine's file manager at one existing directory — one click from the
|
|
584
|
+
* panel to the shell integrations the user already has (Listary, QuickLook, a
|
|
585
|
+
* terminal "open here"). Detached by design: the file manager outlives this
|
|
586
|
+
* call, so there is no exit status to report and no error to invent.
|
|
587
|
+
* @param path - fully qualified directory on the host.
|
|
588
|
+
* @returns the canonical path and the helper that was asked to show it.
|
|
589
|
+
*/
|
|
590
|
+
const coreReveal = async (path) => {
|
|
591
|
+
if (typeof path !== 'string' || !fullyQualified(path)) {
|
|
592
|
+
throw new Error('reveal requires a fully qualified path, got "' + String(path) + '"')
|
|
593
|
+
}
|
|
594
|
+
const target = await fs.resolve(path)
|
|
595
|
+
const info = await fs.stat(target)
|
|
596
|
+
if (!info || info.type !== 'directory') throw new Error('reveal requires an existing directory: "' + path + '"')
|
|
597
|
+
const absolute = fs.processPath(target)
|
|
598
|
+
const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
599
|
+
let child
|
|
600
|
+
try {
|
|
601
|
+
child = spawn(command, [absolute], { detached: true, stdio: 'ignore' })
|
|
602
|
+
} catch (e) {
|
|
603
|
+
throw new Error('cannot open "' + absolute + '": ' + String(e && e.message ? e.message : e))
|
|
604
|
+
}
|
|
605
|
+
// A detached spawn reports a missing executable asynchronously, so wait for
|
|
606
|
+
// 'spawn' before claiming success: otherwise a host without `xdg-open` would
|
|
607
|
+
// report an opened file manager that never appeared. After that the process
|
|
608
|
+
// is on its own — detached, unreferenced, and with a listener so a later
|
|
609
|
+
// failure cannot surface as an unhandled 'error' event on the plugin.
|
|
610
|
+
try {
|
|
611
|
+
await new Promise((resolve, reject) => {
|
|
612
|
+
child.once('spawn', resolve)
|
|
613
|
+
child.once('error', reject)
|
|
614
|
+
})
|
|
615
|
+
} catch (e) {
|
|
616
|
+
throw new Error('cannot open "' + absolute + '": ' + String(e && e.message ? e.message : e))
|
|
617
|
+
}
|
|
618
|
+
child.on('error', () => {})
|
|
619
|
+
child.unref()
|
|
620
|
+
return { path: absolute, via: command }
|
|
621
|
+
}
|
|
622
|
+
|
|
398
623
|
// ------------------------------------- secondary-directory file discovery
|
|
399
624
|
// The shipped `@` file-reference menu is SINGLE-ROOT by construction: its
|
|
400
625
|
// provider (`dsh-file-reference-local`) builds one `WorkspaceFileSearch` per
|
|
@@ -688,6 +913,9 @@ export function apply(ctx) {
|
|
|
688
913
|
const remoteErrorMessage = (e) =>
|
|
689
914
|
'multi-folder: ' + String(e && e.message ? e.message : e).replace(/^multi-folder:\s*/, '')
|
|
690
915
|
|
|
916
|
+
/** One failure's message, for composing a diagnostic without nesting prefixes. */
|
|
917
|
+
const reasonOf = (e) => String(e && e.message ? e.message : e)
|
|
918
|
+
|
|
691
919
|
const multiFolderApi = {
|
|
692
920
|
async list(workspace) {
|
|
693
921
|
try {
|
|
@@ -738,7 +966,48 @@ export function apply(ctx) {
|
|
|
738
966
|
throw new Error(remoteErrorMessage(e))
|
|
739
967
|
}
|
|
740
968
|
},
|
|
969
|
+
/**
|
|
970
|
+
* Open the system folder chooser on the host machine and report what came
|
|
971
|
+
* back. `via` tells the client half apart the three outcomes it must
|
|
972
|
+
* distinguish: a completed system dialog (`host-native` / `os-dialog` —
|
|
973
|
+
* a null path means the user cancelled, so nothing must be opened), and
|
|
974
|
+
* `unavailable`, where NO system picker could answer and the client should
|
|
975
|
+
* keep using the plugin's own browser.
|
|
976
|
+
*/
|
|
977
|
+
async pick(cwd, signal) {
|
|
978
|
+
try {
|
|
979
|
+
signal?.throwIfAborted()
|
|
980
|
+
let native = null
|
|
981
|
+
let nativeFailure = null
|
|
982
|
+
try {
|
|
983
|
+
native = await pickViaHostService(signal)
|
|
984
|
+
} catch (e) {
|
|
985
|
+
// A composed chooser that refuses or crashes must not take the session
|
|
986
|
+
// down with it — the OS dialog below is exactly the second attempt.
|
|
987
|
+
// The reason is still kept and logged, because a downgrade nobody can
|
|
988
|
+
// explain afterwards is the failure mode this fallback exists to avoid.
|
|
989
|
+
nativeFailure = e
|
|
990
|
+
ctx.logger?.warn?.('multi-folder: the host directory picker failed: ' + reasonOf(nativeFailure))
|
|
991
|
+
}
|
|
992
|
+
signal?.throwIfAborted()
|
|
993
|
+
if (native) return native
|
|
994
|
+
const os = await pickViaOsDialog(cwd, signal)
|
|
995
|
+
if (os) return os
|
|
996
|
+
if (nativeFailure) throw new Error('the host directory picker failed: ' + reasonOf(nativeFailure))
|
|
997
|
+
return { path: null, via: 'unavailable' }
|
|
998
|
+
} catch (e) {
|
|
999
|
+
throw new Error(remoteErrorMessage(e))
|
|
1000
|
+
}
|
|
1001
|
+
},
|
|
1002
|
+
async reveal(path) {
|
|
1003
|
+
try {
|
|
1004
|
+
return await coreReveal(path)
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
throw new Error(remoteErrorMessage(e))
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
741
1009
|
}
|
|
1010
|
+
|
|
742
1011
|
Object.defineProperty(multiFolderApi, 'typertRemote', {
|
|
743
1012
|
value: Object.freeze({
|
|
744
1013
|
service: multiFolderApi,
|
|
@@ -770,6 +1039,8 @@ export function apply(ctx) {
|
|
|
770
1039
|
remoteInvocation('browse', ['path']),
|
|
771
1040
|
remoteInvocation('makeDir', ['parent', 'name']),
|
|
772
1041
|
remoteInvocation('listFiles', ['workspace', 'query']),
|
|
1042
|
+
{ ...remoteInvocation('pick', ['cwd']), cancellation: { parameter: 'signal' } },
|
|
1043
|
+
remoteInvocation('reveal', ['path']),
|
|
773
1044
|
],
|
|
774
1045
|
}
|
|
775
1046
|
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#requires -Version 5.1
|
|
2
|
+
<#
|
|
3
|
+
native-picker.ps1 -- a modern Windows folder chooser for non-GUI processes.
|
|
4
|
+
|
|
5
|
+
Why this exists: `System.Windows.Forms.FolderBrowserDialog` is the LEGACY
|
|
6
|
+
tree-view dialog (SHBrowseForFolder) and `System.Windows.Forms.OpenFolderDialog`
|
|
7
|
+
(.NET 8+) is not present in every PowerShell build, so neither can be relied on
|
|
8
|
+
when the goal is the SAME dialog Explorer and other apps show -- the Vista+
|
|
9
|
+
common item dialog (`IFileOpenDialog` with `FOS_PICKFOLDERS`). Shell helpers
|
|
10
|
+
such as Listary hook that dialog, not the legacy tree.
|
|
11
|
+
|
|
12
|
+
The dialog runs on a dedicated STA thread (PowerShell 7 starts MTA, where the
|
|
13
|
+
common dialog is not guaranteed to work), gets a bounded foreground assist
|
|
14
|
+
(the caller is usually a background host process, so a fresh window would
|
|
15
|
+
otherwise open behind the user's windows), and can auto-close after a timeout
|
|
16
|
+
so a request can never be blocked forever by an unanswered dialog.
|
|
17
|
+
|
|
18
|
+
Output: the selected absolute path on stdout, or NOTHING when the user
|
|
19
|
+
cancelled or the timeout dismissed the dialog; both of those exit 0, so the
|
|
20
|
+
caller can tell them from a helper that never ran. A non-zero exit therefore
|
|
21
|
+
means the helper itself failed (an ExecutionPolicy block, a failed Add-Type)
|
|
22
|
+
and must be reported instead of read as a cancellation.
|
|
23
|
+
#>
|
|
24
|
+
[CmdletBinding()]
|
|
25
|
+
param(
|
|
26
|
+
[string]$InitialDirectory = '',
|
|
27
|
+
[int]$TimeoutMs = 0,
|
|
28
|
+
[switch]$DryRun
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
$ErrorActionPreference = 'Stop'
|
|
32
|
+
|
|
33
|
+
$source = @'
|
|
34
|
+
using System;
|
|
35
|
+
using System.Runtime.InteropServices;
|
|
36
|
+
using System.Text;
|
|
37
|
+
using System.Threading;
|
|
38
|
+
|
|
39
|
+
public static class DshFolderPicker
|
|
40
|
+
{
|
|
41
|
+
[ComImport, Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
|
|
42
|
+
private class FileOpenDialogRCW
|
|
43
|
+
{
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
[ComImport, Guid("D57C7288-D4AD-4768-BE02-9D969532D960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
47
|
+
private interface IFileOpenDialog
|
|
48
|
+
{
|
|
49
|
+
[PreserveSig]
|
|
50
|
+
int Show(IntPtr hwndParent);
|
|
51
|
+
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
|
|
52
|
+
void SetFileTypeIndex(uint iFileType);
|
|
53
|
+
void GetFileTypeIndex(out uint piFileType);
|
|
54
|
+
void Advise(IntPtr pfde, out uint pdwCookie);
|
|
55
|
+
void Unadvise(uint dwCookie);
|
|
56
|
+
void SetOptions(uint fos);
|
|
57
|
+
void GetOptions(out uint pfos);
|
|
58
|
+
void SetDefaultFolder(IShellItem psi);
|
|
59
|
+
void SetFolder(IShellItem psi);
|
|
60
|
+
void GetFolder(out IShellItem ppsi);
|
|
61
|
+
void GetCurrentSelection(out IShellItem ppsi);
|
|
62
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
|
63
|
+
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
|
|
64
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
|
|
65
|
+
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
|
|
66
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
|
|
67
|
+
void GetResult(out IShellItem ppsi);
|
|
68
|
+
void AddPlace(IShellItem psi, int fdap);
|
|
69
|
+
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
|
|
70
|
+
void Close([MarshalAs(UnmanagedType.Error)] int hr);
|
|
71
|
+
void SetClientGuid(ref Guid guid);
|
|
72
|
+
void ClearClientData();
|
|
73
|
+
void SetFilter(IntPtr pFilter);
|
|
74
|
+
void GetResults(out IntPtr ppenum);
|
|
75
|
+
void GetSelectedItems(out IntPtr ppsai);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
[ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
79
|
+
private interface IShellItem
|
|
80
|
+
{
|
|
81
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
82
|
+
void GetParent(out IShellItem ppsi);
|
|
83
|
+
void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
|
|
84
|
+
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
|
|
85
|
+
void Compare(IShellItem psi, uint hint, out int piOrder);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
|
|
89
|
+
private static extern void SHCreateItemFromParsingName(
|
|
90
|
+
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
|
|
91
|
+
IntPtr pbc,
|
|
92
|
+
ref Guid riid,
|
|
93
|
+
[MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
|
|
94
|
+
|
|
95
|
+
private delegate bool EnumProc(IntPtr hWnd, IntPtr param);
|
|
96
|
+
|
|
97
|
+
[DllImport("kernel32.dll")]
|
|
98
|
+
private static extern uint GetCurrentProcessId();
|
|
99
|
+
|
|
100
|
+
[DllImport("user32.dll")]
|
|
101
|
+
private static extern bool EnumWindows(EnumProc callback, IntPtr param);
|
|
102
|
+
|
|
103
|
+
[DllImport("user32.dll")]
|
|
104
|
+
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
105
|
+
|
|
106
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
107
|
+
private static extern int GetClassName(IntPtr hWnd, StringBuilder text, int count);
|
|
108
|
+
|
|
109
|
+
[DllImport("user32.dll")]
|
|
110
|
+
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
111
|
+
|
|
112
|
+
[DllImport("user32.dll")]
|
|
113
|
+
private static extern bool BringWindowToTop(IntPtr hWnd);
|
|
114
|
+
|
|
115
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
116
|
+
private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
|
117
|
+
|
|
118
|
+
[DllImport("user32.dll")]
|
|
119
|
+
private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
|
|
120
|
+
|
|
121
|
+
private const uint FOS_PICKFOLDERS = 0x00000020;
|
|
122
|
+
private const uint FOS_FORCEFILESYSTEM = 0x00000040;
|
|
123
|
+
private const uint FOS_PATHMUSTEXIST = 0x00000800;
|
|
124
|
+
private const uint SIGDN_FILESYSPATH = 0x80058000;
|
|
125
|
+
private const uint WM_CLOSE = 0x0010;
|
|
126
|
+
/** DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 / _PER_MONITOR_AWARE. */
|
|
127
|
+
private static readonly IntPtr PER_MONITOR_AWARE_V2 = new IntPtr(-4);
|
|
128
|
+
private static readonly IntPtr PER_MONITOR_AWARE = new IntPtr(-3);
|
|
129
|
+
|
|
130
|
+
/// The folder dialog is a plain #32770 window created BY THIS PROCESS, so it
|
|
131
|
+
/// is found by pid + class rather than by its title: a title is optional
|
|
132
|
+
/// (Windows then supplies its own localized one), and matching on one could
|
|
133
|
+
/// pick up a window that is not ours.
|
|
134
|
+
private static IntPtr FindOwnDialog()
|
|
135
|
+
{
|
|
136
|
+
uint me = GetCurrentProcessId();
|
|
137
|
+
IntPtr found = IntPtr.Zero;
|
|
138
|
+
EnumWindows(delegate(IntPtr hWnd, IntPtr param)
|
|
139
|
+
{
|
|
140
|
+
uint pid;
|
|
141
|
+
GetWindowThreadProcessId(hWnd, out pid);
|
|
142
|
+
if (pid != me) return true;
|
|
143
|
+
StringBuilder cls = new StringBuilder(64);
|
|
144
|
+
GetClassName(hWnd, cls, cls.Capacity);
|
|
145
|
+
if (cls.ToString() != "#32770") return true;
|
|
146
|
+
found = hWnd;
|
|
147
|
+
return false;
|
|
148
|
+
}, IntPtr.Zero);
|
|
149
|
+
return found;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public static string Pick(string initial, int timeoutMs)
|
|
153
|
+
{
|
|
154
|
+
string result = null;
|
|
155
|
+
Exception failure = null;
|
|
156
|
+
Thread thread = new Thread(delegate()
|
|
157
|
+
{
|
|
158
|
+
IntPtr previous = IntPtr.Zero;
|
|
159
|
+
try
|
|
160
|
+
{
|
|
161
|
+
// Render at the monitor's REAL DPI. The PowerShell host is
|
|
162
|
+
// DPI-UNAWARE, so an untouched dialog is drawn at 96 DPI and then
|
|
163
|
+
// BITMAP-STRETCHED by Windows to the display scaling (150% on the
|
|
164
|
+
// machine this was written on) — which is exactly the soft, fuzzy
|
|
165
|
+
// text users notice. Thread-level awareness is the fix that always
|
|
166
|
+
// applies: a process manifest may already have fixed the PROCESS
|
|
167
|
+
// context (SetProcessDpiAwarenessContext would then be refused), but
|
|
168
|
+
// a thread context may be set at any time, and the dialog is created
|
|
169
|
+
// on this thread.
|
|
170
|
+
previous = SetThreadDpiAwarenessContext(PER_MONITOR_AWARE_V2);
|
|
171
|
+
if (previous == IntPtr.Zero) previous = SetThreadDpiAwarenessContext(PER_MONITOR_AWARE);
|
|
172
|
+
}
|
|
173
|
+
catch
|
|
174
|
+
{
|
|
175
|
+
// A Windows build without the thread-scoped DPI API still shows the
|
|
176
|
+
// dialog, only bitmap-stretched at the process DPI — not worth
|
|
177
|
+
// failing the pick over, and an exception here would have escaped
|
|
178
|
+
// this thread and killed the helper outright.
|
|
179
|
+
}
|
|
180
|
+
try { result = Show(initial, timeoutMs); }
|
|
181
|
+
catch (Exception error) { failure = error; }
|
|
182
|
+
finally
|
|
183
|
+
{
|
|
184
|
+
if (previous != IntPtr.Zero) SetThreadDpiAwarenessContext(previous);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
thread.SetApartmentState(ApartmentState.STA);
|
|
188
|
+
thread.IsBackground = true;
|
|
189
|
+
thread.Start();
|
|
190
|
+
bool finished = timeoutMs > 0
|
|
191
|
+
? thread.Join(timeoutMs + 5000)
|
|
192
|
+
: thread.Join(Timeout.Infinite);
|
|
193
|
+
if (failure != null) throw failure;
|
|
194
|
+
return finished ? result : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private static string Show(string initial, int timeoutMs)
|
|
198
|
+
{
|
|
199
|
+
IFileOpenDialog dialog = (IFileOpenDialog)new FileOpenDialogRCW();
|
|
200
|
+
uint options;
|
|
201
|
+
dialog.GetOptions(out options);
|
|
202
|
+
dialog.SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST);
|
|
203
|
+
if (!String.IsNullOrEmpty(initial))
|
|
204
|
+
{
|
|
205
|
+
try
|
|
206
|
+
{
|
|
207
|
+
Guid iid = typeof(IShellItem).GUID;
|
|
208
|
+
IShellItem folder;
|
|
209
|
+
SHCreateItemFromParsingName(initial, IntPtr.Zero, ref iid, out folder);
|
|
210
|
+
dialog.SetFolder(folder);
|
|
211
|
+
}
|
|
212
|
+
catch
|
|
213
|
+
{
|
|
214
|
+
// An unusable start directory is not worth failing the pick over.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// One watchdog owns both jobs, because they need the same window handle
|
|
218
|
+
// but NOT the same apartment: `IFileDialog::Close` cannot be called on
|
|
219
|
+
// the dialog from another thread (COM refuses the cross-apartment call
|
|
220
|
+
// and the dialog simply stayed on screen until the process died), while
|
|
221
|
+
// the plain Win32 calls below work from any thread. WM_CLOSE is what the
|
|
222
|
+
// dialog's own X button sends, so Show() returns the ordinary cancel
|
|
223
|
+
// HRESULT afterwards.
|
|
224
|
+
// `watchdog` is declared first and assigned second on purpose: a lambda
|
|
225
|
+
// that captures a local must find it definitely assigned when the
|
|
226
|
+
// delegate is created, so the single-statement form does not compile.
|
|
227
|
+
Timer watchdog = null;
|
|
228
|
+
DateTime deadline = timeoutMs > 0 ? DateTime.UtcNow.AddMilliseconds(timeoutMs) : DateTime.MinValue;
|
|
229
|
+
// The foreground assist is BOUNDED: it exists to bring a fresh dialog in
|
|
230
|
+
// front of a user whose host process is in the background, not to fight
|
|
231
|
+
// that user for the foreground for the dialog's whole life. Roughly three
|
|
232
|
+
// seconds of nudges, after which this timer only watches the deadline.
|
|
233
|
+
int assistTicks = 12;
|
|
234
|
+
int windowWaits = 240;
|
|
235
|
+
watchdog = new Timer(delegate(object state)
|
|
236
|
+
{
|
|
237
|
+
IntPtr window = FindOwnDialog();
|
|
238
|
+
if (window == IntPtr.Zero)
|
|
239
|
+
{
|
|
240
|
+
// A dialog that never appears leaves nothing to watch.
|
|
241
|
+
if (--windowWaits <= 0 && watchdog != null) watchdog.Dispose();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (deadline != DateTime.MinValue && DateTime.UtcNow >= deadline)
|
|
245
|
+
{
|
|
246
|
+
PostMessage(window, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
|
|
247
|
+
if (watchdog != null) watchdog.Dispose();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (assistTicks > 0)
|
|
251
|
+
{
|
|
252
|
+
SetForegroundWindow(window);
|
|
253
|
+
BringWindowToTop(window);
|
|
254
|
+
assistTicks--;
|
|
255
|
+
}
|
|
256
|
+
}, null, 250, 250);
|
|
257
|
+
int hr;
|
|
258
|
+
try { hr = dialog.Show(IntPtr.Zero); }
|
|
259
|
+
finally
|
|
260
|
+
{
|
|
261
|
+
if (watchdog != null) watchdog.Dispose();
|
|
262
|
+
}
|
|
263
|
+
if (hr != 0)
|
|
264
|
+
{
|
|
265
|
+
// 0x800704C7 (HRESULT_FROM_WIN32(ERROR_CANCELLED)) is the ordinary
|
|
266
|
+
// dismiss path; every other HRESULT means the dialog could not be
|
|
267
|
+
// shown at all, which the caller needs to hear about.
|
|
268
|
+
if (hr == unchecked((int)0x800704C7)) return null;
|
|
269
|
+
throw new COMException("dsh-native-picker: dialog returned 0x" + hr.ToString("X8"), hr);
|
|
270
|
+
}
|
|
271
|
+
IShellItem item;
|
|
272
|
+
dialog.GetResult(out item);
|
|
273
|
+
string path;
|
|
274
|
+
item.GetDisplayName(SIGDN_FILESYSPATH, out path);
|
|
275
|
+
return path;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
'@
|
|
279
|
+
|
|
280
|
+
if ($DryRun) {
|
|
281
|
+
# Compile only: proves the helper builds in this PowerShell without showing UI.
|
|
282
|
+
if (-not ('DshFolderPicker' -as [type])) { Add-Type -TypeDefinition $source -Language CSharp }
|
|
283
|
+
Write-Output 'compiled'
|
|
284
|
+
exit 0
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (-not ('DshFolderPicker' -as [type])) { Add-Type -TypeDefinition $source -Language CSharp }
|
|
288
|
+
$selected = [DshFolderPicker]::Pick($InitialDirectory, $TimeoutMs)
|
|
289
|
+
if ($selected) { Write-Output $selected }
|
|
290
|
+
exit 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multi-folder",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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.1-alpha.2": "compatible",
|
|
46
|
+
"0.2.1-alpha.1": "compatible",
|
|
45
47
|
"0.2.0-rc.1": "compatible",
|
|
46
48
|
"0.1.7-rc.2": "compatible",
|
|
47
49
|
"0.1.6-alpha.1": "compatible",
|