dsh-remote 0.5.1 → 0.5.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 CHANGED
@@ -1,3 +1,7 @@
1
+ **English** · [中文](./README.zh.md)
2
+
3
+ ---
4
+
1
5
  # dsh-remote
2
6
 
3
7
  **Remote-work assistant for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).**
@@ -10,11 +14,13 @@ The harness Web UI intentionally binds `127.0.0.1` (the CLI rejects `--host 0.0.
10
14
 
11
15
  Settings → **远程工作区** — a multi-machine SSH registry (add / edit / delete / set-current, password stored locally):
12
16
 
13
- <img src="./docs/ui-settings.svg" alt="dsh-remote settings — multi-machine registry" width="720"/>
17
+ <img src="https://raw.githubusercontent.com/flymysql/dsh-remote/main/docs/ui-settings.svg" alt="dsh-remote settings — multi-machine registry" width="720"/>
18
+
19
+ The native **"Add workspace" / "Select workspace"** flow — a centered modal, two tabs, opens on **本机 (local)**; switch to **远程 (remote)**:
14
20
 
15
- The native **"Add workspace" / "Select workspace"** flow — one dialog, two tabs (本地 local / 远程 remote):
21
+ - **远程** — a **machine `<select>`**, a path field that **auto-prefills `/` and live-completes** directories (picking one immediately reveals its next level, OS/VSCode-style), plus a **浏览…** floating browser that fills the field without committing — you review, edit, then **设为远程工作区**.
16
22
 
17
- <img src="./docs/ui-picker.svg" alt="dsh-remote workspace picker — 本机 (native OS folder) + 远程 (machine → directory)" width="720"/>
23
+ <img src="https://raw.githubusercontent.com/flymysql/dsh-remote/main/docs/ui-picker.svg" alt="dsh-remote workspace picker — centered modal; 本机 (local) tab by default; 远程 machine → prefilled root path + autocomplete" width="720"/>
18
24
 
19
25
  ---
20
26
 
@@ -23,9 +29,11 @@ The native **"Add workspace" / "Select workspace"** flow — one dialog, two tab
23
29
  - **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.
24
30
  - **Two-tab workspace picker** (fills the native "Add workspace" flow):
25
31
  - **本机 / 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).
26
- - **远程 / Remote** — pick a **machine**, then browse its directories (or type a path) 🡺 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.
32
+ - **远程 / 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.
27
33
  - **Bidirectional SFTP sync** — `rw_sync` (remote → mirror) and `rw_push` (mirror → remote) round-trip your local-mirror edits back to the machine.
28
- - **Model tools** — `rw_info`, `rw_connect`, `rw_pick_workspace`, `rw_list_dir`, `rw_read_file`, `rw_exec`, `rw_sync`, `rw_push`.
34
+ - **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`.
35
+ - **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.
36
+ - **Connection health** — a **「测试连接」 test-connection** button in the Settings page validates host/user/key/password before you save a machine.
29
37
  - The active `user@host:/path` is injected into every system prompt so the agent knows its working root.
30
38
  - **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`).
31
39
 
@@ -45,6 +53,7 @@ dsh plugin add dsh-remote # add the bundle
45
53
  - **远程** → choose the machine → browse to a remote directory (or type `/path`) → "设为远程工作区" ⇒ a local mirror workspace is created and adopted.
46
54
  3. **Work with the agent** — treat it like any workspace:
47
55
  - `rw_list_dir(path?)`/`rw_read_file` — inspect remote files
56
+ - `rw_write_file(path, content)` — create or overwrite a remote file directly
48
57
  - `rw_exec(command)` — run remote shell commands
49
58
  - `rw_sync` / `rw_push` — pull/push the local mirror to and from the remote
50
59
 
@@ -53,15 +62,16 @@ dsh plugin add dsh-remote # add the bundle
53
62
  Provide a default machine in `cordis.patch.yml`:
54
63
 
55
64
  ```yaml
65
+ # Example only — use values for your own machine.
56
66
  - id: dsh-remote
57
67
  name: dsh-remote
58
68
  config:
59
- host: 10.0.0.8
69
+ host: 203.0.113.10 # or your real host / hostname
60
70
  port: 22
61
71
  username: dev
62
- privateKeyPath: C:/Users/you/.ssh/id_rsa
72
+ privateKeyPath: ~/.ssh/id_rsa
63
73
  # or password: '…'
64
- workspace: /home/dev/project
74
+ workspace: ~/project
65
75
  ```
66
76
 
67
77
  If `host` is empty the plugin starts disconnected and you configure machines in the UI.
@@ -85,7 +95,7 @@ dsh --profile web
85
95
  npx --yes @deepseek-ai/dsh --profile web # http://127.0.0.1:3080
86
96
 
87
97
  # use a local checkout instead of the npm version (dev iteration)
88
- npx --yes @deepseek-ai/dsh plugin --profile web add D:/work/dsh-community/dsh-remote
98
+ npx --yes @deepseek-ai/dsh plugin --profile web add /path/to/dsh-remote
89
99
  npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # back to release
90
100
  ```
91
101
 
package/README.zh.md CHANGED
@@ -1,3 +1,7 @@
1
+ [English](./README.md) · **中文**
2
+
3
+ ---
4
+
1
5
  # dsh-remote
2
6
 
3
7
  **为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)打造的远程工作助手。**
