dsh-remote 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +142 -154
  2. package/lib/index.js +125 -0
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -1,13 +1,15 @@
1
1
  // dsh-remote — client half.
2
- // Settings → 远程工作区 + a unified workspace directory picker that fills
3
- // ui-workspace's two directory-flow holes (sidebar + conversation hero).
2
+ // Settings → 远程工作区: a multi-machine SSH registry (add/edit/select current;
3
+ // password stored host-side and kept private). No workspace path here — the
4
+ // path is picked at workspace-add time.
4
5
  //
5
- // The picker has two tabs, styled to match the harness:
6
- // • 本机 — drives the SAME local Host backend as the official picker
7
- // (ctx.workspaces.listDirectory), so local dirs still work.
8
- // • 远程 — browses the remote over /dsh-remote/ls (SFTP); on confirm it asks
9
- // the host to build a real LOCAL mirror (/dsh-remote/mirror) then
10
- // onPicked(localMirror) so the DSH workspaceRegistry adopts the local dir.
6
+ // A unified workspace directory picker fills ui-workspace's two directory-flow
7
+ // holes (sidebar + conversation hero):
8
+ // • 本机 tab → opens the NATIVE OS folder chooser (ctx.workspaces.pickDirectory)
9
+ // and returns the picked local path (works with local workspaces).
10
+ // • 远程 tab → pick a machine (dropdown), list its directories over
11
+ // /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
12
+ // real LOCAL mirror → onPicked(localMirror) so host adopts it as a workspace.
11
13
  //
12
14
  // Client entries must be classic scripts registered via window.__ModuleLoader__.load
13
15
  // ({ id, factory }); the factory receives a synchronous `require`.
@@ -23,111 +25,92 @@ window.__ModuleLoader__.load({
23
25
 
24
26
  async function api(method, path, body) {
25
27
  const opts = { method, headers: {} }
26
- if (body) {
27
- opts.headers['Content-Type'] = 'application/json'
28
- opts.body = JSON.stringify(body)
29
- }
28
+ if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
30
29
  const res = await fetch(path, opts)
31
30
  const data = await res.json().catch(() => ({}))
32
31
  if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
33
32
  return data
34
33
  }
35
34
 
36
- const T = { bg: 'rgba(128,128,128,0.07)', border: 'rgba(128,128,128,0.35)', danger: '#e06c75', ok: '#4caf7d', radius: 8 }
37
-
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)' }
38
36
  const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg }
39
37
  const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, cursor: 'pointer' }
38
+ const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
39
+ const boxS = box
40
+
41
+ let WORKSPACES = null // ctx.get('workspaces'), set in apply()
40
42
 
41
- // ── Settings → 远程工作区 ────────────────────────────────────────────────
43
+ // ── Settings → 远程工作区 (machine registry) ─────────────────────────────
42
44
  function RemoteWorkspacePage() {
43
- const [status, setStatus] = React.useState(null)
44
- const [form, setForm] = React.useState({ host: '', port: '22', username: '', password: '', privateKeyPath: '' })
45
+ const [machines, setMachines] = React.useState([])
46
+ const [currentId, setCurrentId] = React.useState('')
47
+ const [form, setForm] = React.useState({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' })
45
48
  const [busy, setBusy] = React.useState(false)
46
- const [browse, setBrowse] = React.useState({ path: '', out: '' })
47
- const [error, setError] = React.useState('')
48
- const [okMsg, setOkMsg] = React.useState('')
49
-
50
- const loadStatus = () => api('GET', '/dsh-remote/status').then(setStatus).catch((e) => setError(String((e && e.message) || e)))
51
- React.useEffect(() => { loadStatus() }, [])
52
-
53
- const setField = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
54
-
55
- const connect = () => {
56
- setBusy(true); setError(''); setOkMsg('')
57
- api('POST', '/dsh-remote/connect', {
58
- host: form.host.trim(), port: Number(form.port) || 22, username: form.username.trim() || 'root',
59
- password: form.password || undefined, privateKeyPath: form.privateKeyPath.trim() || undefined,
60
- })
61
- .then((res) => { setStatus(res); if (res && res.workspace) setBrowse({ path: res.workspace, out: '' }); setOkMsg(res.ok ? '已连接 ✓ 可浏览远程目录。' : '') })
62
- .catch((e) => setError(String((e && e.message) || e)))
63
- .finally(() => setBusy(false))
64
- }
49
+ const [msg, setMsg] = React.useState('')
50
+ const [err, setErr] = React.useState('')
65
51
 
66
- const doBrowse = (p) => {
67
- const target = p !== undefined ? p : browse.path.trim()
68
- setBusy(true); setError('')
69
- api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(target || ''))
70
- .then((res) => setBrowse({ path: target, out: res.text }))
71
- .catch((e) => setError(String((e && e.message) || e)))
72
- .finally(() => setBusy(false))
73
- }
52
+ const refresh = () => api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setCurrentId(r.currentId || '') })
53
+ React.useEffect(() => { refresh() }, [])
74
54
 
