dsh-remote 0.5.10 → 0.6.9

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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 dsh-remote contributors
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.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-remote contributors
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
@@ -34,14 +34,21 @@ Real capture (host scrubbed to a placeholder):
34
34
 
35
35
  - **Multi-machine SSH** — save any number of hosts (`host`/`port`/`user` + **private key** or **password**). Passwords are stored locally and never shown back in the UI. Switch with one click in Settings.
36
36
  - **Two-tab workspace picker** (fills the native "Add workspace" flow):
37
- - **本机 / Local** — opens the **native OS folder chooser** over the host, or lets you type a local path → adopted directly as a normal DSH local workspace (local workspaces fully coexist).
38
- - **远程 / Remote** — the picker is a **centered modal** (never squeezed into a narrow sidebar). Pick a **machine** → the path field is **pre-filled with `/`** and live **autocompletes** directories; **selecting a directory immediately lists its next level** (OS/VSCode-style cascade). A **浏览…** floating browser (opaque, height-capped, scrollable, follows symlinks) fills the field without committing — you review, edit, then confirm. On confirm it creates a **real local mirror** (`~/.dsh/remote-workspaces/<host>/<base>-<hash>`) that passes `fs.realpath` → the harness adopts it as a real workspace while dsh-remote keeps it synced over SFTP.
39
- - **Bidirectional SFTP sync** — `rw_sync` (remote → mirror) and `rw_push` (mirror → remote) round-trip your local-mirror edits back to the machine.
40
- - **Model tools** — `rw_info`, `rw_connect`, `rw_pick_workspace`, `rw_list_dir`, `rw_read_file`, `rw_write_file`, `rw_exec`, `rw_sync`, `rw_push`, `rw_disconnect`.
41
- - **Write directly to a remote file** — `rw_write_file` creates or overwrites a remote file (making parent directories), so you don't have to round-trip through a local mirror for a single-file edit.
37
+ - **本机 / Local** — opens the **native OS folder chooser** over the host, or lets you type a local path → adopted directly as a normal DSH local workspace (local workspaces fully coexist). The chooser uses the DSH `directoryPicker` service when available and otherwise falls back to the plugin's own native picker (macOS `osascript` / Linux `zenity`→`kdialog`) — so it works even when the framework service isn't registered on the desktop boot path.
38
+ - **远程 / Remote** — the picker is a **centered modal** (never squeezed into a narrow sidebar). Pick a **machine** → the path field is **pre-filled with `/`** and live **autocompletes** directories; **selecting a directory immediately lists its next level** (OS/VSCode-style cascade). A **浏览…** floating browser (opaque, height-capped, scrollable, follows symlinks) fills the field without committing — you review, edit, then confirm. On confirm it creates a **real local mirror** under `$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<base>` (a short path-hash is appended only when a different remote path already took the same basename) that passes `fs.realpath` → the harness adopts it as a real workspace while dsh-remote keeps it synced over SFTP. The chosen workspace is persisted on the machine, so it survives restarts.
39
+ - **Bidirectional SFTP sync** — `rw_sync` (remote → mirror) and `rw_push` (mirror → remote) round-trip your local-mirror edits back to the machine. Both are **incremental**: files whose size + mtime already match are skipped, and a per-file size cap prevents accidental big-binary downloads. Directory sweeps run with bounded parallelism.
40
+ - **Model tools** — `rw_info`, `rw_connect`, `rw_pick_workspace`, `rw_list_dir`, `rw_read_file`, `rw_write_file`, `rw_exec` (runs in the current workspace by default, or `cwd=<path>`), `rw_search` (portable recursive grep), `rw_download`, `rw_upload`, `rw_sync`, `rw_push`, `rw_disconnect`.
41
+ - **Write directly to a remote file** — `rw_write_file` creates or overwrites a remote file (making parent directories), so you don't have to round-trip through a local mirror for a single-file edit. `rw_download` / `rw_upload` move a single file either way when you need the real bytes.
42
42
  - **Connection health** — a **「测试连接」 test-connection** button in the Settings page validates host/user/key/password before you save a machine.