@@ -10,11 +14,13 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
10
14
 
11
15
  设置 → **远程工作区** —— 多机 SSH 列表(增/删/改/设为当前,密码本地保存、不回显):
12
16
 
13
- <img src="./docs/ui-settings.svg" alt="dsh-remote 设置页 — 多机列表" width="720"/>
17
+ <img src="https://raw.githubusercontent.com/flymysql/dsh-remote/main/docs/ui-settings.svg" alt="dsh-remote 设置页 — 多机列表" width="720"/>
18
+
19
+ 原生 **「Add workspace / 选择工作区」** 流程 —— **居中弹窗**、两个 tab,**默认落在「本机」**;切到 **「远程」**:
14
20
 
15
- 原生 **「Add workspace / 选择工作区」** 流程 —— 一个对话框两个 tab(本地+远程):
21
+ - **远程** —— 一个**机器下拉**;路径输入框**自动预填 `/` 并实时补全目录**(点选一个目录后**立即列出它的下一级**,像系统/VSCode 逐级选目录);另外有**「浏览…」浮窗**,选中仅回填到输入框(不直接提交),你复核 / 修改后点「设为远程工作区」。
16
22
 
17
- <img src="./docs/ui-picker.svg" alt="dsh-remote 工作区选择 — 本机系统文件夹 + 远程(选机器→列目录)" width="720"/>
23
+ <img src="https://raw.githubusercontent.com/flymysql/dsh-remote/main/docs/ui-picker.svg" alt="dsh-remote 工作区选择 — 居中弹窗;默认本机 tab;远程:机器 → 预填根路径 + 自动补全" width="720"/>
18
24
 
19
25
  ---
20
26
 
@@ -23,9 +29,11 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
23
29
  - **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。
24
30
  - **双 tab 工作区选择器**(填充原生「Add workspace」流程):
25
31
  - **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。
