dsh-remote 0.3.3 → 0.4.1
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/lib/client.js +146 -154
- package/lib/index.js +125 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// dsh-remote — client half.
|
|
2
|
-
// Settings →
|
|
3
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 [
|
|
44
|
-
const [
|
|
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 [
|
|
47
|
-
const [
|
|
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
|
|
67
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.catch((e) =>
|
|
81
|
-
|
|
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 = (
|
|
85
|
-
'
|
|
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
|
-
'
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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('
|
|
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
|
|
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-]{
|
|
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,109 @@ 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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
|
|
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
|
|
180
|
-
if (
|
|
181
|
-
|
|
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
|
|
189
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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('div', { style: { display: 'flex', gap: 6 } },
|
|
180
|
+
React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
|
|
181
|
+
React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
|
|
182
|
+
),
|
|
183
|
+
React.createElement('button', { style: buttonS, onClick: chooseLocal, disabled: loading }, '打开系统文件夹选择器'),
|
|
184
|
+
)
|
|
185
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
186
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
187
|
+
React.createElement('label', { style: { fontSize: 12, opacity: 0.8 } }, '远程机器:'),
|
|
188
|
+
React.createElement('select', { value: machineId, onChange: (e) => useMachine(e.target.value), style: inputS },
|
|
189
|
+
React.createElement('option', { value: '' }, '— 选择 —'),
|
|
190
|
+
machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, m.name + ' (' + m.username + '@' + m.host + ')')),
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
194
|
+
React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '远程目录,如 /home/dev/project', style: inputS }),
|
|
195
|
+
React.createElement('button', { style: buttonS, onClick: () => loadRemote(machineId, path), disabled: !machineId || loading }, '载入'),
|
|
196
|
+
React.createElement('button', { style: buttonS, onClick: goUp, disabled: !machineId || loading }, '上一级'),
|
|
197
|
+
),
|
|
198
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
199
|
+
React.createElement('div', { style: boxS, ...{ minHeight: 150 } },
|
|
200
|
+
loading ? '加载中…'
|
|
201
|
+
: (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 } }, '先选机器,再点「载入」或输入路径')),
|
|
202
|
+
),
|
|
203
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } },
|
|
204
|
+
React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消'),
|
|
205
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: pickRemote, disabled: busy || loading || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
(tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
|
|
215
209
|
)
|
|
216
210
|
}
|
|
217
211
|
|
|
218
212
|
function apply(ctx) {
|
|
219
213
|
const slots = ctx.get('slots')
|
|
220
214
|
if (slots === undefined) return
|
|
221
|
-
|
|
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
|
|
215
|
+
WORKSPACES = (ctx && ctx.get && ctx.get('workspaces')) || null
|
|
225
216
|
slots.inject('settings.section', () =>
|
|
226
217
|
slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
|
|
227
218
|
)
|
|
228
219
|
slots.inject(
|
|
229
220
|
'conversation.hero.workspace.directoryFlow',
|
|
230
|
-
() =>
|
|
231
|
-
|
|
221
|
+
() => slots.inject('sidebar.workspaces.directoryFlow',
|
|
222
|
+
function* () {
|
|
232
223
|
yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
|
|
233
224
|
yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
|
|
234
|
-
}
|
|
225
|
+
},
|
|
226
|
+
),
|
|
235
227
|
)
|
|
236
228
|
}
|
|
237
229
|
|
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
|
+
"version": "0.4.1",
|
|
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",
|