dsh-folder-path 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cooper Zhuang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,158 @@
1
- # Temporary Holding Version
1
+ # dsh-folder-path
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH(DeepSeek Harness)Web 插件:在输入框里**粘贴一个刚从资源管理器 / Finder 复制来的文件夹**时,
4
+ 不再尝试把它当成附件上传(原版会显示「上传失败,点击重试」),而是把它的**完整路径**作为文本插进输入框。
5
+
6
+ - 复制一个文件夹 → `Ctrl+V`(macOS `Cmd+V`)→ 输入框出现 `D:\项目\数据\2026Q1` / `/Users/me/项目/数据`
7
+ - 复制多个文件夹 → 每行一条
8
+ - 普通文本、图片、真实文件的粘贴**完全不受影响**(插件只在粘贴载荷里出现目录时才接管)
9
+
10
+ ## 它解决的是什么问题
11
+
12
+ 浏览器是沙盒:网页 JavaScript 拿不到本地文件的磁盘路径。把一个文件夹粘进网页时,
13
+ 浏览器交出的是一个 `size: 0`、`type: ''` 的 `File`——字节不可读,因此 DSH 原版的上传链路必然失败;
14
+ 而路径也根本不在粘贴载荷里(`File.name` 只有文件夹名)。
15
+
16
+ 但剪贴板是**进程级**的:运行宿主的那台机器上,剪贴板里此刻装的正是你刚复制的那份文件列表。
17
+ 所以本插件由**宿主半边**去读系统剪贴板的文件列表——Windows 走 CF_HDROP,macOS 走 NSPasteboard 的文件 URL——
18
+ 再把真实全路径交回浏览器半边。
19
+
20
+ ## 平台要求
21
+
22
+ Windows(剪贴板经 PowerShell 5.1 的 `System.Windows.Forms` 读取)或 macOS(系统自带 `osascript`)。
23
+ 其他平台宿主返回 `unsupported-platform`,粘贴行为退回原版。
24
+
25
+ ## 安装
26
+
27
+ ```sh
28
+ dsh plugin --profile <profile> add dsh-folder-path
29
+ ```
30
+
31
+ 把 `<profile>` 换成目标 profile(例如桌面版 `desktop`、`dsh web` 用 `web`)。
32
+
33
+ 也可以从源码目录安装:
34
+
35
+ ```sh
36
+ git clone https://github.com/CooperZhuang/dsh-plugins-cooper-custom.git
37
+ dsh plugin --profile <profile> add ./dsh-plugins-cooper-custom/packages/dsh-folder-path
38
+ ```
39
+
40
+ 安装后刷新页面即可。Desktop Beta 2.0.18-beta.1(内核 0.2.1-rc.2)起新增的 bundle 行通常能热挂载,
41
+ 宿主不必重启;**更早的版本必须重启 `dsh web`**,不确定时重启一次总是安全的。
42
+
43
+ 验证是否挂上:`dsh --profile <profile> --dump-config` 里应出现 `# == dsh-folder-path` 层。
44
+
45
+ ### 卸载
46
+
47
+ ```sh
48
+ dsh plugin --profile <profile> remove dsh-folder-path
49
+ ```
50
+
51
+ 删除后重启宿主一次。
52
+
53
+ ## 兼容性
54
+
55
+ 本包声明的兼容区间是 `engines.dsh: ^0.2.1-rc.2 || ^0.2.2-alpha.2`(Node.js `>=24`)。
56
+ 已在 **DSH 0.2.1-rc.2**(npm `@deepseek-ai/dsh` 0.2.1-rc.2)上逐项核对过安装、组合、宿主路由与浏览器半边;
57
+ 内核接口一旦变化,最坏结果是插件不再接管粘贴、行为退回原版,而不会破坏原有的粘贴/上传链路。
58
+ 本插件只用到内核的这几处:
59
+
60
+ | 内核接口 | 用途 |
61
+ |---|---|
62
+ | `webServer` 服务:`ctx.inject(['webServer'])` + `ctx.webServer.register({ kind: 'exact', … })` | 宿主半注册那条精确路由 |
63
+ | `ctx.effect` / `ctx.logger` | 路由的挂载与清理、剪贴板探测失败时的告警 |
64
+ | 插件入口的 `name` / `apply(ctx, config)` 形状 | 宿主半的加载方式 |
65
+ | `package.json` 的 `dsh.bundle.patch` 与 `dsh.client.platform` | bundle 补丁层与浏览器半的模块登记 |
66
+ | `exports['./client']` + `window.__ModuleLoader__.load({ id, factory })` | 浏览器半的加载方式 |
67
+
68
+ 插件**不涉及子代理**,所以内核 0.2.1-rc.2 的子代理约定(`startActivation({provider, request})`、Agent Preset)
69
+ 不改变它的任何行为。`test/manifest.test.js` 与 `test/client.test.js` 把上面这份清单钉在测试里。
70
+
71
+ ## 使用
72
+
73
+ 1. 在资源管理器(Windows)或 Finder(macOS)里选中一个或多个文件夹,`Ctrl+C`(macOS `Cmd+C`)
74
+ 2. 回到 DSH Web 输入框,`Ctrl+V`(macOS `Cmd+V`)
75
+ 3. 路径作为文本出现在草稿里;解析失败时右下角出现一条提示,草稿不动
76
+
77
+ ## 工作原理
78
+
79
+ | 半边 | 位置 | 职责 |
80
+ |---|---|---|
81
+ | Host 半 `lib/index.js` | 宿主进程(`webServer` 服务) | 精确路由 `POST /dsh-folder-path/api/v1/clipboard-paths`:读系统剪贴板的文件列表(Windows CF_HDROP / macOS NSPasteboard)、按浏览器报来的文件夹名匹配、返回真实全路径 |
82
+ | Client 半 `lib/client.js` | 浏览器(`__ModuleLoader__` bundle) | document 捕获阶段拦截 `paste`,只有载荷里含**目录**时才接管;向宿主取路径,再把文本按 DSH 自己的粘贴通路插进 composer |
83
+
84
+ 插入不是直接改 DOM:客户端补发一个只带 `text/plain` 的合成 `paste` 事件,
85
+ 走的是 DSH 编辑器**原生的文本粘贴命令**(草稿状态、命令菜单、撤销历史、已有 chip 都不受影响);
86
+ 引擎不允许构造带数据的 `ClipboardEvent` 时,退化为 `document.execCommand('insertText')`。
87
+
88
+ 宿主按平台读剪贴板:Windows 用 PowerShell(`System.Windows.Forms.Clipboard`,输出 **base64(UTF-8 JSON)**,
89
+ 中文/空格路径不会被重定向进程的代码页破坏);macOS 用 `osascript -l JavaScript` 读 NSPasteboard 的
90
+ `public.file-url` 条目,并合并 Finder 的旧格式 `NSFilenamesPboardType` 列表,避免多选文件夹被折叠成一条。
91
+ 目录还是文件由宿主进程自己 `stat` 判断。同一时刻的多次粘贴会合并成一次探测。
92
+
93
+ ## 配置
94
+
95
+ 插件的配置写在 profile 的补丁层里(`~/.dsh/profiles/web/cordis.patch.yml` 或本包自带的
96
+ `cordis.patch.yml` 会被 `dsh plugin add` 接入),全部可选:
97
+
98
+ ```yaml
99
+ - insert:
100
+ - id: folder-path
101
+ name: dsh-folder-path
102
+ config:
103
+ enabled: true # false = 宿主半边空转(客户端因此不再接管,行为退回原版)
104
+ timeoutMs: 5000 # 单次剪贴板探测预算
105
+ powershell: powershell.exe # 也可用环境变量 DSH_FOLDER_PATH_POWERSHELL 覆盖
106
+ osascript: /usr/bin/osascript # 也可用环境变量 DSH_FOLDER_PATH_OSASCRIPT 覆盖
107
+ allowNonLoopback: false # true = 允许远程浏览器读取宿主机剪贴板(见下)
108
+ ```
109
+
110
+ ## 安全边界
111
+
112
+ - **只读**:路由只读剪贴板,从不打开、复制或上传文件
113
+ - **只接受环回**:默认拒绝非环回对端(`remote-not-supported`)——否则远程浏览器会被喂**宿主机**的路径
114
+ - **同源**:带 `Origin` 的请求必须与 `Host` 一致,否则 403
115
+ - **不猜路径**:宿主报回的文件夹名必须与剪贴板条目对上;对不上就明确失败,绝不返回一个"看起来像"的路径
116
+ - **不缓存**:响应 `cache-control: no-store`
117
+
118
+ ## 已知限制
119
+
120
+ - **只支持 Windows 与 macOS**。其它平台宿主返回 `unsupported-platform`,客户端给出提示,行为退回原版。
121
+ - **只处理粘贴,不处理拖放**。浏览器拖放载荷里同样没有磁盘路径,本插件不猜;把文件夹拖进窗口仍是原版行为。
122
+ - **远程访问时读不到你的剪贴板**。通过局域网 IP、端口转发或跳板机访问 `dsh web` 时,剪贴板属于**运行宿主的那台机器**。
123
+ 默认直接拒绝;`allowNonLoopback: true` 会读宿主机的剪贴板(只有"浏览器和宿主在同一台机器、但用局域网 IP 打开"时才该打开它)。
124
+ - Windows 依赖 PowerShell 5.1 的 `System.Windows.Forms`;被安全策略挡住时返回 `clipboard-error` 并提示。
125
+ - macOS 依赖系统自带的 `osascript`(读粘贴板不需要 TCC 授权,也不会弹「允许访问剪贴板」);
126
+ 只认标准文件复制格式(`public.file-url` / `NSFilenamesPboardType`),自造私有剪贴板格式的 App 读不到。
127
+
128
+ ## 开发
129
+
130
+ ```sh
131
+ npm run build # client/index.js -> lib/client.js(把浏览器半边包成 __ModuleLoader__ bundle)
132
+ npm test # 语法检查 + 宿主路由 / 浏览器半契约 / 清单契约测试(node --test,无需 DSH 服务器)
133
+ ```
134
+
135
+ 浏览器侧调试钩子(devtools 控制台):
136
+
137
+ ```js
138
+ await __dshFolderPath.probe(['文件夹名']) // 直接问宿主要路径
139
+ __dshFolderPath.insert(document.querySelector('[contenteditable]'), '文本') // 直接试插入
140
+ ```
141
+
142
+ 改**浏览器半边**后:`npm run build`,然后刷新页面即可,**不必重启宿主**——
143
+ `dsh-client-hmr` 每 500 ms 轮询各客户端产物,重建后重算 revision 并推给页面。
144
+ 只有**新增**客户端模块才需要重启宿主(启动时的扫描决定「有哪些客户端模块」)。
145
+
146
+ 改**宿主半边**后:必须**重启宿主进程**(宿主走 Node 的 ESM 缓存,运行中的那份代码不会跟着变)。
147
+ Desktop 版要把应用**完全退出**再打开——关窗口不算退出。
148
+
149
+ ## 致谢
150
+
151
+ 架构参考了同类的 macOS 插件 [jhuanxx44/dsh-paste-path](https://github.com/jhuanxx44/dsh-paste-path)
152
+ (Finder 复制 → 宿主读粘贴板 → Ctrl+V 插路径):环回/同源校验、宿主侧读取系统剪贴板、
153
+ "宿主属于运行 dsh 的那台机器"这条边界都沿用它的判断;本插件把同一思路落到 Windows 剪贴板,
154
+ 并把触发方式改为**粘贴事件驱动的目录识别**(Windows 上无粘贴板变更计数可供廉价轮询)。
155
+
156
+ ## License
157
+
158
+ MIT
package/README_EN.md ADDED
@@ -0,0 +1,179 @@
1
+ # dsh-folder-path
2
+
3
+ A DeepSeek Harness (DSH) Web plugin: paste a **folder** you just copied in Windows Explorer or macOS
4
+ Finder into the composer (`Ctrl+V` / `Cmd+V`) and get its **absolute path as text**, instead of the
5
+ stock "upload failed" dead end.
6
+
7
+ - Copy one folder → `Ctrl+V` (`Cmd+V` on macOS) → the draft gets `D:\work\data\2026Q1` or `/Users/me/work/data`
8
+ - Copy several folders → one path per line
9
+ - Ordinary text, images and real files paste exactly as before (the plugin only takes over when the
10
+ paste payload actually contains a directory)
11
+
12
+ ## The problem it solves
13
+
14
+ The browser is a sandbox: page JavaScript never sees a local file's disk path. Pasting a folder hands
15
+ the page a `File` with `size: 0` and `type: ''` — its bytes cannot be read, so DSH's upload path must
16
+ fail — and the path itself is not in the payload at all (`File.name` is just the basename).
17
+
18
+ The clipboard, however, is process-wide: on the machine running the host, it holds exactly the file
19
+ list you just copied. So the **host half** reads the platform clipboard's file list — CF_HDROP on
20
+ Windows, NSPasteboard file URLs on macOS — and hands the real absolute paths back to the browser half.
21
+
22
+ ## Requirements
23
+
24
+ Windows (the clipboard is read through PowerShell 5.1 `System.Windows.Forms`) or macOS (system
25
+ `osascript`). Other platforms answer `unsupported-platform` and stock paste behaviour applies.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ dsh plugin --profile <profile> add dsh-folder-path
31
+ ```
32
+
33
+ Replace `<profile>` with the target profile name (for example `desktop` for the desktop app, `web` for `dsh web`).
34
+
35
+ From a source checkout:
36
+
37
+ ```sh
38
+ git clone https://github.com/CooperZhuang/dsh-plugins-cooper-custom.git
39
+ dsh plugin --profile <profile> add ./dsh-plugins-cooper-custom/packages/dsh-folder-path
40
+ ```
41
+
42
+ Then refresh the page. Since Desktop Beta 2.0.18-beta.1 (core 0.2.1-rc.2) a newly added bundle row
43
+ is usually hot-mounted, so the host needs no restart; **earlier versions require restarting
44
+ `dsh web`**. Restarting once is always safe when in doubt.
45
+
46
+ Check it mounted: `dsh --profile <profile> --dump-config` should show a `# == dsh-folder-path` layer.
47
+
48
+ ### Uninstall
49
+
50
+ ```sh
51
+ dsh plugin --profile <profile> remove dsh-folder-path
52
+ ```
53
+
54
+ Restart the host once afterwards.
55
+
56
+ ## Compatibility
57
+
58
+ This package declares `engines.dsh: ^0.2.1-rc.2 || ^0.2.2-alpha.2` and Node.js `>=24`. It was checked
59
+ item by item against **DSH 0.2.1-rc.2** (npm `@deepseek-ai/dsh` 0.2.1-rc.2): installing, composing,
60
+ the host route and the browser half all work as before. If a kernel interface changes, the worst case is
61
+ that the plugin stops taking over folder pastes and behaviour falls back to stock — the original
62
+ paste/upload path is never broken. The plugin touches exactly this much of the kernel:
63
+
64
+ | Kernel interface | Used for |
65
+ |---|---|
66
+ | the `webServer` service: `ctx.inject(['webServer'])` + `ctx.webServer.register({ kind: 'exact', … })` | the host half registers its exact route |
67
+ | `ctx.effect` / `ctx.logger` | mounting and cleaning up the route, warning on a failed clipboard probe |
68
+ | the plugin entry's `name` / `apply(ctx, config)` shape | how the host half is loaded |
69
+ | `package.json`'s `dsh.bundle.patch` and `dsh.client.platform` | the bundle patch layer and the browser half's module registration |
70
+ | `exports['./client']` + `window.__ModuleLoader__.load({ id, factory })` | how the browser half is loaded |
71
+
72
+ The plugin involves **no subagents**, so the kernel 0.2.1-rc.2 subagent convention
73
+ (`startActivation({provider, request})`, Agent Presets) changes nothing for it. `test/manifest.test.js`
74
+ and `test/client.test.js` pin the list above in the test suite.
75
+
76
+ ## Use
77
+
78
+ 1. Select one or more folders in Explorer / Finder and press `Ctrl+C` (`Cmd+C` on macOS)
79
+ 2. Return to the DSH Web composer and press `Ctrl+V` (`Cmd+V` on macOS)
80
+ 3. The paths appear in the draft; when the host cannot resolve them, a notice appears and the draft is
81
+ left untouched
82
+
83
+ ## How it works
84
+
85
+ | Half | Where | Job |
86
+ |---|---|---|
87
+ | Host `lib/index.js` | host process (`webServer`) | exact route `POST /dsh-folder-path/api/v1/clipboard-paths`: read the platform clipboard's file list (CF_HDROP on Windows, NSPasteboard on macOS), match the folder names the browser reported, answer with the real absolute paths |
88
+ | Client `lib/client.js` | browser (`__ModuleLoader__` bundle) | capture-phase `paste` interception, only when the payload contains a directory; asks the host, then inserts the text through DSH's own paste path |
89
+
90
+ Insertion does not poke the DOM: the client re-dispatches a synthetic `paste` carrying only
91
+ `text/plain`, which reaches the editor's **native text-paste command** (draft state, command menu,
92
+ undo history and existing chips all stay intact). Engines that refuse to construct a `ClipboardEvent`
93
+ with clipboard data fall back to `document.execCommand('insertText')`.
94
+
95
+ The host reads the clipboard per platform. Windows uses PowerShell
96
+ (`System.Windows.Forms.Clipboard`) and emits **base64(UTF-8 JSON)**, so paths with Chinese characters
97
+ or spaces survive the redirected process's code page. macOS uses `osascript -l JavaScript` to read the
98
+ NSPasteboard's `public.file-url` entries and merges Finder's legacy `NSFilenamesPboardType` list, so a
99
+ multi-selection cannot collapse into one item; directory versus file is decided by the host with
100
+ `stat`. Concurrent pastes share one probe.
101
+
102
+ ## Configuration
103
+
104
+ Every knob is optional and lives in the profile patch layer (`~/.dsh/profiles/web/cordis.patch.yml`,
105
+ or this package's own `cordis.patch.yml`, which `dsh plugin add` wires in):
106
+
107
+ ```yaml
108
+ - insert:
109
+ - id: folder-path
110
+ name: dsh-folder-path
111
+ config:
112
+ enabled: true # false mounts the host half inert (the client then stops taking over)
113
+ timeoutMs: 5000 # per-probe budget
114
+ powershell: powershell.exe # also settable via DSH_FOLDER_PATH_POWERSHELL
115
+ osascript: /usr/bin/osascript # also settable via DSH_FOLDER_PATH_OSASCRIPT
116
+ allowNonLoopback: false # true = let a remote browser read this host's clipboard
117
+ ```
118
+
119
+ ## Security boundary
120
+
121
+ - **Read-only**: the route only reads the clipboard; files are never opened, copied or uploaded
122
+ - **Loopback only** by default (`remote-not-supported`), so a remote browser is never handed the
123
+ *host machine's* paths
124
+ - **Same-origin**: a request carrying `Origin` must match `Host`, else 403
125
+ - **No guessing**: the reported folder name must match a clipboard entry; otherwise the request fails
126
+ loudly instead of returning a plausible-looking wrong path
127
+ - **No caching**: `cache-control: no-store`
128
+
129
+ ## Known limitations
130
+
131
+ - **Windows and macOS only.** Other platforms answer `unsupported-platform` and the client shows a notice.
132
+ - **Paste only, not drag-and-drop.** A drop payload carries no disk path either; this plugin does not
133
+ guess, so dropping a folder keeps the stock behaviour.
134
+ - **Remote access cannot reach your clipboard.** Over a LAN address, port forward or jump host the
135
+ clipboard belongs to the machine running the host. That is rejected by default;
136
+ `allowNonLoopback: true` reads the host's clipboard instead (only sensible when the browser and the
137
+ host are the same machine reached through a LAN IP).
138
+ - Windows requires PowerShell 5.1 with `System.Windows.Forms`; if policy blocks it the route answers
139
+ `clipboard-error` and the client explains.
140
+ - macOS uses the system `osascript`; reading the pasteboard needs no TCC grant and raises no
141
+ "allow clipboard access" prompt. Only the standard copy flavours (`public.file-url`,
142
+ `NSFilenamesPboardType`) are recognised — an app that puts files on the pasteboard in a private
143
+ format will not be read.
144
+
145
+ ## Development
146
+
147
+ ```sh
148
+ npm run build # client/index.js -> lib/client.js (wraps the browser half as a __ModuleLoader__ bundle)
149
+ npm test # syntax checks + host route / browser-half contract / manifest contract tests
150
+ # (node --test, no DSH server needed)
151
+ ```
152
+
153
+ Browser-side debugging hooks (devtools console):
154
+
155
+ ```js
156
+ await __dshFolderPath.probe(['folder-name']) // ask the host directly
157
+ __dshFolderPath.insert(document.querySelector('[contenteditable]'), 'text')
158
+ ```
159
+
160
+ After changing the **browser half**: `npm run build`, then refresh the page — **no host restart
161
+ needed**. `dsh-client-hmr` polls every client bundle every 500 ms, recomputes the revision after a
162
+ rebuild and pushes it to the page. Only **adding** a client module needs a restart (the startup scan
163
+ decides which client modules exist).
164
+
165
+ After changing the **host half**: you must **restart the host process** (the host goes through Node's
166
+ ESM cache, so the running copy of the code does not change with it). In the Desktop app, quit the app
167
+ completely and reopen it — closing the window is not quitting.
168
+
169
+ ## Credits
170
+
171
+ The architecture follows the macOS plugin [jhuanxx44/dsh-paste-path](https://github.com/jhuanxx44/dsh-paste-path)
172
+ (copy in Finder → host reads the pasteboard → Ctrl+V inserts the path): its loopback/same-origin
173
+ checks, host-side system-clipboard reads and the "the clipboard belongs to the machine running dsh"
174
+ boundary all carry over. This plugin brings the same idea to the Windows clipboard and triggers on
175
+ the **paste event's directory payload** (Windows offers no cheap pasteboard change counter to poll).
176
+
177
+ ## License
178
+
179
+ MIT
@@ -0,0 +1,306 @@
1
+ /**
2
+ * `dsh-folder-path` browser half.
3
+ *
4
+ * Shipped as the package's `./client` bundle: the DSH client module system
5
+ * loads it as a `window.__ModuleLoader__.load({ id, factory })` script, calls
6
+ * `factory(require)`, and mounts the exported plugin (`apply`) into the browser
7
+ * Cordis tree.
8
+ *
9
+ * What it does — one thing, in the capture phase, before the conversation
10
+ * plugin's Lexical paste command can turn the folder into a doomed upload:
11
+ *
12
+ * * a paste is inspected for entries that are *directories*
13
+ * (`webkitGetAsEntry().isDirectory`; the paste payload is a `File` of size 0
14
+ * with no bytes to upload, which is why 上传失败 appears today);
15
+ * * the pasted folder names go to the host half, which reads the host
16
+ * machine's clipboard (Windows file-drop list, macOS NSPasteboard file
17
+ * URLs) and answers with the real full paths;
18
+ * * those paths are inserted into the composer as plain text by re-dispatching
19
+ * a synthetic text/plain paste, so the insertion goes through DSH's own
20
+ * composer path (command menu, draft state, undo history) instead of
21
+ * poking at the DOM.
22
+ *
23
+ * Ordinary pastes — text, images, real files — are never touched: the handler
24
+ * returns before `preventDefault()` unless a directory is present.
25
+ */
26
+
27
+ /** The host route; hand-mirrored from `lib/index.js` (ROUTE_PATH). */
28
+ var ROUTE_PATH = '/dsh-folder-path/api/v1/clipboard-paths'
29
+
30
+ /** Marks the synthetic events this plugin dispatches, so they are not re-read. */
31
+ var SYNTHETIC = '__dshFolderPathSynthetic'
32
+
33
+ /** The composer's editable root, as Lexical renders it. */
34
+ var EDITABLE_SELECTOR = '[contenteditable=""],[contenteditable="true"]'
35
+
36
+ /** How long a failure notice stays on screen. */
37
+ var TOAST_MS = 5000
38
+
39
+ /**
40
+ * Split a paste payload into directories and files.
41
+ *
42
+ * `webkitGetAsEntry()` is the only reliable directory signal. The
43
+ * `size === 0 && type === ''` arm is a fallback for engines without the entry
44
+ * API; it is deliberately not applied when an entry exists, so a genuinely
45
+ * empty file is never mistaken for a folder.
46
+ *
47
+ * @param dataTransfer - the event's clipboard or drag data.
48
+ * @returns directory basenames and the non-directory files.
49
+ */
50
+ function classifyPayload(dataTransfer) {
51
+ var directories = []
52
+ var files = []
53
+ var items = dataTransfer.items ? Array.prototype.slice.call(dataTransfer.items) : []
54
+
55
+ for (var index = 0; index < items.length; index += 1) {
56
+ var item = items[index]
57
+ if (item.kind !== 'file') continue
58
+
59
+ var entry = null
60
+ try {
61
+ entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null
62
+ } catch (error) {
63
+ entry = null
64
+ }
65
+
66
+ if (entry !== null) {
67
+ // The entry API answers both questions. Reading the file of a directory is
68
+ // deliberately skipped: the browser hands over a zero-byte `File` whose
69
+ // bytes cannot be read, which is the upload failure this plugin exists to
70
+ // avoid, so there is nothing to forward for it.
71
+ if (entry.isDirectory === true) {
72
+ if (entry.name) directories.push(entry.name)
73
+ continue
74
+ }
75
+ var entryFile = null
76
+ try {
77
+ entryFile = item.getAsFile()
78
+ } catch (error) {
79
+ entryFile = null
80
+ }
81
+ if (entryFile !== null) files.push(entryFile)
82
+ continue
83
+ }
84
+
85
+ var file = null
86
+ try {
87
+ file = item.getAsFile()
88
+ } catch (error) {
89
+ file = null
90
+ }
91
+ if (file === null) continue
92
+ if (file.size === 0 && file.type === '') {
93
+ // No entry API (a non-Chromium engine): a zero-byte, type-less item is
94
+ // the only directory-shaped signal left to go on.
95
+ if (file.name) directories.push(file.name)
96
+ continue
97
+ }
98
+ files.push(file)
99
+ }
100
+
101
+ return { directories: directories, files: files }
102
+ }
103
+
104
+ /**
105
+ * Find the composer root a paste belongs to.
106
+ *
107
+ * A paste into anything that is not an editable surface is left alone: the
108
+ * plugin swallows events it handles, and swallowing a paste it cannot insert
109
+ * into would silently eat user data.
110
+ *
111
+ * @param target - the event target.
112
+ * @returns the editable root element, or null.
113
+ */
114
+ function editableRootOf(target) {
115
+ var candidates = [target, document.activeElement]
116
+ for (var index = 0; index < candidates.length; index += 1) {
117
+ var candidate = candidates[index]
118
+ if (candidate && typeof candidate.closest === 'function') {
119
+ var root = candidate.closest(EDITABLE_SELECTOR)
120
+ if (root !== null) return root
121
+ }
122
+ }
123
+ return null
124
+ }
125
+
126
+ /**
127
+ * Ask the host half for the full paths behind the pasted folder names.
128
+ * @param names - directory basenames the browser saw.
129
+ * @returns the resolved paths (empty when the host could not answer).
130
+ */
131
+ async function requestPaths(names) {
132
+ var response = await fetch(ROUTE_PATH, {
133
+ method: 'POST',
134
+ headers: { 'content-type': 'application/json' },
135
+ body: JSON.stringify({ names: names }),
136
+ })
137
+ if (!response.ok) return { paths: [], reason: 'http-' + String(response.status) }
138
+ var payload = await response.json()
139
+ if (payload && payload.ok === true && Array.isArray(payload.paths)) {
140
+ return { paths: payload.paths, reason: null }
141
+ }
142
+ return { paths: [], reason: (payload && payload.reason) || 'unknown' }
143
+ }
144
+
145
+ /**
146
+ * Insert plain text through DSH's own paste path.
147
+ *
148
+ * A synthetic `paste` carrying only `text/plain` reaches the same Lexical
149
+ * command a real text paste does, so the draft, command menu and undo history
150
+ * stay consistent. `execCommand` is the fallback for engines that refuse to
151
+ * construct a `ClipboardEvent` with clipboard data.
152
+ *
153
+ * @param root - the composer's editable root.
154
+ * @param text - the text to insert.
155
+ */
156
+ function insertText(root, text) {
157
+ root.focus({ preventScroll: true })
158
+ try {
159
+ var dataTransfer = new DataTransfer()
160
+ dataTransfer.setData('text/plain', text)
161
+ var event = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true })
162
+ event[SYNTHETIC] = true
163
+ root.dispatchEvent(event)
164
+ if (event.defaultPrevented) return
165
+ } catch (error) {
166
+ /* fall through to the DOM insertion below */
167
+ }
168
+ document.execCommand('insertText', false, text)
169
+ }
170
+
171
+ /**
172
+ * Hand non-directory files back to the composer.
173
+ *
174
+ * Copying a folder and files together is legal; the files still belong to the
175
+ * attachment flow, so they are forwarded as one synthetic file paste after the
176
+ * paths are in.
177
+ *
178
+ * @param root - the composer's editable root.
179
+ * @param files - the files taken out of the original paste.
180
+ */
181
+ function forwardFiles(root, files) {
182
+ if (files.length === 0) return
183
+ try {
184
+ var dataTransfer = new DataTransfer()
185
+ for (var index = 0; index < files.length; index += 1) dataTransfer.items.add(files[index])
186
+ var event = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true })
187
+ event[SYNTHETIC] = true
188
+ root.dispatchEvent(event)
189
+ } catch (error) {
190
+ /* the paths are the point; a failed forward must not undo them */
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Failure copy for a host answer.
196
+ * @param reason - the host's reason code.
197
+ * @returns the message to show.
198
+ */
199
+ function failureText(reason) {
200
+ if (reason === 'unsupported-platform') return '当前宿主系统不支持读取剪贴板里的文件夹路径(仅支持 Windows / macOS)'
201
+ if (reason === 'remote-not-supported') return '当前页面不在运行 dsh web 的这台机器上,读不到这台机器的剪贴板'
202
+ if (reason === 'clipboard-error') return '读取剪贴板失败,没能解析文件夹的完整路径'
203
+ if (reason === 'no-match') return '剪贴板里的内容与粘贴的文件夹不一致,没能解析完整路径'
204
+ return '没能解析文件夹的完整路径(宿主未响应)'
205
+ }
206
+
207
+ /**
208
+ * Show a short-lived notice above the composer.
209
+ * @param message - the text to show.
210
+ */
211
+ function toast(message) {
212
+ try {
213
+ var element = document.createElement('div')
214
+ element.textContent = message
215
+ element.setAttribute('data-dsh-folder-path-toast', '')
216
+ element.style.cssText =
217
+ 'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:2147483000;' +
218
+ 'max-width:70vw;padding:8px 14px;border-radius:10px;background:rgba(28,28,30,0.92);color:#fff;' +
219
+ 'font-size:13px;line-height:1.45;box-shadow:0 6px 24px rgba(0,0,0,0.28);pointer-events:none;'
220
+ document.body.appendChild(element)
221
+ setTimeout(function () {
222
+ element.remove()
223
+ }, TOAST_MS)
224
+ } catch (error) {
225
+ /* a missing notice must never break the paste */
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Build the browser-half plugin from the bundle's `require`.
231
+ * @param require - the module system's synchronous require.
232
+ * @returns the plugin's `apply`.
233
+ */
234
+ function createPlugin(require) {
235
+ /**
236
+ * Resolve the pasted folders and put their paths in the composer.
237
+ * @param root - the composer's editable root.
238
+ * @param directories - the pasted directory basenames.
239
+ * @param files - the non-directory files from the same paste.
240
+ */
241
+ async function resolveAndInsert(root, directories, files) {
242
+ var result
243
+ try {
244
+ result = await requestPaths(directories)
245
+ } catch (error) {
246
+ result = { paths: [], reason: 'host-unreachable' }
247
+ }
248
+ if (result.paths.length === 0) {
249
+ toast(failureText(result.reason))
250
+ forwardFiles(root, files)
251
+ return
252
+ }
253
+ if (!root.isConnected) {
254
+ toast('输入框已经关闭,路径没有插入:' + result.paths.join('、'))
255
+ return
256
+ }
257
+ insertText(root, result.paths.join('\n'))
258
+ forwardFiles(root, files)
259
+ }
260
+
261
+ /**
262
+ * Capture-phase paste handler: intercepts directory pastes only.
263
+ * @param event - the paste event.
264
+ */
265
+ function onPaste(event) {
266
+ if (event[SYNTHETIC] === true) return
267
+ if (!event.clipboardData) return
268
+
269
+ var payload = classifyPayload(event.clipboardData)
270
+ if (payload.directories.length === 0) return
271
+
272
+ var root = editableRootOf(event.target)
273
+ if (root === null) return
274
+
275
+ event.preventDefault()
276
+ event.stopPropagation()
277
+ void resolveAndInsert(root, payload.directories, payload.files)
278
+ }
279
+
280
+ /**
281
+ * Mount the listener; the returned plugin is applied by the client runner.
282
+ * @param ctx - browser Cordis context.
283
+ */
284
+ function apply(ctx) {
285
+ document.addEventListener('paste', onPaste, true)
286
+ ctx.effect(
287
+ function () {
288
+ return function () {
289
+ document.removeEventListener('paste', onPaste, true)
290
+ }
291
+ },
292
+ 'dsh-folder-path: paste listener',
293
+ )
294
+
295
+ // Troubleshooting hook: the same resolution the paste path uses, callable
296
+ // from the devtools console (`await __dshFolderPath.probe(['name'])`).
297
+ window.__dshFolderPath = {
298
+ route: ROUTE_PATH,
299
+ probe: requestPaths,
300
+ insert: insertText,
301
+ classify: classifyPayload,
302
+ }
303
+ }
304
+
305
+ return { apply: apply }
306
+ }