43
43
  - The active `user@host:/path` is injected into every system prompt so the agent knows its working root.
44
44
  - **No official `dsh-workspace` core is modified** — everything is delivered as a normal plugin (directory-flow holes filled by the client half at `priority -100`).
45
+ - **Cross-platform remotes** — commands use portable POSIX forms (`ls -la`, `sed -n`, `find … -exec grep`), so the same plugin works against macOS/BSD as well as GNU/Linux hosts.
46
+ - **Host-key verification (TOFU)** — every SSH connect verifies the host key
47
+ (`hostKeyMode: accept-new`): first connect records it, a later CHANGE is rejected
48
+ as a possible man-in-the-middle. `verify` also refuses hosts never seen before;
49
+ `off` disables it. Stored at `$DSH_HOME/remote-workspaces/known_hosts.json`; reset
50
+ with `/remote forget-key`.
51
+ - **Data lives under the harness home** — machines + mirrors follow `$DSH_HOME` (the desktop app sets it to its own `userData/harness`); pre-0.6 data under `~/.dsh/remote-workspaces` is migrated automatically on first run.
45
52
 
46
53
  ## Install
47
54
 
@@ -60,7 +67,8 @@ dsh plugin add dsh-remote # add the bundle
60
67
  3. **Work with the agent** — treat it like any workspace:
61
68
  - `rw_list_dir(path?)`/`rw_read_file` — inspect remote files
62
69
  - `rw_write_file(path, content)` — create or overwrite a remote file directly
63
- - `rw_exec(command)` — run remote shell commands
70
+ - `rw_search(pattern, path?)` — grep remote files
71
+ - `rw_exec(command, cwd?)` — run remote shell commands (defaults to the workspace dir)
64
72
  - `rw_sync` / `rw_push` — pull/push the local mirror to and from the remote
65
73
 
66
74
  ## CLI defaults (optional)
@@ -107,6 +115,38 @@ npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # back to re
107
115
 
108
116
  After a successful start, `Settings → 远程工作区` appears and the "Add workspace" flow gains the 本机 / 远程 tabs (screenshots above).
109
117
 
118
+ ## Development (sandbox, not product)
119
+
120
+ Iterate **in the sandbox**, never by hand-editing a product profile — the
121
+ product profile is re-managed by the plugin manager and reverts hand-deployed
122
+ files on reinstall. Use the helper script:
123
+
124
+ ```bash
125
+ scripts/dev-run.sh --restart # start / restart the isolated sandbox
126
+ scripts/dev-run.sh --stop # stop it
127
+ scripts/dev-run.sh --status # is it running?
128
+ ```
129
+
130
+ - Runs its own DSH instance (`dev-harness/harness` inside this repo) with the
131
+ plugin copied in from `lib/` — it boots through the same `bin.js web --patch`
132
+ path as the desktop app, so the sandbox reproduces the product boot behavior.
133
+ - The sandbox web UI serves on `http://127.0.0.1:50599` and the plugin routes
134
+ are live immediately (e.g. `GET /dsh-remote/machines`).
135
+ - **Host-half changes** (`lib/index.js`) need a sandbox restart (`--restart`);
136
+ **client-half changes** (`lib/client.js`) need a page refresh.
137
+ - Node ESM resolves dependencies from the importing file's real path, so the
138
+ script **copies** `lib/` (hardlink copy, `cp -al`) into the sandbox profile
139
+ instead of symlinking — a symlink breaks `@deepseek-ai/*` resolution.
140
+ - Run `scripts/check.mjs` (static framework-constraint gate: command-name
141
+ regex, …) before every commit; `scripts/boot-smoke.sh` boots an isolated
142
+ instance to prove the plugin still starts.
143
+ - Full rules live in `scripts/dev-standards.md` (command names, cordis service
144
+ access via `ctx.get()` only, optional framework services may never register,
145
+ verify third-party callback contracts against the real runtime, …).
146
+
147
+ Deploying to a product profile is a separate, explicit action (`./sync.sh`)
148
+ and should be done only when you intend to release.
149
+
110
150
  ## Configuration