75
- const setWorkspace = () => {
76
- const p = browse.path.trim(); if (!p) return
77
- setBusy(true); setError(''); setOkMsg('')
78
- api('POST', '/dsh-remote/workspace', { path: p })
79
- .then((res) => { setStatus(res); setOkMsg('远程工作区已设为 ' + p) })
80
- .catch((e) => setError(String((e && e.message) || e)))
81
- .finally(() => setBusy(false))
55
+ const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
56
+ const startEdit = (m) => setForm({ name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root', password: '', privateKeyPath: m.privateKeyPath || '', id: m.id })
57
+ const save = (action) => {
58
+ setBusy(true); setErr(''); setMsg('')
59
+ if (action === 'delete') {
60
+ api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg('已删除') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
61
+ return
62
+ }
63
+ api('POST', '/dsh-remote/machines', {
64
+ action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
65
+ username: form.username, password: form.password, privateKeyPath: form.privateKeyPath,
66
+ }).then((r) => { refresh(); setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
82
67
  }
68
+ const useNow = (id) => { setBusy(true); api('POST', '/dsh-remote/current', { id }).then((r) => { setCurrentId(r.currentId); setMsg('已切换为当前远程机') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
83
69
 
84
- const row = (t, v, k) => React.createElement(
85
- 'div', { key: k },
86
- React.createElement('label', { style: { fontSize: 12, opacity: 0.75 } }, t + ': '), v,
87
- )
70
+ const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center' } },
71
+ React.createElement('label', { style: { width: 76, fontSize: 12, opacity: 0.8 } }, label), ctrl)
88
72
 
89
- return React.createElement(
90
- 'div',
91
- { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 720 } },
92
- React.createElement('div', { style: { fontWeight: 600, fontSize: 15 } }, '远程工作区(dsh-remote)'),
73
+ return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 760 } },
74
+ React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
93
75
  React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
94
- '连接远程主机后即可用 rw_* 工具操作;选中的远程目录会镜像成一个本地工作区(可与本地工作区并存)。'),
95
- status
96
- ? React.createElement('div', { style: { display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 13 } },
97
- React.createElement('span', null, '远程: ', React.createElement('code', null, (status.username || '?') + '@' + (status.host || '?'))),
98
- React.createElement('span', null, '连接: ', React.createElement('code', null, status.connected ? '是' : '否')),
99
- React.createElement('span', null, '工作区: ', React.createElement('code', null, status.workspace || '(未设置)')),
100
- status.localMirror ? React.createElement('span', null, '本地镜像: ', React.createElement('code', null, status.localMirror)) : null,
101
- )
102
- : null,
103
- React.createElement('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 } },
104
- row('主机', React.createElement('input', { value: form.host, onChange: setField('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
105
- row('端口', React.createElement('input', { value: form.port, onChange: setField('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
106
- row('用户', React.createElement('input', { value: form.username, onChange: setField('username'), placeholder: 'root', style: inputS }), 'u'),
107
- row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setField('privateKeyPath'), placeholder: '如 C:/Users/me/.ssh/id_rsa', style: inputS }), 'k'),
108
- row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setField('password'), placeholder: 'SSH 无 key 时用', style: inputS }), 'w'),
76
+ '维护多台 SSH 机器。路径不在设置里配置 —— 新建/选择工作区时,「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
77
+ React.createElement('div', { style: boxS },
78
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
79
+ machines.length
80
+ ? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
81
+ React.createElement('div', { style: { flex: 1, fontSize: 13 } },
82
+ m.name + ' ',
83
+ React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
84
+ m.passwordSet ? ' 🔒' : '',
85
+ m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
86
+ React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
87
+ React.createElement('button', { style: buttonS, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
88
+ : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
89
+ ),
90
+ React.createElement('div', { style: boxS },
91
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
92
+ row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
93
+ row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
94
+ row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
95
+ row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
96
+ row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显、仅保存)', style: inputS }), 'w'),
97
+ row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用默认 key', style: inputS }), 'k'),
98
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
99
+ msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
100
+ form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
101
+ React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr('') } }, form.id ? '取消编辑' : '清空'),
102
+ React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
103
+ ),
109
104
  ),
110
- React.createElement('button', { onClick: connect, disabled: busy, style: { ...buttonS, alignSelf: 'flex-start' } }, busy ? '连接中…' : '连接远程'),
111
- React.createElement('div', { style: { display: 'flex', gap: 8 } },
112
- React.createElement('input', { value: browse.path, onChange: (e) => setBrowse({ ...browse, path: e.target.value }), placeholder: '远程路径,如 /home/dev/project', style: inputS }),
113
- React.createElement('button', { onClick: () => doBrowse(), disabled: busy, style: buttonS }, '列目录'),
114
- React.createElement('button', { onClick: setWorkspace, disabled: busy || !browse.path.trim(), style: buttonS }, '设为远程工作区'),
115
- ),
116
- error ? React.createElement('div', { style: { color: T.danger, whiteSpace: 'pre-wrap', fontSize: 13 } }, error) : null,
117
- okMsg ? React.createElement('div', { style: { color: T.ok, fontSize: 13 } }, okMsg) : null,
118
- browse.out
119
- ? React.createElement('pre', { style: { fontSize: 12, background: T.bg, padding: 10, borderRadius: T.radius, maxHeight: 320, overflow: 'auto', whiteSpace: 'pre-wrap' } }, browse.out)
120
- : null,
105
+ err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
121
106
  )
122
107
  }
123
108
 
124
- // ── Unified workspace directory picker (fills the directory-flow holes) ──
125
- let LOCAL_WORKSPACES = null // ctx.workspaces, bound in apply()
126
-
109
+ // ── Unified picker (fill the directory-flow holes) ───────────────────
127
110
  function parseLs(text) {
128
111
  const out = []
129
112
  for (const ln of String(text || '').split('\n')) {
130
- const m = ln.match(/^(d[-r][rwx-]{8})\s+[^ ]+\s+[^ ]+\s+[^ ]+\s+[^ ]+\s+\d{4,}-\d{2}-\d{2}([T ]+\S+)?\s+(.+)$/)
113
+ const m = ln.match(/^(d[-r][rwx-]{9})\s+[^ ]+\s+[^ ]+\s+[^ ]+\s+[^ ]+\s+\d{4}-\d{2}-\d{2}([T ]+\S+)?\s+(.+)$/)
131
114
  if (!m) continue
132
115
  out.push((m[1].charAt(0) === 'd' ? '📁' : '📄') + m[3])
133
116
  }
@@ -138,100 +121,105 @@ window.__ModuleLoader__.load({
138
121
  function DirPicker(props) {
139
122
  const { open, busy, onPicked, onCancel } = props
140
123
  const [tab, setTab] = React.useState('local')
124
+ const [machines, setMachines] = React.useState([])
125
+ const [machineId, setMachineId] = React.useState('')
141
126
  const [path, setPath] = React.useState('')
142
127
  const [items, setItems] = React.useState(null)
143
128
  const [err, setErr] = React.useState('')
144
129
  const [loading, setLoading] = React.useState(false)
145
130
 
146
- const load = (target, useLocal) => {
147
- const p = target !== undefined ? target : path
148
- setLoading(true); setErr('')
149
- if (useLocal) {
150
- const w = LOCAL_WORKSPACES || {}
151
- const list = w.listDirectory || (() => Promise.reject(new Error('本机目录服务不可用')))
152
- Promise.resolve(list(p || undefined)).then((lv) => {
153
- const names = (lv && lv.entries) || (lv && lv.children) || (lv && lv.list) || []
154
- setPath(p || (lv && lv.path) || '')
155
- setItems((Array.isArray(names) ? names : []).map((e) => (e && e.name ? e.name : String(e))))
156
- }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setLoading(false))
131
+ 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])
132
+
133
+ const chooseLocal = () => {
134
+ const w = WORKSPACES || {}
135
+ const pick = w.pickDirectory || w.openDirectory || w.pickPath
136
+ if (typeof pick === 'function') {
137
+ setLoading(true); setErr('')
138
+ Promise.resolve(pick()).then((p) => { if (p) onPicked(String(p)); }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setLoading(false))
157
139
  } else {
158
- api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')).then((res) => {
159
- setPath(res && res.path ? res.path : p)
160
- setItems(parseLs(res && res.text))
161
- }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setLoading(false))
140
+ setErr('本机系统选择器不可用,请在对话框中输入本机路径或使用远程tab')
162
141
  }
163
142
  }
164
143
 
165
- React.useEffect(() => { if (open) load('', tab === 'local') }, [open])
166
-
167
- const switchTab = (t) => { setTab(t); setPath(''); setItems(null); setErr(''); load('', t === 'local') }
168
- const goUp = () => {
169
- const sep = tab === 'local' ? '\\' : '/'
170
- const idx = String(path).replace(/[\\/]+$/, '').lastIndexOf(sep)
171
- const parent = idx <= 0 ? (tab === 'local' ? (path.slice(0, 3) || 'C:\\') : '/') : String(path).slice(0, idx)
172
- load(parent, tab === 'local')
144
+ const switchTab = (t) => { setTab(t); setErr(''); if (t === 'remote' && machineId) loadRemote(machineId, '') }
145
+ const useMachine = (id) => { setMachineId(id); loadRemote(id, '') }
146
+ const loadRemote = (id, target) => {
147
+ setLoading(true); setErr('')
148
+ api('POST', '/dsh-remote/current', { id }).catch(() => {})
149
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(target || '')))
150
+ .then((res) => { setPath(res && res.path ? res.path : target || ''); setItems(parseLs(res && res.text)) })
151
+ .catch((e) => setErr(String((e && e.message) || e)))
152
+ .finally(() => setLoading(false))
153
+ }
154
+ const enter = (n) => {
155
+ const base = String(path).replace(/\/+$/, '')
156
+ const next = (base ? base : '') + '/' + String(n).replace(/^📁/, '')
157
+ loadRemote(machineId, next)
173
158
  }
174
- const enter = (name) => {
175
- const sep = tab === 'local' ? '\\' : '/'
176
- const np = path === '' || path.endsWith(sep) ? path + name : path + sep + name
177
- load(np, tab === 'local')
159
+ const goUp = () => {
160
+ const base = String(path).replace(/\/+$/, '')
161
+ const idx = base.lastIndexOf('/')
162
+ loadRemote(machineId, idx <= 0 ? '/' : base.slice(0, idx))
178
163
  }
179
- const pickCurrent = () => {
180
- if (loading || busy) return
181
- if (tab === 'local') { onPicked(path); return }
182
- api('POST', '/dsh-remote/mirror', { path })
183
- .then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || '')))
184
- .catch((e) => setErr(String((e && e.message) || e)))
164
+ const pickRemote = () => {
165
+ if (busy || loading) return
166
+ 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)))
185
167
  }
