dsh-remote 0.5.7 → 0.5.8
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 +10 -30
- package/README.zh.md +9 -29
- package/lib/client.js +61 -250
- package/lib/index.js +914 -1026
- package/package.json +52 -61
package/README.md
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
**English** · [中文](./README.zh.md)
|
|
2
|
-
|
|
3
|
-
---
|
|
4
|
-
|
|
5
1
|
# dsh-remote
|
|
6
2
|
|
|
7
|
-
[](https://www.npmjs.com/package/dsh-remote)
|
|
8
|
-
[](LICENSE)
|
|
9
|
-
[](https://github.com/topics/dsh-plugin)
|
|
10
|
-
|
|
11
3
|
**Remote-work assistant for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH).**
|
|
12
4
|
|
|
13
5
|
Manage several SSH machines, then pick a **remote workspace** (or a **local** one) and let the agent operate right there without leaving the harness — listing files, reading code, running builds & commands over the remote host, and keeping that remote directory mirrored into a real local workspace object.
|
|
@@ -18,15 +10,11 @@ The harness Web UI intentionally binds `127.0.0.1` (the CLI rejects `--host 0.0.
|
|
|
18
10
|
|
|
19
11
|
Settings → **远程工作区** — a multi-machine SSH registry (add / edit / delete / set-current, password stored locally):
|
|
20
12
|
|
|
21
|
-
<img src="https://
|
|
13
|
+
<img src="https://raw.githubusercontent.com/flymysql/dsh-remote/main/docs/ui-settings.svg" alt="dsh-remote settings — multi-machine registry" width="720"/>
|
|
22
14
|
|
|
23
|
-
The native **"Add workspace" / "Select workspace"** flow —
|
|
15
|
+
The native **"Add workspace" / "Select workspace"** flow — one dialog, two tabs (本地 local / 远程 remote):
|
|
24
16
|
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
Real capture (host scrubbed to a placeholder):
|
|
28
|
-
|
|
29
|
-
<img src="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-picker-panel.png" alt="dsh-remote workspace picker — real dialog; 本机 (local) tab; 远程 machine select + prefilled root path + autocomplete" width="720"/>
|
|
17
|
+
<img src="https://raw.githubusercontent.com/flymysql/dsh-remote/main/docs/ui-picker.svg" alt="dsh-remote workspace picker — 本机 (native OS folder) + 远程 (machine → directory)" width="720"/>
|
|
30
18
|
|
|
31
19
|
---
|
|
32
20
|
|
|
@@ -35,11 +23,9 @@ Real capture (host scrubbed to a placeholder):
|
|
|
35
23
|
- **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
24
|
- **Two-tab workspace picker** (fills the native "Add workspace" flow):
|
|
37
25
|
- **本机 / 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** —
|
|
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.
|
|
39
27
|
- **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`, `
|
|
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.
|
|
42
|
-
- **Connection health** — a **「测试连接」 test-connection** button in the Settings page validates host/user/key/password before you save a machine.
|
|
28
|
+
- **Model tools** — `rw_info`, `rw_connect`, `rw_pick_workspace`, `rw_list_dir`, `rw_read_file`, `rw_exec`, `rw_sync`, `rw_push`.
|
|
43
29
|
- The active `user@host:/path` is injected into every system prompt so the agent knows its working root.
|
|
44
30
|
- **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
31
|
|
|
@@ -59,7 +45,6 @@ dsh plugin add dsh-remote # add the bundle
|
|
|
59
45
|
- **远程** → choose the machine → browse to a remote directory (or type `/path`) → "设为远程工作区" ⇒ a local mirror workspace is created and adopted.
|
|
60
46
|
3. **Work with the agent** — treat it like any workspace:
|
|
61
47
|
- `rw_list_dir(path?)`/`rw_read_file` — inspect remote files
|
|
62
|
-
- `rw_write_file(path, content)` — create or overwrite a remote file directly
|
|
63
48
|
- `rw_exec(command)` — run remote shell commands
|
|
64
49
|
- `rw_sync` / `rw_push` — pull/push the local mirror to and from the remote
|
|
65
50
|
|
|
@@ -68,16 +53,15 @@ dsh plugin add dsh-remote # add the bundle
|
|
|
68
53
|
Provide a default machine in `cordis.patch.yml`:
|
|
69
54
|
|
|
70
55
|
```yaml
|
|
71
|
-
# Example only — use values for your own machine.
|
|
72
56
|
- id: dsh-remote
|
|
73
57
|
name: dsh-remote
|
|
74
58
|
config:
|
|
75
|
-
host:
|
|
59
|
+
host: 10.0.0.8
|
|
76
60
|
port: 22
|
|
77
61
|
username: dev
|
|
78
|
-
privateKeyPath:
|
|
62
|
+
privateKeyPath: C:/Users/you/.ssh/id_rsa
|
|
79
63
|
# or password: '…'
|
|
80
|
-
workspace:
|
|
64
|
+
workspace: /home/dev/project
|
|
81
65
|
```
|
|
82
66
|
|
|
83
67
|
If `host` is empty the plugin starts disconnected and you configure machines in the UI.
|
|
@@ -101,7 +85,7 @@ dsh --profile web
|
|
|
101
85
|
npx --yes @deepseek-ai/dsh --profile web # http://127.0.0.1:3080
|
|
102
86
|
|
|
103
87
|
# use a local checkout instead of the npm version (dev iteration)
|
|
104
|
-
npx --yes @deepseek-ai/dsh plugin --profile web add /
|
|
88
|
+
npx --yes @deepseek-ai/dsh plugin --profile web add D:/work/dsh-community/dsh-remote
|
|
105
89
|
npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # back to release
|
|
106
90
|
```
|
|
107
91
|
|
|
@@ -126,8 +110,4 @@ Giving the plugin a machine's credentials lets the agent run **shell commands as
|
|
|
126
110
|
|
|
127
111
|
## License
|
|
128
112
|
|
|
129
|
-
MIT
|
|
130
|
-
|
|
131
|
-
## Changelog
|
|
132
|
-
|
|
133
|
-
See [CHANGELOG.md](./CHANGELOG.md).
|
|
113
|
+
MIT
|
package/README.zh.md
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
[English](./README.md) · **中文**
|
|
2
|
-
|
|
3
|
-
---
|
|
4
|
-
|
|
5
1
|
# dsh-remote
|
|
6
2
|
|
|
7
|
-
[](https://www.npmjs.com/package/dsh-remote)
|
|
8
|
-
[](LICENSE)
|
|
9
|
-
[](https://github.com/topics/dsh-plugin)
|
|
10
|
-
|
|
11
3
|
**为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)打造的远程工作助手。**
|
|
12
4
|
|
|
13
5
|
维护多台 SSH 机器,然后在「选择工作区」时选一个**远程工作区**(或**本地工作区**),Agent 就能在不离开 harness 的情况下直接操作——列文件、读代码、在远程主机上跑构建/命令,并把远程目录镜像成一个真实的本地工作区对象。
|
|
@@ -18,15 +10,11 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
18
10
|
|
|
19
11
|
设置 → **远程工作区** —— 多机 SSH 列表(增/删/改/设为当前,密码本地保存、不回显):
|
|
20
12
|
|
|
21
|
-
<img src="https://
|
|
13
|
+
<img src="https://raw.githubusercontent.com/flymysql/dsh-remote/main/docs/ui-settings.svg" alt="dsh-remote 设置页 — 多机列表" width="720"/>
|
|
22
14
|
|
|
23
|
-
原生 **「Add workspace / 选择工作区」** 流程 ——
|
|
15
|
+
原生 **「Add workspace / 选择工作区」** 流程 —— 一个对话框两个 tab(本地+远程):
|
|
24
16
|
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
真实截图(机器已打码为占位):
|
|
28
|
-
|
|
29
|
-
<img src="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-picker-panel.png" alt="dsh-remote 工作区选择 — 真实弹窗;默认本机 tab;远程:机器下拉 + 预填根路径 + 自动补全" width="720"/>
|
|
17
|
+
<img src="https://raw.githubusercontent.com/flymysql/dsh-remote/main/docs/ui-picker.svg" alt="dsh-remote 工作区选择 — 本机系统文件夹 + 远程(选机器→列目录)" width="720"/>
|
|
30
18
|
|
|
31
19
|
---
|
|
32
20
|
|
|
@@ -35,11 +23,9 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
35
23
|
- **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。
|
|
36
24
|
- **双 tab 工作区选择器**(填充原生「Add workspace」流程):
|
|
37
25
|
- **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。
|
|
38
|
-
- **远程** ——
|
|
26
|
+
- **远程** —— 先**选机器**,再浏览其目录(或输入路径)🡺 确定后会创建**真实本地镜像**(`~/.dsh/remote-workspaces/<host>/<basename>-<hash>`,`fs.realpath` 通过)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。
|
|
39
27
|
- **双向 SFTP 同步** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。
|
|
40
|
-
- **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`
|
|
41
|
-
- **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步。
|
|
42
|
-
- **连接体检** —— 设置页提供「测试连接」按钮,在保存机器之前先验证 host/user/密码/私钥是否可用。
|
|
28
|
+
- **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_exec`、`rw_sync`、`rw_push`。
|
|
43
29
|
- 当前 `user@host:/path` 会注入每次系统提示,让 Agent 明确自己的工作根。
|
|
44
30
|
- **不改任何 `dsh-workspace` 官方代码** —— 全部作为普通插件实现(client 半以 `priority -100` 填充 directory-flow holes)。
|
|
45
31
|
|
|
@@ -59,7 +45,6 @@ dsh plugin add dsh-remote # 添加 bundle
|
|
|
59
45
|
- **远程** → 选机器 → 浏览到远程目录(或输入 `/path`)→ 「设为远程工作区」⇒ 创建并收养一个本地镜像工作区。
|
|
60
46
|
3. **让 Agent 工作** —— 把它当普通工作区用:
|
|
61
47
|
- `rw_list_dir(path?)` / `rw_read_file` —— 查看远程文件
|
|
62
|
-
- `rw_write_file(path, content)` —— 直接创建或覆盖远程文件
|
|
63
48
|
- `rw_exec(command)` —— 在远程执行命令
|
|
64
49
|
- `rw_sync` / `rw_push` —— 拉取/推送本地镜像 <-> 远程
|
|
65
50
|
|
|
@@ -68,17 +53,16 @@ dsh plugin add dsh-remote # 添加 bundle
|
|
|
68
53
|
可在 `cordis.patch.yml` 提供默认机:
|
|
69
54
|
|
|
70
55
|
```yaml
|
|
71
|
-
# 示例:请换成你自己的机器
|
|
72
56
|
- id: dsh-remote
|
|
73
57
|
name: dsh-remote
|
|
74
58
|
config:
|
|
75
|
-
host:
|
|
59
|
+
host: 10.0.0.8
|
|
76
60
|
port: 22
|
|
77
61
|
username: dev
|
|
78
|
-
privateKeyPath:
|
|
62
|
+
privateKeyPath: C:/Users/you/.ssh/id_rsa
|
|
79
63
|
# 或用密码登录:
|
|
80
64
|
# password: '…'
|
|
81
|
-
workspace:
|
|
65
|
+
workspace: /home/dev/project
|
|
82
66
|
```
|
|
83
67
|
|
|
84
68
|
若 `host` 为空,插件启动时处于断开状态,在 UI 里配置机器即可。
|
|
@@ -127,8 +111,4 @@ npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # 恢复用
|
|
|
127
111
|
|
|
128
112
|
## License
|
|
129
113
|
|
|
130
|
-
MIT
|
|
131
|
-
|
|
132
|
-
## 变更记录
|
|
133
|
-
|
|
134
|
-
见 [CHANGELOG.md](./CHANGELOG.md)。
|
|
114
|
+
MIT
|
package/lib/client.js
CHANGED
|
@@ -32,27 +32,9 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
return data
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
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' }
|
|
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' }
|
|
56
38
|
const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
|
|
57
39
|
const boxS = box
|
|
58
40
|
|
|
@@ -66,21 +48,6 @@ window.__ModuleLoader__.load({
|
|
|
66
48
|
const [busy, setBusy] = React.useState(false)
|
|
67
49
|
const [msg, setMsg] = React.useState('')
|
|
68
50
|
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
|
-
}
|
|
84
51
|
|
|
85
52
|
const refresh = () => api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setCurrentId(r.currentId || '') })
|
|
86
53
|
React.useEffect(() => { refresh() }, [])
|
|
@@ -133,9 +100,7 @@ window.__ModuleLoader__.load({
|
|
|
133
100
|
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
|
|
134
101
|
msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
|
|
135
102
|
form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
|
|
136
|
-
React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr('')
|
|
137
|
-
React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
|
|
138
|
-
tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
|
|
103
|
+
React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr('') } }, form.id ? '取消编辑' : '清空'),
|
|
139
104
|
React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
|
|
140
105
|
),
|
|
141
106
|
),
|
|
@@ -150,40 +115,16 @@ window.__ModuleLoader__.load({
|
|
|
150
115
|
const t = ln.trim()
|
|
151
116
|
if (!t || !t.length || /^total\b/i.test(t)) continue
|
|
152
117
|
const parts = t.split(/\s+/).filter(Boolean)
|
|
153
|
-
|
|
154
|
-
// NAME starts at column 8 (everything before it is the fixed metadata).
|
|
155
|
-
if (parts.length < 8) continue
|
|
118
|
+
if (parts.length < 2) continue
|
|
156
119
|
const mode = parts[0]
|
|
157
|
-
const name = parts.
|
|
158
|
-
if (
|
|
159
|
-
const
|
|
160
|
-
out.push(
|
|
120
|
+
const name = parts[parts.length - 1]
|
|
121
|
+
if (name === '.' || name === '..') continue
|
|
122
|
+
const isDirL = mode.charAt(0) === 'd'
|
|
123
|
+
out.push((isDirL ? '📁' : '📄') + name)
|
|
161
124
|
}
|
|
162
125
|
return out
|
|
163
126
|
}
|
|
164
127
|
|
|
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
|
-
|
|
187
128
|
function DirPicker(props) {
|
|
188
129
|
const { open, busy, onPicked, onCancel } = props
|
|
189
130
|
const [tab, setTab] = React.useState('local')
|
|
@@ -193,38 +134,17 @@ window.__ModuleLoader__.load({
|
|
|
193
134
|
const [items, setItems] = React.useState(null)
|
|
194
135
|
const [err, setErr] = React.useState('')
|
|
195
136
|
const [loading, setLoading] = React.useState(false)
|
|
196
|
-
|
|
197
|
-
const
|
|
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)
|
|
137
|
+
const [mOpen, setMOpen] = React.useState(false)
|
|
138
|
+
const ddRef = React.useRef(null)
|
|
202
139
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
.
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
}
|
|
140
|
+
// Clicking anywhere outside the machine dropdown closes it (so the
|
|
141
|
+
// candidate list never stays open and covers the buttons below).
|
|
142
|
+
React.useEffect(() => {
|
|
143
|
+
if (!mOpen) return
|
|
144
|
+
const onDown = (ev) => { if (ddRef.current && !ddRef.current.contains(ev.target)) setMOpen(false) }
|
|
145
|
+
document.addEventListener('mousedown', onDown)
|
|
146
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
147
|
+
}, [mOpen])
|
|
228
148
|
|
|
229
149
|
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])
|
|
230
150
|
|
|
@@ -240,145 +160,34 @@ window.__ModuleLoader__.load({
|
|
|
240
160
|
.finally(() => setLoading(false))
|
|
241
161
|
}
|
|
242
162
|
|
|
243
|
-
const switchTab = (t) => {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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)
|
|
163
|
+
const switchTab = (t) => { setTab(t); setErr(''); if (t === 'remote' && machineId) loadRemote(machineId, '') }
|
|
164
|
+
const useMachine = (id) => { setMachineId(id); loadRemote(id, '') }
|
|
165
|
+
const loadRemote = (id, target) => {
|
|
166
|
+
setLoading(true); setErr('')
|
|
273
167
|
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
274
|
-
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(
|
|
275
|
-
.then((res) => {
|
|
276
|
-
|
|
277
|
-
|
|
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) })
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const onPathChange = (raw) => {
|
|
288
|
-
setPath(raw); setErr('')
|
|
289
|
-
if (suggestTimer.current) clearTimeout(suggestTimer.current)
|
|
290
|
-
suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
|
|
291
|
-
}
|
|
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) })
|
|
309
|
-
}
|
|
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)
|
|
168
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(target || '')))
|
|
169
|
+
.then((res) => { setPath(res && res.path ? res.path : target || ''); setItems(parseLs(res && res.text)) })
|
|
170
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
171
|
+
.finally(() => setLoading(false))
|
|
315
172
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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)))
|
|
173
|
+
const enter = (n) => {
|
|
174
|
+
const base = String(path).replace(/\/+$/, '')
|
|
175
|
+
const next = (base ? base : '') + '/' + String(n).replace(/^📁/, '')
|
|
176
|
+
loadRemote(machineId, next)
|
|
323
177
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
setPath(String(p || ''))
|
|
329
|
-
setSuggestOpen(false)
|
|
330
|
-
setPopOpen(false)
|
|
178
|
+
const goUp = () => {
|
|
179
|
+
const base = String(path).replace(/\/+$/, '')
|
|
180
|
+
const idx = base.lastIndexOf('/')
|
|
181
|
+
loadRemote(machineId, idx <= 0 ? '/' : base.slice(0, idx))
|
|
331
182
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
)
|
|
183
|
+
const pickRemote = () => {
|
|
184
|
+
if (busy || loading) return
|
|
185
|
+
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)))
|
|
369
186
|
}
|
|
370
187
|
|
|
371
188
|
if (!open) return null
|
|
372
189
|
const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
|
|
373
|
-
|
|
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
|
-
),
|
|
190
|
+
return React.createElement('div', { style: { padding: 14, minWidth: 460, maxWidth: 720 } },
|
|
382
191
|
React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
|
|
383
192
|
tabBtn('local', '本机'),
|
|
384
193
|
tabBtn('remote', '远程'),
|
|
@@ -395,33 +204,35 @@ window.__ModuleLoader__.load({
|
|
|
395
204
|
)
|
|
396
205
|
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
397
206
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
398
|
-
React.createElement('
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
207
|
+
React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
|
|
208
|
+
React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 240 }, onClick: () => setMOpen((v) => !v) },
|
|
209
|
+
(machines.find((m) => m.id === machineId) || {}).name || (machineId ? '加载中…' : '— 选择机器 —'),
|
|
210
|
+
),
|
|
211
|
+
mOpen
|
|
212
|
+
? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 280, maxHeight: 240, overflow: 'auto', background: '#1f1f23', border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
|
|
213
|
+
machines.length
|
|
214
|
+
? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setMOpen(false); useMachine(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 + ')'))
|
|
215
|
+
: React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
|
|
216
|
+
)
|
|
217
|
+
: null,
|
|
402
218
|
),
|
|
403
219
|
),
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
React.createElement('
|
|
407
|
-
React.createElement('button', { style:
|
|
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,
|
|
220
|
+
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
221
|
+
React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '远程目录,如 /home/dev/project', style: inputS }),
|
|
222
|
+
React.createElement('button', { style: buttonS, onClick: () => loadRemote(machineId, path), disabled: !machineId || loading }, '载入'),
|
|
223
|
+
React.createElement('button', { style: buttonS, onClick: goUp, disabled: !machineId || loading }, '上一级'),
|
|
414
224
|
),
|
|
415
225
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
416
|
-
React.createElement('div', { style:
|
|
417
|
-
|
|
418
|
-
|
|
226
|
+
React.createElement('div', { style: boxS, ...{ minHeight: 150 } },
|
|
227
|
+
loading ? '加载中…'
|
|
228
|
+
: (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 } }, '先选机器,再点「载入」或输入路径')),
|
|
229
|
+
),
|
|
230
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } },
|
|
231
|
+
React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消'),
|
|
232
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: pickRemote, disabled: busy || loading || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
|
|
419
233
|
),
|
|
420
|
-
// 悬浮浏览弹层:选中的路径回填到输入框(不直接提交)
|
|
421
|
-
popOpen ? renderDirPopup() : null,
|
|
422
234
|
),
|
|
423
235
|
(tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
|
|
424
|
-
),
|
|
425
236
|
)
|
|
426
237
|
}
|
|
427
238
|
|