111
151
 
112
152
  | Key | Type | Default | Meaning |
@@ -115,10 +155,12 @@ After a successful start, `Settings → 远程工作区` appears and the "Add wo
115
155
  | `port` | int | `22` | default SSH port |
116
156
  | `username` | string | `''` | default SSH user |
117
157
  | `password` | string | `''` | default SSH password (non-empty overrides key) |
118
- | `privateKeyPath` | string | `''` | private key path (`~/.ssh/id_rsa` when empty) |
158
+ | `privateKeyPath` | string | `''` | private key path (used only when explicitly provided) |
119
159
  | `workspace` | string | `''` | default remote workspace path |
120
160
  | `commandTimeoutMs` | int | 20000 | per remote command timeout |
121
161
  | `connectTimeoutMs` | int | 15000 | SSH connect timeout |
162
+ | `maxFileBytes` | int | 52428800 | skip mirroring files larger than this (0 = no cap) |
163
+ | `hostKeyMode` | string | `accept-new` | host-key policy: `accept-new` (TOFU), `verify` (reject unknown hosts), `off` (skip) |
122
164
 
123
165
  ## Safety
124
166
 
package/README.zh.md CHANGED
@@ -34,13 +34,16 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
34
34
 
35
35
  - **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。
36
36
  - **双 tab 工作区选择器**(填充原生「Add workspace」流程):