186
168
 
187
169
  if (!open) return null
188
- const isDir = (label) => String(label || '').charAt(0) === '📁'
189
- const cell = { ...buttonS, textAlign: 'center' }
190
- const active = { ...cell, fontWeight: 700 }
191
- return React.createElement(
192
- 'div', { style: { padding: 14, minWidth: 440, maxWidth: 680 } },
170
+ const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
171
+ return React.createElement('div', { style: { padding: 14, minWidth: 460, maxWidth: 720 } },
193
172
  React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
194
- React.createElement('button', { onClick: () => switchTab('local'), style: tab === 'local' ? active : cell }, '本机'),
195
- React.createElement('button', { onClick: () => switchTab('remote'), style: tab === 'remote' ? active : cell }, '远程'),
196
- ),
197
- React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
198
- React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: tab === 'local' ? '本机目录路径' : '远程路径,如 /home/dev/project', style: inputS }),
199
- React.createElement('button', { onClick: () => load(path, tab === 'local'), style: buttonS }, '载入'),
200
- React.createElement('button', { onClick: goUp, style: buttonS }, '上一级'),
201
- ),
202
- err ? React.createElement('div', { style: { color: T.danger, marginBottom: 6, fontSize: 12 } }, err) : null,
203
- React.createElement(
204
- 'div', { style: { border: '1px solid ' + T.border, borderRadius: T.radius, padding: 8, maxHeight: 280, overflow: 'auto', fontSize: 13 } },
205
- loading
206
- ? '加载中…'
207
- : (items && items.length
208
- ? items.slice(0, 400).map((n, i) => React.createElement('div', { key: i, onClick: () => (isDir(n) ? enter(n.slice(2)) : null), style: { padding: '3px 6px', borderRadius: 4, cursor: isDir(n) ? 'pointer' : 'default', color: isDir(n) ? T.ok : 'inherit' } }, isDir(n) ? n : ' ' + n))
209
- : React.createElement('div', { style: { opacity: 0.6 } }, items === null ? '(输入路径后点「载入」)' : '(空)')),
210
- ),
211
- React.createElement('div', { style: { display: 'flex', gap: 8, marginTop: 8, justifyContent: 'flex-end' } },
212
- React.createElement('button', { onClick: onCancel, style: { background: 'transparent' } }, '取消'),
213
- React.createElement('button', { onClick: pickCurrent, disabled: loading || busy || !path.trim(), style: buttonS }, busy ? '处理中…' : tab === 'local' ? '选用此目录' : '设为远程工作区'),
173
+ tabBtn('local', '本机'),
174
+ tabBtn('remote', '远程'),
214
175
  ),