26
- - **远程** —— 先**选机器**,再浏览其目录(或输入路径)🡺 确定后会创建**真实本地镜像**(`~/.dsh/remote-workspaces/<host>/<basename>-<hash>`,`fs.realpath` 通过)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。
32
+ - **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → 路径框**自动预填 `/`** 并**实时补全**目录;**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」浮层**(不透明、定高、内部滚动、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定。确定会创建**真实本地镜像**(`~/.dsh/remote-workspaces/<host>/<basename>-<hash>`,`fs.realpath` 通过)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。
27
33
  - **双向 SFTP 同步** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。
28
- - **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_exec`、`rw_sync`、`rw_push`。
34
+ - **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_write_file`、`rw_exec`、`rw_sync`、`rw_push`、`rw_disconnect`。
35
+ - **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步。
36
+ - **连接体检** —— 设置页提供「测试连接」按钮,在保存机器之前先验证 host/user/密码/私钥是否可用。
29
37
  - 当前 `user@host:/path` 会注入每次系统提示,让 Agent 明确自己的工作根。
30
38
  - **不改任何 `dsh-workspace` 官方代码** —— 全部作为普通插件实现(client 半以 `priority -100` 填充 directory-flow holes)。
31
39
 
@@ -45,6 +53,7 @@ dsh plugin add dsh-remote # 添加 bundle
45
53
  - **远程** → 选机器 → 浏览到远程目录(或输入 `/path`)→ 「设为远程工作区」⇒ 创建并收养一个本地镜像工作区。
46
54
  3. **让 Agent 工作** —— 把它当普通工作区用:
47
55
  - `rw_list_dir(path?)` / `rw_read_file` —— 查看远程文件
56
+ - `rw_write_file(path, content)` —— 直接创建或覆盖远程文件
48
57
  - `rw_exec(command)` —— 在远程执行命令
49
58
  - `rw_sync` / `rw_push` —— 拉取/推送本地镜像 <-> 远程
50
59
 
@@ -53,16 +62,17 @@ dsh plugin add dsh-remote # 添加 bundle
53
62
  可在 `cordis.patch.yml` 提供默认机:
54
63
 
55
64
  ```yaml
65
+ # 示例:请换成你自己的机器
56
66
  - id: dsh-remote
57
67
  name: dsh-remote
58
68
  config:
59
- host: 10.0.0.8
69
+ host: 203.0.113.10 # 或你的真实主机 / hostname
60
70
  port: 22
61
71
  username: dev
62
- privateKeyPath: C:/Users/you/.ssh/id_rsa
72
+ privateKeyPath: ~/.ssh/id_rsa
63
73
  # 或用密码登录:
64
74
  # password: '…'
65
- workspace: /home/dev/project
75
+ workspace: ~/project
66
76
  ```
67
77
 
68
78
  若 `host` 为空,插件启动时处于断开状态,在 UI 里配置机器即可。
package/lib/client.js CHANGED
@@ -32,9 +32,27 @@ window.__ModuleLoader__.load({
32
32
  return data
33
33
  }
34
34
 
35
- const T = { bg: 'rgba(128,128,128,0.07)', border: 'rgba(128,128,128,0.35)', danger: '#e06c75', ok: '#4caf7d', radius: 8, muted: 'rgba(128,128,128,0.7)' }
36
- const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg }
37
- const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, cursor: 'pointer' }
35
+ // Theme via DSH design tokens so this follows the harness light/dark theme.
36
+ const v = (name, fb) => `var(${name}, ${fb})`
37
+ const T = {
38
+ bg: v('--dsw-alias-bg-layer-1', 'rgba(128,128,128,0.07)'),
39
+ bg2: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.10)'),
40
+ border: v('--dsw-alias-border-l2', 'rgba(128,128,128,0.35)'),
41
+ borderStrong: v('--dsw-alias-border-l3', 'rgba(128,128,128,0.5)'),
42
+ danger: v('--dsw-static-red-500', '#e06c75'),
43
+ dangerText: v('--dsw-static-red-400', '#e06c75'),
44
+ ok: v('--dsw-static-green-500', '#4caf7d'),
45
+ radius: 8,
46
+ muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
47
+ label: v('--dsw-alias-label-primary', '#e4e4e7'),
48
+ primary: v('--dsw-alias-button-primary-fill', '#2563eb'),
49
+ onPrimary: v('--dsw-alias-button-contrast-fill', '#fff'),
50
+ hoverBg: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.14)'),
51
+ }
52
+ const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
53
+ const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
54
+ const primaryBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: T.primary, color: T.onPrimary, cursor: 'pointer', fontWeight: 600 }
55
+ const ghostBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: 'transparent', color: T.label, cursor: 'pointer' }
38
56
  const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