37
- - **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。
38
- - **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → 路径框**自动预填 `/`** 并**实时补全**目录;**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」浮层**(不透明、定高、内部滚动、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定。确定会创建**真实本地镜像**(`~/.dsh/remote-workspaces/<host>/<basename>-<hash>`,`fs.realpath` 通过)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。
39
- - **双向 SFTP 同步** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。
40
- - **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_write_file`、`rw_exec`、`rw_sync`、`rw_push`、`rw_disconnect`。
41
- - **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步。
37
+ - **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。优先用 DSH 的 `directoryPicker` 服务,服务缺失时**回退到插件自持的原生选择器**(macOS `osascript` / Linux `zenity`→`kdialog`)——桌面启动路径上框架服务不注册也能用。
38
+ - **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → 路径框**自动预填 `/`** 并**实时补全**目录;**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」浮层**(不透明、定高、内部滚动、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定。确定会创建**真实本地镜像**(`$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<basename>`;仅当同主机上**别的远端路径**已占用同名 basename 时才追加短路径 hash)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。所选工作区会**持久化到该机器**,重启不丢。
39
+ - **双向 SFTP 同步(增量)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。两者都会**跳过 size+mtime 未变化的文件**,并有**单文件大小上限**防误拉大二进制;目录遍历带**有界并发**。
40
+ - **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_write_file`、`rw_exec`(默认在工作区目录执行,可传 `cwd=`)、`rw_search`(可移植递归 grep)、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_disconnect`。
41
+ - **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步;`rw_download` / `rw_upload` 可单文件双向取真字节。
42
42
  - **连接体检** —— 设置页提供「测试连接」按钮,在保存机器之前先验证 host/user/密码/私钥是否可用。
43
43
  - 当前 `user@host:/path` 会注入每次系统提示,让 Agent 明确自己的工作根。
44
+ - **远端跨平台** —— 命令全部用可移植 POSIX 写法(`ls -la` / `sed -n` / `find … -exec grep`),macOS/BSD 与 GNU/Linux 远端都能用。
45
+ - **主机指纹校验(TOFU)** —— 每次 SSH 连接都校验主机密钥(`hostKeyMode: accept-new`):首次连接记录,之后**密钥一旦变化立即拒绝**(防中间人)。`verify` 模式还会拒绝从未见过的机器;`off` 关闭校验。指纹存于 `$DSH_HOME/remote-workspaces/known_hosts.json`;误判可用 `/remote forget-key` 重置。
46
+ - **数据跟随 Harness 根目录** —— 机器清单与镜像放在 `$DSH_HOME/remote-workspaces`(桌面版即 `userData/harness` 下);0.6 之前落在 `~/.dsh/remote-workspaces` 的数据**首次启动自动迁移**,不丢失。
44
47
  - **不改任何 `dsh-workspace` 官方代码** —— 全部作为普通插件实现(client 半以 `priority -100` 填充 directory-flow holes)。
45
48
 
46
49
  ## 安装
@@ -60,7 +63,8 @@ dsh plugin add dsh-remote # 添加 bundle
60
63
  3. **让 Agent 工作** —— 把它当普通工作区用:
61
64
  - `rw_list_dir(path?)` / `rw_read_file` —— 查看远程文件
62
65
  - `rw_write_file(path, content)` —— 直接创建或覆盖远程文件
63
- - `rw_exec(command)` —— 在远程执行命令
66
+ - `rw_search(pattern, path?)` —— 远程 grep
67
+ - `rw_exec(command, cwd?)` —— 在远程执行命令(默认在工作区目录)
64
68
  - `rw_sync` / `rw_push` —— 拉取/推送本地镜像 <-> 远程
65
69
 
66
70
  ## 可选:CLI 默认机
@@ -108,6 +112,32 @@ npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # 恢复用
108
112
 
109
113
  启动成功后,设置 →「远程工作区」会出现;「Add workspace」流程会带「本机 / 远程」两个 tab(见上方效果图)。
110
114
 
115
+ ## 开发(沙箱优先,勿改产品)
116
+
117
+ 迭代**一律在沙箱**里做,绝不手工改产品 profile——产品 profile 由插件管理器重管,
118
+ 重装会把手工部署的文件还原掉。用仓库内的辅助脚本:
119
+
120
+ ```bash
121
+ scripts/dev-run.sh --restart # 启动 / 重启隔离沙箱
122
+ scripts/dev-run.sh --stop # 停止
123
+ scripts/dev-run.sh --status # 是否在运行
124
+ ```
125
+
126
+ - 自带一套独立 DSH 实例(仓库内 `dev-harness/harness`),把 `lib/` 复制进沙箱
127
+ profile——与桌面 App 走同一条 `bin.js web --patch` 启动路径,沙箱即产品启动行为。
128
+ - 沙箱 web UI 在 `http://127.0.0.1:50599`,插件路由立即可见(如
129
+ `GET /dsh-remote/machines`)。
130
+ - **宿主半改动**(`lib/index.js`)需重启沙箱(`--restart`);**客户端半改动**
131
+ (`lib/client.js`)只需刷新页面。
132
+ - Node ESM 按导入文件的真实路径解析依赖,脚本用**硬链接拷贝**(`cp -al`)把
133
+ `lib/` 复制进沙箱 profile,而不是软链——软链会破坏 `@deepseek-ai/*` 的解析。
134
+ - 每次提交前跑 `scripts/check.mjs`(静态框架约束闸门:命令名正则等);
135
+ `scripts/boot-smoke.sh` 用隔离实例证明插件仍能启动。
136
+ - 完整规则见 `scripts/dev-standards.md`(命令名、cordis 服务只许 `ctx.get()`、
137
+ 可选框架服务可能压根不注册、三方库回调契约以真实运行为准等)。
138
+
139
+ 部署到产品 profile 是单独的受控动作(`./sync.sh`),只在确定要发布时做。
140
+
111
141
  ## 配置
112
142
 
113
143
  | 键 | 类型 | 默认 | 说明 |
@@ -116,10 +146,12 @@ npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # 恢复用
116
146
  | `port` | int | `22` | 默认 SSH 端口 |
117
147
  | `username` | string | `''` | 默认 SSH 用户 |
118
148
  | `password` | string | `''` | 默认 SSH 密码(非空覆盖 key) |
119
- | `privateKeyPath` | string | `''` | 私钥路径(空=`~/.ssh/id_rsa`) |
149
+ | `privateKeyPath` | string | `''` | 私钥路径(仅在显式提供时使用) |
120
150
  | `workspace` | string | `''` | 默认远程工作区路径 |