176
+ tab === 'local'
177
+ ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
178
+ React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '点击按钮打开系统文件夹选择器,选中本地目录即作为本地工作区。'),
179
+ React.createElement('button', { style: buttonS, onClick: chooseLocal, disabled: loading }, '打开系统文件夹选择器'),
180
+ )
181
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
182
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
183
+ React.createElement('label', { style: { fontSize: 12, opacity: 0.8 } }, '远程机器:'),
184
+ React.createElement('select', { value: machineId, onChange: (e) => useMachine(e.target.value), style: inputS },
185
+ React.createElement('option', { value: '' }, '— 选择 —'),
186
+ machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, m.name + ' (' + m.username + '@' + m.host + ')')),
187
+ ),
188
+ ),
189
+ React.createElement('div', { style: { display: 'flex', gap: 6 } },
190
+ React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '远程目录,如 /home/dev/project', style: inputS }),
191
+ React.createElement('button', { style: buttonS, onClick: () => loadRemote(machineId, path), disabled: !machineId || loading }, '载入'),
192
+ React.createElement('button', { style: buttonS, onClick: goUp, disabled: !machineId || loading }, '上一级'),
193
+ ),
194
+ err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
195
+ React.createElement('div', { style: boxS, ...{ minHeight: 150 } },
196
+ loading ? '加载中…'
197
+ : (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 } }, '先选机器,再点「载入」或输入路径')),
198
+ ),
199
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } },
200
+ React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消'),
201
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: pickRemote, disabled: busy || loading || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
202
+ ),
203
+ ),
204
+ (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
215
205
  )