39
57
  const boxS = box
40
58
 
@@ -48,6 +66,21 @@ window.__ModuleLoader__.load({
48
66
  const [busy, setBusy] = React.useState(false)
49
67
  const [msg, setMsg] = React.useState('')
50
68
  const [err, setErr] = React.useState('')
69
+ const [tst, setTst] = React.useState('') // idle | testing | ok | failing
70
+
71
+ const testConn = () => {
72
+ if (!form.host.trim() || tst === 'testing') return
73
+ setTst('testing'); setErr(''); setMsg('')
74
+ api('POST', '/dsh-remote/test-connect', {
75
+ host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
76
+ password: form.password, privateKeyPath: form.privateKeyPath,
77
+ })
78
+ .then((r) => {
79
+ if (r && r.ok) { setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)` : '连接成功') }
80
+ else { setTst('failing'); setErr((r && r.error) || '连接失败') }
81
+ })
82
+ .catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
83
+ }
51
84
 
52
85
  const refresh = () => api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setCurrentId(r.currentId || '') })
53
86
  React.useEffect(() => { refresh() }, [])
@@ -100,7 +133,9 @@ window.__ModuleLoader__.load({
100
133
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
101
134
  msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
102
135
  form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
103
- React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr('') } }, form.id ? '取消编辑' : '清空'),
136
+ React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr(''); setTst('idle') } }, form.id ? '取消编辑' : '清空'),
137
+ React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
138
+ tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
104
139
  React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
105
140
  ),
106
141
  ),
@@ -115,16 +150,40 @@ window.__ModuleLoader__.load({
115
150
  const t = ln.trim()
116
151
  if (!t || !t.length || /^total\b/i.test(t)) continue
117
152
  const parts = t.split(/\s+/).filter(Boolean)
118
- if (parts.length < 2) continue
153
+ // ls -l row: mode links owner group size month day [hh:mm|year] name...
154
+ // NAME starts at column 8 (everything before it is the fixed metadata).
155
+ if (parts.length < 8) continue
119
156
  const mode = parts[0]
120
- const name = parts[parts.length - 1]
121
- if (name === '.' || name === '..') continue
122
- const isDirL = mode.charAt(0) === 'd'
123
- out.push((isDirL ? '📁' : '📄') + name)
157
+ const name = parts.slice(8).join(' ')
158
+ if (!name || name === '.' || name === '..') continue
159
+ const isDir = mode.charAt(0) === 'd'
160
+ out.push({ name, dir: isDir })
124
161
  }
125
162
  return out
126
163
  }
127
164
 
165
+ // Clickable breadcrumb of the current remote path; clicking a segment jumps
166
+ // to that ancestor level (finite-width, one line, ellipsized at the front).
167
+ function breadcrumb(active, cur, jumpTo) {
168
+ const norm = String(cur || '').replace(/\/+$/, '')
169
+ const segs = norm === '' || norm === '/' ? [] : norm.split('/')
170
+ const crumbs = []
171
+ crumbs.push(React.createElement('span', { key: 'root', style: { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted }, onClick: active ? () => jumpTo('/') : undefined }, '/'))
172
+ let acc = ''
173
+ for (const s of segs) {
174
+ if (!s) continue
175
+ acc += '/' + s
176
+ crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
177
+ crumbs.push(React.createElement('span', {
178
+ key: acc,
179
+ style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === norm ? 700 : 400, whiteSpace: 'nowrap' },
180
+ onClick: active ? () => jumpTo(acc) : undefined,
181
+ }, s))
182
+ }
183
+ if (!crumbs.length) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, '/'))
184
+ return React.createElement('span', { key: 'crumb' }, crumbs)
185
+ }
186
+
128
187
  function DirPicker(props) {
129
188
  const { open, busy, onPicked, onCancel } = props
130
189
  const [tab, setTab] = React.useState('local')
@@ -134,6 +193,38 @@ window.__ModuleLoader__.load({
134
193
  const [items, setItems] = React.useState(null)
135
194
  const [err, setErr] = React.useState('')
136
195
  const [loading, setLoading] = React.useState(false)
196
+ // 级联下钻状态:每一格是 { path, dirs } —— dirs 是该路径下的目录列表。
197
+ const [levels, setLevels] = React.useState(null)
198
+ const [popOpen, setPopOpen] = React.useState(false)
199
+ const [suggest, setSuggest] = React.useState([])
200
+ const [suggestOpen, setSuggestOpen] = React.useState(false)
201
+ const suggestTimer = React.useRef(null)
202
+
203
+ const loadLevels = (id, p, toIndex) => {
204
+ if (!id) return
205
+ setLoading(true); setErr('')
206
+ api('POST', '/dsh-remote/current', { id }).catch(() => {})
207
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
208
+ .then((res) => {
209
+ const real = res && res.path ? res.path : (p || '')
210
+ // server returns [{ type:'dir'|'file', name }]; entering a dir is
211
+ // decided by type dir (server already follows symlinks to dirs).
212
+ const list = Array.isArray(res && res.items)
213
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
214
+ : parseLs(res && res.text)
215
+ const node = { path: real, dirs: list.filter((it) => it.dir), all: list }
216
+ setLevels((prev) => {
217
+ const base = prev && prev.length ? prev.slice() : []
218
+ // toIndex >= 0 → put this node at that position, truncating below it.
219
+ let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
220
+ if (idx >= base.length) return base.concat([node])
221
+ base[idx] = node
222
+ return base.slice(0, idx + 1)
223
+ })
224
+ })
225
+ .catch((e) => setErr(String((e && e.message) || e)))
226
+ .finally(() => setLoading(false))
227
+ }
137
228
 
138
229
  React.useEffect(() => { if (open) { api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '') }) } }, [open])
139
230
 
@@ -149,34 +240,145 @@ window.__ModuleLoader__.load({
149
240
  .finally(() => setLoading(false))
150
241
  }
151
242
 
152
- const switchTab = (t) => { setTab(t); setErr(''); if (t === 'remote' && machineId) loadRemote(machineId, '') }
153
- const useMachine = (id) => { setMachineId(id); loadRemote(id, '') }
154
- const loadRemote = (id, target) => {
155
- setLoading(true); setErr('')
243
+ const switchTab = (t) => {
244
+ setTab(t); setErr('')
245
+ if (t === 'remote') {
246
+ if (machineId) loadLevels(machineId, '', 0)
247
+ // Prime the field with the machine's root so the user immediately gets
248
+ // a '/'-level completion list without typing anything.
249
+ if (!path.trim()) { setPath('/'); loadSuggestions('/') }
250
+ }
251
+ }
252
+
253
+ // enterDir(name): drive into the named subdir of the current deepest level,
254
+ // appending that directory as the new deepest level (path is pieced together).
255
+ const enterDir = (name) => {
256
+ if (busy || loading) return
257
+ const last = levels && levels.length ? levels[levels.length - 1] : null
258
+ const base = last && last.path ? last.path : ''
259
+ const next = base === '/' ? '/' + name : (base ? base : '') + '/' + name
260
+ loadLevels(machineId, next, (levels ? levels.length : 0))
261
+ }
262
+
263
+ // Autocomplete: given a partial remote path, list children of its parent dir
264
+ // that start with the last segment (powered by the structured ls endpoint).
265
+ const loadSuggestions = (raw, mid) => {
266
+ const id = mid || machineId
267
+ if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
268
+ const t = String(raw || '').trim()
269
+ if (!t) { setSuggest([]); setSuggestOpen(false); return }
270
+ const slash = t.lastIndexOf('/')
271
+ const parent = slash <= 0 ? '/' : t.slice(0, slash)
272
+ const lastSeg = slash < 0 ? t : t.slice(slash + 1)
156
273
  api('POST', '/dsh-remote/current', { id }).catch(() => {})
157
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(target || '')))
158
- .then((res) => { setPath(res && res.path ? res.path : target || ''); setItems(parseLs(res && res.text)) })
159
- .catch((e) => setErr(String((e && e.message) || e)))
160
- .finally(() => setLoading(false))
274
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
275
+ .then((res) => {
276
+ const list = Array.isArray(res && res.items)
277
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
278
+ : parseLs(res && res.text)
279
+ const base = parent === '/' ? '/' : parent
280
+ const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
281
+ .slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + '/' + it.name))
282
+ setSuggest(matches)
283
+ setSuggestOpen(!!matches.length)
284
+ }).catch(() => { setSuggest([]); setSuggestOpen(false) })
161
285
  }
162
- const enter = (n) => {
163
- const base = String(path).replace(/\/+$/, '')
164
- const next = (base ? base : '') + '/' + String(n).replace(/^📁/, '')
165
- loadRemote(machineId, next)
286
+
287
+ const onPathChange = (raw) => {
288
+ setPath(raw); setErr('')
289
+ if (suggestTimer.current) clearTimeout(suggestTimer.current)
290
+ suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
166
291
  }
167
- const goUp = () => {
168
- const base = String(path).replace(/\/+$/, '')
169
- const idx = base.lastIndexOf('/')
170
- loadRemote(machineId, idx <= 0 ? '/' : base.slice(0, idx))
292
+
293
+ // After a directory is chosen, immediately reveal the next level: list the
294
+ // chosen directory's children as fresh completions (no keystroke needed).
295
+ const continueSuggest = (dir) => {
296
+ if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
297
+ setSuggestOpen(false)
298
+ api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
299
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/\/+$/, '') || '/')))
300
+ .then((res) => {
301
+ const list = Array.isArray(res && res.items)
302
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
303
+ : parseLs(res && res.text)
304
+ const base = String(dir).replace(/\/+$/, '') === '' ? '/' : String(dir).replace(/\/+$/, '')
305
+ const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + '/' + it.name))
306
+ setSuggest(kids)
307
+ setSuggestOpen(!!kids.length)
308
+ }).catch(() => { setSuggest([]); setSuggestOpen(false) })
171
309
  }
172
- const pickRemote = () => {
173
- if (busy || loading) return
174
- api('POST', '/dsh-remote/mirror', { path }).then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || ''))).catch((e) => setErr(String((e && e.message) || e)))
310
+
311
+ // Choose a suggestion: fill the field and immediately open its next level.
312
+ const selectSuggestion = (s) => {
313
+ setPath(s); setErr(''); setSuggestOpen(false)
314
+ continueSuggest(s)
315
+ }
316
+
317
+ // Commit an explicit remote path as the workspace (race from the input).
318
+ const commitPath = (p) => {
319
+ const target = String(p || '').trim()
320
+ if (!target || !machineId || busy) return
321
+ setPopOpen(false); setSuggestOpen(false)
322
+ api('POST', '/dsh-remote/mirror', { path: target }).then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || ''))).catch((e) => setErr(String((e && e.message) || e)))
323
+ }
324
+
325
+ // Confirm the highlighted directory from the browser popup by filling the
326
+ // path input (not committing), so the user can review/edit before commit.
327
+ const acceptBrowserPick = (p) => {
328
+ setPath(String(p || ''))
329
+ setSuggestOpen(false)
330
+ setPopOpen(false)
331
+ }
332
+
333
+ function renderDirPopup() {
334
+ if (!levels || !levels.length) {
335
+ return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
336
+ }
337
+ const last = levels[levels.length - 1]
338
+ const entries = last.all || []
339
+ // Floating overlay: pinned to viewport, covers the dialog background so
340
+ // it never stretches the surrounding layout. The picker panel is a fixed
341
+ // (height-capped) box whose directory list scrolls internally.
342
+ return React.createElement('div', {
343
+ style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
344
+ onClick: () => setPopOpen(false),
345
+ },
346
+ 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() },
347
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
348
+ 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 }, '回上一级 ▴'),
349
+ 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 }))),
350
+ React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
351
+ ),
352
+ React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
353
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
354
+ : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
355
+ key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
356
+ onClick: it.dir ? () => enterDir(it.name) : undefined,
357
+ 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 },
358
+ },
359
+ React.createElement('span', null, it.dir ? '📁' : '📄'),
360
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
361
+ )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
362
+ ),
363
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
364
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
365
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => acceptBrowserPick(last.path) }, '选用此路径'),
366
+ ),
367
+ ),
368
+ )
175
369
  }
176
370
 
177
371
  if (!open) return null
178
372
  const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
179
- return React.createElement('div', { style: { padding: 14, minWidth: 460, maxWidth: 720 } },
373
+ // The picker is a full-viewport centered modal (backdrop + panel), so it
374
+ // renders identically in the narrow sidebar and in the conversation and
375
+ // is never squeezed into a cramped in-place column.
376
+ 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() } },
377
+ 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() },
378
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
379
+ React.createElement('div', null, '选择工作目录'),
380
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
381
+ ),
180
382
  React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
181
383
  tabBtn('local', '本机'),
182
384
  tabBtn('remote', '远程'),
@@ -193,28 +395,33 @@ window.__ModuleLoader__.load({
193
395
  )
194
396
  : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
195
397
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
196
- React.createElement('label', { style: { fontSize: 12, opacity: 0.8 } }, '远程机器:'),
197
- React.createElement('select', { value: machineId, onChange: (e) => useMachine(e.target.value), style: inputS },
398
+ React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
399
+ React.createElement('select', { value: machineId, onChange: (e) => { const id = e.target.value; setMachineId(id); setLevels(null); if (id) { loadLevels(id, '', 0); if (!path.trim()) { setPath('/'); loadSuggestions('/', id) } } }, style: { ...inputS, maxWidth: '100%', minWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
198
400
  React.createElement('option', { value: '' }, '— 选择 —'),
199
401
  machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, m.name + ' (' + m.username + '@' + m.host + ')')),
200
402
  ),
201
403
  ),
202
- React.createElement('div', { style: { display: 'flex', gap: 6 } },
203
- React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '远程目录,如 /home/dev/project', style: inputS }),
204
- React.createElement('button', { style: buttonS, onClick: () => loadRemote(machineId, path), disabled: !machineId || loading }, '载入'),
205
- React.createElement('button', { style: buttonS, onClick: goUp, disabled: !machineId || loading }, '上一级'),
404
+ // 路径输入框(带自动补全)+ 打开浏览弹层按钮
405
+ React.createElement('div', { style: { position: 'relative', display: 'flex', gap: 6 } },
406
+ React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
407
+ React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
408
+ // 自动补全下拉
409
+ (suggestOpen && suggest.length)
410
+ ? 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)' } },
411
+ 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)),
412
+ )
413
+ : null,
206
414
  ),
207
415
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
208
- React.createElement('div', { style: boxS, ...{ minHeight: 150 } },
209
- loading ? '加载中…'
210
- : (items && items.length ? items.slice(0, 400).map((n, i) => { const isDir = n.indexOf('📁') === 0; return React.createElement('div', { key: i, onClick: () => (isDir ? enter(n.slice(2)) : null), style: { padding: '3px 6px', borderRadius: 4, cursor: isDir ? 'pointer' : 'default', color: isDir ? T.ok : 'inherit' } }, isDir ? n : ' ' + n) }) : React.createElement('div', { style: { opacity: 0.6 } }, '先选机器,再点「载入」或输入路径')),
211
- ),
212
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } },
213
- React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消'),
214
- React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: pickRemote, disabled: busy || loading || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
416
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
417
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
418
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
215
419
  ),
420
+ // 悬浮浏览弹层:选中的路径回填到输入框(不直接提交)
421
+ popOpen ? renderDirPopup() : null,
216
422
  ),
217
423
  (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
424
+ ),
218
425
  )
219
426
  }
220
427