121
151
  | `commandTimeoutMs` | int | 20000 | 单条远程命令超时 |
122
152
  | `connectTimeoutMs` | int | 15000 | SSH 连接超时 |
153
+ | `maxFileBytes` | int | 52428800 | 镜像同步时跳过超过该大小的文件(0=不设上限) |
154
+ | `hostKeyMode` | string | `accept-new` | 主机指纹策略:`accept-new`(首次信任)、`verify`(拒绝未知主机)、`off`(跳过校验) |
123
155
 
124
156
  ## 安全提醒
125
157
 
package/cordis.patch.yml CHANGED
@@ -1,6 +1,6 @@
1
- # dsh-remote bundle patch: inserts this plugin into a profile's layer stack.
2
- # Applied when a profile lists this bundle; rows reference the package by name so
3
- # Node resolution finds the installed code.
4
- - insert:
5
- - id: dsh-remote
6
- name: 'dsh-remote'
1
+ # dsh-remote bundle patch: inserts this plugin into a profile's layer stack.
2
+ # Applied when a profile lists this bundle; rows reference the package by name so
3
+ # Node resolution finds the installed code.
4
+ - insert:
5
+ - id: dsh-remote
6
+ name: 'dsh-remote'
package/lib/client.js CHANGED
@@ -200,14 +200,14 @@ window.__ModuleLoader__.load({
200
200
  const [suggestOpen, setSuggestOpen] = React.useState(false)
201
201
  const suggestTimer = React.useRef(null)
202
202
  const suggestRef = React.useRef(null)
203
- // 点击候选路径下拉外部的空白处时,收起自动补全列表
203
+ // 点击自动补全下拉外部的空白处时收起补全列表(上游 0.5.10)
204
204
  React.useEffect(() => {
205
205
  if (!suggestOpen) return
206
206
  const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
207
207
  document.addEventListener('mousedown', onDown)
208
208
  return () => document.removeEventListener('mousedown', onDown)
209
209
  }, [suggestOpen])
210
- // 机器下拉:点外部任意处关闭,避免候选层常驻遮挡下方按钮
210
+ // 机器下拉(自绘 dropdown):点外部任意处关闭,避免候选层常驻遮挡下方按钮(上游 0.5.8)
211
211
  const [mOpen, setMOpen] = React.useState(false)
212
212
  const ddRef = React.useRef(null)
213
213
  React.useEffect(() => {
@@ -273,7 +273,8 @@ window.__ModuleLoader__.load({
273
273
  if (busy || loading) return
274
274
  const last = levels && levels.length ? levels[levels.length - 1] : null
275
275
  const base = last && last.path ? last.path : ''
276
- const next = base === '/' ? '/' + name : (base ? base : '') + '/' + name
276
+ const sep = base.includes('\\') ? '\\' : '/'
277
+ const next = base === '/' ? '/' + name : (base ? base + sep + name : name)
277
278
  loadLevels(machineId, next, (levels ? levels.length : 0))
278
279
  }
279
280
 
@@ -284,8 +285,11 @@ window.__ModuleLoader__.load({
284
285
  if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
285
286
  const t = String(raw || '').trim()
286
287
  if (!t) { setSuggest([]); setSuggestOpen(false); return }
287
- const slash = t.lastIndexOf('/')
288
- const parent = slash <= 0 ? '/' : t.slice(0, slash)
288
+ // Windows drive path (D:\...) uses backslashes; POSIX uses '/'.
289
+ const isWin = t.includes('\\')
290
+ const sep = isWin ? '\\' : '/'
291
+ const slash = t.lastIndexOf(sep)
292
+ const parent = slash <= 0 ? (isWin ? t.match(/^[a-zA-Z]:\\?/) ? t : '' : '/') : t.slice(0, slash)
289
293
  const lastSeg = slash < 0 ? t : t.slice(slash + 1)
290
294
  api('POST', '/dsh-remote/current', { id }).catch(() => {})
291
295
  .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
@@ -295,7 +299,7 @@ window.__ModuleLoader__.load({
295
299
  : parseLs(res && res.text)
296
300
  const base = parent === '/' ? '/' : parent
297
301
  const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
298
- .slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + '/' + it.name))
302
+ .slice(0, 40).map((it) => (base === '/' || !base ? sep + it.name : base + sep + it.name))
299
303
  setSuggest(matches)
300
304
  setSuggestOpen(!!matches.length)
301
305
  }).catch(() => { setSuggest([]); setSuggestOpen(false) })
@@ -312,14 +316,15 @@ window.__ModuleLoader__.load({
312
316
  const continueSuggest = (dir) => {
313
317
  if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
314
318
  setSuggestOpen(false)
319
+ const sep = String(dir).includes('\\') ? '\\' : '/'
315
320
  api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
316
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/\/+$/, '') || '/')))
321
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
317
322
  .then((res) => {
318
323
  const list = Array.isArray(res && res.items)
319
324
  ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
320
325
  : parseLs(res && res.text)
321
- const base = String(dir).replace(/\/+$/, '') === '' ? '/' : String(dir).replace(/\/+$/, '')
322
- const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + '/' + it.name))
326
+ const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
327
+ const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + sep + it.name))
323
328
  setSuggest(kids)
324
329
  setSuggestOpen(!!kids.length)
325
330
  }).catch(() => { setSuggest([]); setSuggestOpen(false) })