216
206
  }
217
207
 
218
208
  function apply(ctx) {
219
209
  const slots = ctx.get('slots')
220
210
  if (slots === undefined) return
221
- // workspaces is a client service: read it lazily via ctx.get (the direct
222
- // property form needs an inject declaration, which a bare plugin can't rely
223
- // on; the official picker injects the service, we resolve it here instead).
224
- LOCAL_WORKSPACES = (ctx && ctx.get && ctx.get('workspaces')) || null
211
+ WORKSPACES = (ctx && ctx.get && ctx.get('workspaces')) || null
225
212
  slots.inject('settings.section', () =>
226
213
  slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
227
214
  )
228
215
  slots.inject(
229
216
  'conversation.hero.workspace.directoryFlow',
230
- () =>
231
- slots.inject('sidebar.workspaces.directoryFlow', function* () {
217
+ () => slots.inject('sidebar.workspaces.directoryFlow',
218
+ function* () {
232
219
  yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
233
220
  yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
234
- }),
221
+ },
222
+ ),
235
223
  )
236
224
  }
237
225
 
package/lib/index.js CHANGED
@@ -193,6 +193,38 @@ async function pushTree(sftp, localDir, remoteDir, remoteBaseDir, maxFiles) {
193
193
  return { files, pushed }
194
194
  }