@@ -353,34 +358,29 @@ window.__ModuleLoader__.load({
353
358
  }
354
359
  const last = levels[levels.length - 1]
355
360
  const entries = last.all || []
356
- // Floating overlay: pinned to viewport, covers the dialog background so
357
- // it never stretches the surrounding layout. The picker panel is a fixed
358
- // (height-capped) box whose directory list scrolls internally.
359
- return React.createElement('div', {
360
- style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
361
- onClick: () => setPopOpen(false),
362
- },
363
- React.createElement('div', { style: { background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 10, boxShadow: '0 10px 40px rgba(0,0,0,0.5)', width: 'min(560px, 94vw)', minWidth: 320, display: 'flex', flexDirection: 'column', maxHeight: 'min(440px, 82vh)', overflow: 'hidden' }, onClick: (e) => e.stopPropagation() },
364
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
365
- React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setLevels((p) => p && p.length > 1 ? p.slice(0, p.length - 1) : p), disabled: levels.length <= 1 || loading }, '回上一级 ▴'),
366
- React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, breadcrumb(machineId, last.path, (p) => setLevels((prev) => { const cut = (prev || []).findIndex((lv) => lv.path === p); return cut >= 0 ? prev.slice(0, cut + 1) : prev }))),
367
- React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
368
- ),
369
- React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
370
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
371
- : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
372
- key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
373
- onClick: it.dir ? () => enterDir(it.name) : undefined,
374
- style: { padding: '7px 12px', cursor: it.dir ? 'pointer' : 'default', color: it.dir ? T.ok : T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
375
- },
376
- React.createElement('span', null, it.dir ? '📁' : '📄'),
377
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
378
- )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
379
- ),
380
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
381
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
382
- React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => acceptBrowserPick(last.path) }, '选用此路径'),
383
- ),
361
+ // Inline panel anchored inside the dialog (NOT a full-viewport overlay):
362
+ // a height-capped box whose directory list scrolls internally, so the
363
+ // dialog's own confirm row ("设为远程工作区") stays visible below it.
364
+ return React.createElement('div', { style: { border: '1px solid ' + T.borderStrong, borderRadius: 10, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), boxShadow: '0 8px 32px rgba(0,0,0,0.4)', display: 'flex', flexDirection: 'column', maxHeight: 'min(300px, 46vh)', overflow: 'hidden' } },
365
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
366
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setLevels((p) => p && p.length > 1 ? p.slice(0, p.length - 1) : p), disabled: levels.length <= 1 || loading }, '回上一级 ▴'),
367
+ React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, breadcrumb(machineId, last.path, (p) => setLevels((prev) => { const cut = (prev || []).findIndex((lv) => lv.path === p); return cut >= 0 ? prev.slice(0, cut + 1) : prev }))),
368
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
369
+ ),
370
+ React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
371
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
372
+ : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
373
+ key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
374
+ onClick: it.dir ? () => enterDir(it.name) : undefined,
375
+ style: { padding: '6px 10px', cursor: it.dir ? 'pointer' : 'default', color: it.dir ? T.ok : T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
376
+ },
377
+ React.createElement('span', null, it.dir ? '📁' : '📄'),
378
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
379
+ )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
380
+ ),
381
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
382
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
383
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
384
384
  ),