195
195
 
196
+ // ── persistent multi-machine registry ─────────────────────────────────────
197
+ const MACHINES_FILE = 'machines.json'
198
+ const machinesFile = () => path.join(homedir(), '.dsh', 'remote-workspaces', MACHINES_FILE)
199
+ function loadMachines() {
200
+ try {
201
+ const j = JSON.parse(readFileSync(machinesFile(), 'utf8'))
202
+ if (Array.isArray(j.list)) return { list: j.list, currentId: j.currentId || (j.list[0] && j.list[0].id) || null }
203
+ } catch {}
204
+ return { list: [], currentId: null }
205
+ }
206
+ function saveMachines(list, currentId) {
207
+ try { mkdirSync(path.dirname(machinesFile()), { recursive: true }) } catch {}
208
+ writeFileSync(machinesFile(), JSON.stringify({ list, currentId }, null, 2))
209
+ }
210
+ function sanitizeMachine(m) {
211
+ if (!m) return m
212
+ const { password, ...rest } = m
213
+ return { ...rest, passwordSet: !!(m.password && m.password.length) }
214
+ }
215
+ /** Apply a machine's fields onto the live config object (pool + tools read it). */
216
+ function applyMachine(config, m) {
217
+ if (!m) return
218
+ config.host = m.host
219
+ config.port = Number(m.port) || 22
220
+ config.username = m.username || ''
221
+ config.password = m.password || ''
222
+ config.privateKeyPath = m.privateKeyPath || ''
223
+ config.passphrase = m.passphrase || ''
224
+ config.workspace = m.workspace || (config.workspace || '')
225
+ }
226
+ function machineId() { return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6) }
227
+
196
228
  // ── SSH pool (key OR password) ──────────────────────────────────────────────
197
229
 
198
230
  class SshPool {
@@ -371,6 +403,34 @@ export async function apply(ctx, config) {
371
403
  const pool = new SshPool(config)
372
404
  ctx.effect(() => () => pool.close(), 'dsh-remote.close')
373
405
 
406
+ // ── machine registry (multi-host) ─────────────────────────────────────────
407
+ const store = loadMachines()
408
+ const machines = store.list
409
+ const machineIndex = (id) => machines.findIndex((m) => m.id === id)
410
+ const currentMachine = () => {
411
+ if (store.currentId) {
412
+ const i = machineIndex(store.currentId)
413
+ if (i >= 0) return machines[i]
414
+ }
415
+ // fall back to config-derived default (host set via cordis config)
416
+ if (config.host) return { id: machineId(), name: config.host, host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase }
417
+ return null
418
+ }
419
+ const setCurrent = (id) => {
420
+ const i = machineIndex(id)
421
+ if (i < 0) return false
422
+ store.currentId = id
423
+ saveMachines(machines, id)
424
+ applyMachine(config, machines[i])
425
+ pool.setTarget({ host: config.host, port: config.port, username: config.username, password: config.password, privateKeyPath: config.privateKeyPath, passphrase: config.passphrase })
426
+ return true
427
+ }
428
+ // If no stored current, adopt a CLI-provided default as the active machine.
429
+ {
430
+ const cur = currentMachine()
431
+ if (cur && cur.host) applyMachine(config, cur)
432
+ }
433
+
374
434
  const run = async (cmd, opts = {}) => {
375
435
  const res = await pool.exec(cmd, opts.timeoutMs)
376
436
  const parts = []
@@ -393,6 +453,8 @@ export async function apply(ctx, config) {
393
453
  connected: !!pool.client,
394
454
  workspace: wsPath(),
395
455
  localMirror: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : '',
456
+ currentId: store.currentId || null,
457
+ machines: machines.map(sanitizeMachine),
396
458
  })
397
459
 
398
460
  // ── tools ─────────────────────────────────────────────────────────────────
@@ -762,6 +824,69 @@ export async function apply(ctx, config) {
762
824
  }
763
825
  },
764
826
  },
827
+ {
828
+ kind: 'exact',
829
+ path: '/dsh-remote/machines',
830
+ handler: async (req, res) => {
831
+ if (req.method === 'GET') {
832
+ return sendJson(res, 200, { machines: machines.map(sanitizeMachine), currentId: store.currentId })
833
+ }
834
+ if (req.method === 'POST') {
835
+ try {
836
+ const body = JSON.parse((await readBody(req)) || '{}')
837
+ const action = body.action || 'add'
838
+ if (action === 'add' || action === 'update') {
839
+ const host = String(body.host || '').trim()
840
+ if (!host) return sendJson(res, 400, { ok: false, error: 'host required' })
841
+ const rec = {
842
+ id: body.id || machineId(),
843
+ name: String(body.name || '').trim() || host,
844
+ host,
845
+ port: Number(body.port) || 22,
846
+ username: String(body.username || '').trim() || 'root',
847
+ password: body.password || '',
848
+ privateKeyPath: String(body.privateKeyPath || '').trim(),
849
+ passphrase: body.passphrase || '',
850
+ workspace: String(body.workspace || '').trim(),
851
+ }
852
+ const i = machineIndex(rec.id)
853
+ if (i >= 0) machines[i] = rec; else machines.push(rec)
854
+ if (!store.currentId) { store.currentId = rec.id }
855
+ saveMachines(machines, store.currentId)
856
+ if (store.currentId === rec.id) applyMachine(config, rec)
857
+ return sendJson(res, 200, { ok: true, machine: sanitizeMachine(rec), machines: machines.map(sanitizeMachine), currentId: store.currentId })
858
+ }
859
+ if (action === 'delete') {
860
+ const i = machineIndex(String(body.id || ''))
861
+ if (i < 0) return sendJson(res, 404, { ok: false, error: 'machine not found' })
862
+ machines.splice(i, 1)
863
+ if (store.currentId === body.id) store.currentId = machines[0] ? machines[0].id : null
864
+ saveMachines(machines, store.currentId)
865
+ return sendJson(res, 200, { ok: true, machines: machines.map(sanitizeMachine), currentId: store.currentId })
866
+ }
867
+ return sendJson(res, 400, { ok: false, error: 'unknown action' })
868
+ } catch (err) {
869
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
870
+ }
871
+ }
872
+ return sendJson(res, 405, { ok: false, error: 'method not allowed' })
873
+ },
874
+ },
875
+ {
876
+ kind: 'exact',
877
+ path: '/dsh-remote/current',
878
+ handler: async (req, res) => {
879
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
880
+ try {
881
+ const body = JSON.parse((await readBody(req)) || '{}')
882
+ const okSet = setCurrent(String(body.id || ''))
883
+ if (!okSet) return sendJson(res, 404, { ok: false, error: 'machine not found' })
884
+ return sendJson(res, 200, { ok: true, ...status() })
885
+ } catch (err) {
886
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
887
+ }
888
+ },
889
+ },
765
890
  ]
766
891
 
767
892
  const disposers = routes.map((r) => webServer.register(r))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password or key), pick a remote workspace, operate on it with rw_pick_workspace / rw_list_dir / rw_read_file / rw_exec / rw_sync tools, and mirror it to a real local directory (SFTP) so the DSH native workspace can adopt it.",
5
5
  "keywords": [
6
6
  "deepseek-harness",