385
385
  )
386
386
  }
@@ -391,7 +391,7 @@ window.__ModuleLoader__.load({
391
391
  // renders identically in the narrow sidebar and in the conversation and
392
392
  // is never squeezed into a cramped in-place column.
393
393
  return React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }, onClick: () => { if (!busy) onCancel() } },
394
- React.createElement('div', { style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
394
+ React.createElement('div', { style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', maxHeight: 'min(620px, 90vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
395
395
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
396
396
  React.createElement('div', null, '选择工作目录'),
397
397
  React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
@@ -410,40 +410,45 @@ window.__ModuleLoader__.load({
410
410
  React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
411
411
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
412
412
  )
413
- : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
413
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
414
414
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
415
415
  React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
416
416
  React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
417
- React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((v) => !v) },
417
+ React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
418
418
  (machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
419
419
  ),
420
420
  mOpen
421
- ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 300, maxHeight: 260, overflow: 'auto', background: '#1f1f23', border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
421
+ ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 300, maxHeight: 260, overflow: 'auto', background: v('--dsw-alias-bg-overlay', '#1f1f23'), border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
422
422
  machines.length
423
- ? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setLevels(null); if (m.id) { loadLevels(m.id, '', 0); if (!path.trim()) { setPath('/'); loadSuggestions('/', m.id) } } setMOpen(false) }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
423
+ ? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setLevels(null); setMOpen(false); if (m.id) { loadLevels(m.id, '', 0); if (!path.trim()) { setPath('/'); loadSuggestions('/', m.id) } } }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
424
424
  : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
425
425
  )
426
426
  : null,
427
427
  ),
428
428
  ),
429
429
  // 路径输入框(带自动补全)+ 打开浏览弹层按钮
430
- React.createElement('div', { ref: suggestRef, style: { position: 'relative', display: 'flex', gap: 6 } },
431
- React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
432
- React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
433
- // 自动补全下拉
430
+ // suggestRef 外层仍用于「点击外部关闭」判定;输入行与补全列表
431
+ // 是两级 div —— 补全列表以流式(非绝对定位)展开,确认按钮被
432
+ // 推到下方而不会被遮挡(issue #4「有子目录时选择框遮挡确定菜单」)。
433
+ React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
434
+ React.createElement('div', { style: { display: 'flex', gap: 6 } },
435
+ React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
436
+ React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
437
+ ),
438
+ // 自动补全下拉(流式展开,参与文档流,不覆盖确认按钮)
434
439
  (suggestOpen && suggest.length)
435
- ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 8, marginTop: 4, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
440
+ ? React.createElement('div', { style: { marginTop: 6, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
436
441
  suggest.map((s, i) => React.createElement('div', { key: s + i, onMouseDown: () => selectSuggestion(s), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, s)),
437
442
  )
438
443
  : null,
439
444
  ),
445
+ // 目录浏览面板(内联,锚定在对话框内;确认按钮保持可见)
446
+ popOpen ? renderDirPopup() : null,
440
447
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
441
448
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
442
449
  React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
443
450
  React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
444
451
  ),
445
- // 悬浮浏览弹层:选中的路径回填到输入框(不直接提交)
446
- popOpen ? renderDirPopup() : null,
447
452
  ),
448
453
  (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
449
454
  ),