dsh-remote 0.5.8 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +133 -113
  2. package/README.zh.md +134 -114
  3. package/lib/client.js +466 -260
  4. package/lib/index.js +124 -12
  5. package/package.json +11 -2
package/lib/client.js CHANGED
@@ -1,261 +1,467 @@
1
- // dsh-remote — client half.
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.
5
- //
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.
13
- //
14
- // Client entries must be classic scripts registered via window.__ModuleLoader__.load
15
- // ({ id, factory }); the factory receives a synchronous `require`.
16
- window.__ModuleLoader__.load({
17
- id: 'dsh-remote',
18
- factory: (require) => {
19
- var module = { exports: {} }
20
- var exports = module.exports
21
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
22
-
23
- const React = require('react')
24
- const name = 'dsh-remote'
25
-
26
- async function api(method, path, body) {
27
- const opts = { method, headers: {} }
28
- if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
29
- const res = await fetch(path, opts)
30
- const data = await res.json().catch(() => ({}))
31
- if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
32
- return data
33
- }
34
-
35
- const T = { bg: 'rgba(128,128,128,0.07)', border: 'rgba(128,128,128,0.35)', danger: '#e06c75', ok: '#4caf7d', radius: 8, muted: 'rgba(128,128,128,0.7)' }
36
- const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg }
37
- const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, cursor: 'pointer' }
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()
42
-
43
- // ── Settings → 远程工作区 (machine registry) ─────────────────────────────
44
- function RemoteWorkspacePage() {
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: '' })
48
- const [busy, setBusy] = React.useState(false)
49
- const [msg, setMsg] = React.useState('')
50
- const [err, setErr] = React.useState('')
51
-
52
- const refresh = () => api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setCurrentId(r.currentId || '') })
53
- React.useEffect(() => { refresh() }, [])
54
-
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))
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)) }
69
- const del = (id) => { if (window.confirm('确定删除这台机器?')) { api('POST', '/dsh-remote/machines', { action: 'delete', id }).then(refresh).then(() => setMsg('已删除')).catch((e) => setErr(String((e && e.message) || e))) } }
70
-
71
- const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center' } },
72
- React.createElement('label', { style: { width: 76, fontSize: 12, opacity: 0.8 } }, label), ctrl)
73
-
74
- return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 760 } },
75
- React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
76
- React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
77
- '维护多台 SSH 机器。路径不在设置里配置 —— 新建/选择工作区时,「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
78
- React.createElement('div', { style: boxS },
79
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
80
- machines.length
81
- ? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
82
- React.createElement('div', { style: { flex: 1, fontSize: 13 } },
83
- m.name + ' ',
84
- React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
85
- m.passwordSet ? ' 🔒' : '',
86
- m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
87
- React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
88
- React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
89
- React.createElement('button', { style: buttonS, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
90
- : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
91
- ),
92
- React.createElement('div', { style: boxS },
93
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
94
- row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
95
- row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
96
- row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
97
- row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
98
- row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显、仅保存)', style: inputS }), 'w'),
99
- row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用默认 key', style: inputS }), 'k'),
100
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
101
- msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
102
- form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
103
- React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr('') } }, form.id ? '取消编辑' : '清空'),
104
- React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
105
- ),
106
- ),
107
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
108
- )
109
- }
110
-
111
- // ── Unified picker (fill the directory-flow holes) ───────────────────
112
- function parseLs(text) {
113
- const out = []
114
- for (const ln of String(text || '').split('\n')) {
115
- const t = ln.trim()
116
- if (!t || !t.length || /^total\b/i.test(t)) continue
117
- const parts = t.split(/\s+/).filter(Boolean)
118
- if (parts.length < 2) continue
119
- const mode = parts[0]
120
- const name = parts[parts.length - 1]
121
- if (name === '.' || name === '..') continue
122
- const isDirL = mode.charAt(0) === 'd'
123
- out.push((isDirL ? '📁' : '📄') + name)
124
- }
125
- return out
126
- }
127
-
128
- function DirPicker(props) {
129
- const { open, busy, onPicked, onCancel } = props
130
- const [tab, setTab] = React.useState('local')
131
- const [machines, setMachines] = React.useState([])
132
- const [machineId, setMachineId] = React.useState('')
133
- const [path, setPath] = React.useState('')
134
- const [items, setItems] = React.useState(null)
135
- const [err, setErr] = React.useState('')
136
- const [loading, setLoading] = React.useState(false)
137
- const [mOpen, setMOpen] = React.useState(false)
138
- const ddRef = React.useRef(null)
139
-
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])
148
-
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])
150
-
151
- const chooseLocal = () => {
152
- setLoading(true); setErr('')
153
- api('POST', '/dsh-remote/local-pick')
154
- .then((r) => {
155
- if (r && r.path) onPicked(String(r.path))
156
- else if (r && r.cancelled) setErr('已取消选择')
157
- else setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径')
158
- })
159
- .catch((e) => setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径'))
160
- .finally(() => setLoading(false))
161
- }
162
-
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('')
167
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
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))
172
- }
173
- const enter = (n) => {
174
- const base = String(path).replace(/\/+$/, '')
175
- const next = (base ? base : '') + '/' + String(n).replace(/^📁/, '')
176
- loadRemote(machineId, next)
177
- }
178
- const goUp = () => {
179
- const base = String(path).replace(/\/+$/, '')
180
- const idx = base.lastIndexOf('/')
181
- loadRemote(machineId, idx <= 0 ? '/' : base.slice(0, idx))
182
- }
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)))
186
- }
187
-
188
- if (!open) return null
189
- const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
190
- return React.createElement('div', { style: { padding: 14, minWidth: 460, maxWidth: 720 } },
191
- React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
192
- tabBtn('local', '本机'),
193
- tabBtn('remote', '远程'),
194
- ),
195
- tab === 'local'
196
- ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
197
- React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '系统选择器优先;不可用时直接输入本机目录。'),
198
- React.createElement('div', { style: { display: 'flex', gap: 6 } },
199
- React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
200
- React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
201
- ),
202
- React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
203
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
204
- )
205
- : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
206
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
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,
218
- ),
219
- ),
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 }, '上一级'),
224
- ),
225
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
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 ? '镜像中…' : '设为远程工作区'),
233
- ),
234
- ),
235
- (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
236
- )
237
- }
238
-
239
- function apply(ctx) {
240
- const slots = ctx.get('slots')
241
- if (slots === undefined) return
242
- WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
243
- slots.inject('settings.section', () =>
244
- slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
245
- )
246
- slots.inject(
247
- 'conversation.hero.workspace.directoryFlow',
248
- () => slots.inject('sidebar.workspaces.directoryFlow',
249
- function* () {
250
- yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
251
- yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
252
- },
253
- ),
254
- )
255
- }
256
-
257
- exports.name = name
258
- exports.apply = apply
259
- return module.exports
260
- },
1
+ // dsh-remote — client half.
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.
5
+ //
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.
13
+ //
14
+ // Client entries must be classic scripts registered via window.__ModuleLoader__.load
15
+ // ({ id, factory }); the factory receives a synchronous `require`.
16
+ window.__ModuleLoader__.load({
17
+ id: 'dsh-remote',
18
+ factory: (require) => {
19
+ var module = { exports: {} }
20
+ var exports = module.exports
21
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
22
+
23
+ const React = require('react')
24
+ const name = 'dsh-remote'
25
+
26
+ async function api(method, path, body) {
27
+ const opts = { method, headers: {} }
28
+ if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
29
+ const res = await fetch(path, opts)
30
+ const data = await res.json().catch(() => ({}))
31
+ if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
32
+ return data
33
+ }
34
+
35
+ // Theme via DSH design tokens so this follows the harness light/dark theme.
36
+ const v = (name, fb) => `var(${name}, ${fb})`
37
+ const T = {
38
+ bg: v('--dsw-alias-bg-layer-1', 'rgba(128,128,128,0.07)'),
39
+ bg2: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.10)'),
40
+ border: v('--dsw-alias-border-l2', 'rgba(128,128,128,0.35)'),
41
+ borderStrong: v('--dsw-alias-border-l3', 'rgba(128,128,128,0.5)'),
42
+ danger: v('--dsw-static-red-500', '#e06c75'),
43
+ dangerText: v('--dsw-static-red-400', '#e06c75'),
44
+ ok: v('--dsw-static-green-500', '#4caf7d'),
45
+ radius: 8,
46
+ muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
47
+ label: v('--dsw-alias-label-primary', '#e4e4e7'),
48
+ primary: v('--dsw-alias-button-primary-fill', '#2563eb'),
49
+ onPrimary: v('--dsw-alias-button-contrast-fill', '#fff'),
50
+ hoverBg: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.14)'),
51
+ }
52
+ const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
53
+ const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
54
+ const primaryBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: T.primary, color: T.onPrimary, cursor: 'pointer', fontWeight: 600 }
55
+ const ghostBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: 'transparent', color: T.label, cursor: 'pointer' }
56
+ const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
57
+ const boxS = box
58
+
59
+ let WORKSPACES = null // ctx.get('workspaces'), set in apply()
60
+
61
+ // ── Settings → 远程工作区 (machine registry) ─────────────────────────────
62
+ function RemoteWorkspacePage() {
63
+ const [machines, setMachines] = React.useState([])
64
+ const [currentId, setCurrentId] = React.useState('')
65
+ const [form, setForm] = React.useState({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' })
66
+ const [busy, setBusy] = React.useState(false)
67
+ const [msg, setMsg] = React.useState('')
68
+ const [err, setErr] = React.useState('')
69
+ const [tst, setTst] = React.useState('') // idle | testing | ok | failing
70
+
71
+ const testConn = () => {
72
+ if (!form.host.trim() || tst === 'testing') return
73
+ setTst('testing'); setErr(''); setMsg('')
74
+ api('POST', '/dsh-remote/test-connect', {
75
+ host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
76
+ password: form.password, privateKeyPath: form.privateKeyPath,
77
+ })
78
+ .then((r) => {
79
+ if (r && r.ok) { setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)` : '连接成功') }
80
+ else { setTst('failing'); setErr((r && r.error) || '连接失败') }
81
+ })
82
+ .catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
83
+ }
84
+
85
+ const refresh = () => api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setCurrentId(r.currentId || '') })
86
+ React.useEffect(() => { refresh() }, [])
87
+
88
+ const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
89
+ 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 })
90
+ const save = (action) => {
91
+ setBusy(true); setErr(''); setMsg('')
92
+ if (action === 'delete') {
93
+ 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))
94
+ return
95
+ }
96
+ api('POST', '/dsh-remote/machines', {
97
+ action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
98
+ username: form.username, password: form.password, privateKeyPath: form.privateKeyPath,
99
+ }).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))
100
+ }
101
+ 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)) }
102
+ const del = (id) => { if (window.confirm('确定删除这台机器?')) { api('POST', '/dsh-remote/machines', { action: 'delete', id }).then(refresh).then(() => setMsg('已删除')).catch((e) => setErr(String((e && e.message) || e))) } }
103
+
104
+ const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center' } },
105
+ React.createElement('label', { style: { width: 76, fontSize: 12, opacity: 0.8 } }, label), ctrl)
106
+
107
+ return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 760 } },
108
+ React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
109
+ React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
110
+ '维护多台 SSH 机器。路径不在设置里配置 —— 新建/选择工作区时,「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
111
+ React.createElement('div', { style: boxS },
112
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
113
+ machines.length
114
+ ? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
115
+ React.createElement('div', { style: { flex: 1, fontSize: 13 } },
116
+ m.name + ' ',
117
+ React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
118
+ m.passwordSet ? ' 🔒' : '',
119
+ m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
120
+ React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
121
+ React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
122
+ React.createElement('button', { style: buttonS, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
123
+ : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
124
+ ),
125
+ React.createElement('div', { style: boxS },
126
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
127
+ row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
128
+ row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
129
+ row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
130
+ row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
131
+ row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显、仅保存)', style: inputS }), 'w'),
132
+ row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用默认 key', style: inputS }), 'k'),
133
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
134
+ msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
135
+ 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(''); setTst('idle') } }, form.id ? '取消编辑' : '清空'),
137
+ React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
138
+ tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
139
+ React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
140
+ ),
141
+ ),
142
+ err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
143
+ )
144
+ }
145
+
146
+ // ── Unified picker (fill the directory-flow holes) ───────────────────
147
+ function parseLs(text) {
148
+ const out = []
149
+ for (const ln of String(text || '').split('\n')) {
150
+ const t = ln.trim()
151
+ if (!t || !t.length || /^total\b/i.test(t)) continue
152
+ const parts = t.split(/\s+/).filter(Boolean)
153
+ // ls -l row: mode links owner group size month day [hh:mm|year] name...
154
+ // NAME starts at column 8 (everything before it is the fixed metadata).
155
+ if (parts.length < 8) continue
156
+ const mode = parts[0]
157
+ const name = parts.slice(8).join(' ')
158
+ if (!name || name === '.' || name === '..') continue
159
+ const isDir = mode.charAt(0) === 'd'
160
+ out.push({ name, dir: isDir })
161
+ }
162
+ return out
163
+ }
164
+
165
+ // Clickable breadcrumb of the current remote path; clicking a segment jumps
166
+ // to that ancestor level (finite-width, one line, ellipsized at the front).
167
+ function breadcrumb(active, cur, jumpTo) {
168
+ const norm = String(cur || '').replace(/\/+$/, '')
169
+ const segs = norm === '' || norm === '/' ? [] : norm.split('/')
170
+ const crumbs = []
171
+ crumbs.push(React.createElement('span', { key: 'root', style: { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted }, onClick: active ? () => jumpTo('/') : undefined }, '/'))
172
+ let acc = ''
173
+ for (const s of segs) {
174
+ if (!s) continue
175
+ acc += '/' + s
176
+ crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
177
+ crumbs.push(React.createElement('span', {
178
+ key: acc,
179
+ style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === norm ? 700 : 400, whiteSpace: 'nowrap' },
180
+ onClick: active ? () => jumpTo(acc) : undefined,
181
+ }, s))
182
+ }
183
+ if (!crumbs.length) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, '/'))
184
+ return React.createElement('span', { key: 'crumb' }, crumbs)
185
+ }
186
+
187
+ function DirPicker(props) {
188
+ const { open, busy, onPicked, onCancel } = props
189
+ const [tab, setTab] = React.useState('local')
190
+ const [machines, setMachines] = React.useState([])
191
+ const [machineId, setMachineId] = React.useState('')
192
+ const [path, setPath] = React.useState('')
193
+ const [items, setItems] = React.useState(null)
194
+ const [err, setErr] = React.useState('')
195
+ const [loading, setLoading] = React.useState(false)
196
+ // 级联下钻状态:每一格是 { path, dirs } —— dirs 是该路径下的目录列表。
197
+ const [levels, setLevels] = React.useState(null)
198
+ const [popOpen, setPopOpen] = React.useState(false)
199
+ const [suggest, setSuggest] = React.useState([])
200
+ const [suggestOpen, setSuggestOpen] = React.useState(false)
201
+ const suggestTimer = React.useRef(null)
202
+ // 机器下拉:点外部任意处关闭,避免候选层常驻遮挡下方按钮
203
+ const [mOpen, setMOpen] = React.useState(false)
204
+ const ddRef = React.useRef(null)
205
+ React.useEffect(() => {
206
+ if (!mOpen) return
207
+ const onDown = (ev) => { if (ddRef.current && !ddRef.current.contains(ev.target)) setMOpen(false) }
208
+ document.addEventListener('mousedown', onDown)
209
+ return () => document.removeEventListener('mousedown', onDown)
210
+ }, [mOpen])
211
+
212
+ const loadLevels = (id, p, toIndex) => {
213
+ if (!id) return
214
+ setLoading(true); setErr('')
215
+ api('POST', '/dsh-remote/current', { id }).catch(() => {})
216
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
217
+ .then((res) => {
218
+ const real = res && res.path ? res.path : (p || '')
219
+ // server returns [{ type:'dir'|'file', name }]; entering a dir is
220
+ // decided by type dir (server already follows symlinks to dirs).
221
+ const list = Array.isArray(res && res.items)
222
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
223
+ : parseLs(res && res.text)
224
+ const node = { path: real, dirs: list.filter((it) => it.dir), all: list }
225
+ setLevels((prev) => {
226
+ const base = prev && prev.length ? prev.slice() : []
227
+ // toIndex >= 0 → put this node at that position, truncating below it.
228
+ let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
229
+ if (idx >= base.length) return base.concat([node])
230
+ base[idx] = node
231
+ return base.slice(0, idx + 1)
232
+ })
233
+ })
234
+ .catch((e) => setErr(String((e && e.message) || e)))
235
+ .finally(() => setLoading(false))
236
+ }
237
+
238
+ 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])
239
+
240
+ const chooseLocal = () => {
241
+ setLoading(true); setErr('')
242
+ api('POST', '/dsh-remote/local-pick')
243
+ .then((r) => {
244
+ if (r && r.path) onPicked(String(r.path))
245
+ else if (r && r.cancelled) setErr('已取消选择')
246
+ else setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径')
247
+ })
248
+ .catch((e) => setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径'))
249
+ .finally(() => setLoading(false))
250
+ }
251
+
252
+ const switchTab = (t) => {
253
+ setTab(t); setErr('')
254
+ if (t === 'remote') {
255
+ if (machineId) loadLevels(machineId, '', 0)
256
+ // Prime the field with the machine's root so the user immediately gets
257
+ // a '/'-level completion list without typing anything.
258
+ if (!path.trim()) { setPath('/'); loadSuggestions('/') }
259
+ }
260
+ }
261
+
262
+ // enterDir(name): drive into the named subdir of the current deepest level,
263
+ // appending that directory as the new deepest level (path is pieced together).
264
+ const enterDir = (name) => {
265
+ if (busy || loading) return
266
+ const last = levels && levels.length ? levels[levels.length - 1] : null
267
+ const base = last && last.path ? last.path : ''
268
+ const next = base === '/' ? '/' + name : (base ? base : '') + '/' + name
269
+ loadLevels(machineId, next, (levels ? levels.length : 0))
270
+ }
271
+
272
+ // Autocomplete: given a partial remote path, list children of its parent dir
273
+ // that start with the last segment (powered by the structured ls endpoint).
274
+ const loadSuggestions = (raw, mid) => {
275
+ const id = mid || machineId
276
+ if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
277
+ const t = String(raw || '').trim()
278
+ if (!t) { setSuggest([]); setSuggestOpen(false); return }
279
+ const slash = t.lastIndexOf('/')
280
+ const parent = slash <= 0 ? '/' : t.slice(0, slash)
281
+ const lastSeg = slash < 0 ? t : t.slice(slash + 1)
282
+ api('POST', '/dsh-remote/current', { id }).catch(() => {})
283
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
284
+ .then((res) => {
285
+ const list = Array.isArray(res && res.items)
286
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
287
+ : parseLs(res && res.text)
288
+ const base = parent === '/' ? '/' : parent
289
+ const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
290
+ .slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + '/' + it.name))
291
+ setSuggest(matches)
292
+ setSuggestOpen(!!matches.length)
293
+ }).catch(() => { setSuggest([]); setSuggestOpen(false) })
294
+ }
295
+
296
+ const onPathChange = (raw) => {
297
+ setPath(raw); setErr('')
298
+ if (suggestTimer.current) clearTimeout(suggestTimer.current)
299
+ suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
300
+ }
301
+
302
+ // After a directory is chosen, immediately reveal the next level: list the
303
+ // chosen directory's children as fresh completions (no keystroke needed).
304
+ const continueSuggest = (dir) => {
305
+ if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
306
+ setSuggestOpen(false)
307
+ api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
308
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/\/+$/, '') || '/')))
309
+ .then((res) => {
310
+ const list = Array.isArray(res && res.items)
311
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
312
+ : parseLs(res && res.text)
313
+ const base = String(dir).replace(/\/+$/, '') === '' ? '/' : String(dir).replace(/\/+$/, '')
314
+ const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + '/' + it.name))
315
+ setSuggest(kids)
316
+ setSuggestOpen(!!kids.length)
317
+ }).catch(() => { setSuggest([]); setSuggestOpen(false) })
318
+ }
319
+
320
+ // Choose a suggestion: fill the field and immediately open its next level.
321
+ const selectSuggestion = (s) => {
322
+ setPath(s); setErr(''); setSuggestOpen(false)
323
+ continueSuggest(s)
324
+ }
325
+
326
+ // Commit an explicit remote path as the workspace (race from the input).
327
+ const commitPath = (p) => {
328
+ const target = String(p || '').trim()
329
+ if (!target || !machineId || busy) return
330
+ setPopOpen(false); setSuggestOpen(false)
331
+ 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)))
332
+ }
333
+
334
+ // Confirm the highlighted directory from the browser popup by filling the
335
+ // path input (not committing), so the user can review/edit before commit.
336
+ const acceptBrowserPick = (p) => {
337
+ setPath(String(p || ''))
338
+ setSuggestOpen(false)
339
+ setPopOpen(false)
340
+ }
341
+
342
+ function renderDirPopup() {
343
+ if (!levels || !levels.length) {
344
+ return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
345
+ }
346
+ const last = levels[levels.length - 1]
347
+ const entries = last.all || []
348
+ // Floating overlay: pinned to viewport, covers the dialog background so
349
+ // it never stretches the surrounding layout. The picker panel is a fixed
350
+ // (height-capped) box whose directory list scrolls internally.
351
+ return React.createElement('div', {
352
+ style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
353
+ onClick: () => setPopOpen(false),
354
+ },
355
+ 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() },
356
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
357
+ 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 }, '回上一级 ▴'),
358
+ 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 }))),
359
+ React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
360
+ ),
361
+ React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
362
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
363
+ : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
364
+ key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
365
+ onClick: it.dir ? () => enterDir(it.name) : undefined,
366
+ 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 },
367
+ },
368
+ React.createElement('span', null, it.dir ? '📁' : '📄'),
369
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
370
+ )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
371
+ ),
372
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
373
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
374
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => acceptBrowserPick(last.path) }, '选用此路径'),
375
+ ),
376
+ ),
377
+ )
378
+ }
379
+
380
+ if (!open) return null
381
+ const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
382
+ // The picker is a full-viewport centered modal (backdrop + panel), so it
383
+ // renders identically in the narrow sidebar and in the conversation and
384
+ // is never squeezed into a cramped in-place column.
385
+ 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() } },
386
+ 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() },
387
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
388
+ React.createElement('div', null, '选择工作目录'),
389
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
390
+ ),
391
+ React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
392
+ tabBtn('local', '本机'),
393
+ tabBtn('remote', '远程'),
394
+ ),
395
+ tab === 'local'
396
+ ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
397
+ React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '系统选择器优先;不可用时直接输入本机目录。'),
398
+ React.createElement('div', { style: { display: 'flex', gap: 6 } },
399
+ React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
400
+ React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
401
+ ),
402
+ React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
403
+ err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
404
+ )
405
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
406
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
407
+ React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
408
+ React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
409
+ React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((v) => !v) },
410
+ (machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
411
+ ),
412
+ mOpen
413
+ ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 300, maxHeight: 260, overflow: 'auto', background: '#1f1f23', border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
414
+ machines.length
415
+ ? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setLevels(null); if (m.id) { loadLevels(m.id, '', 0); if (!path.trim()) { setPath('/'); loadSuggestions('/', m.id) } } setMOpen(false) }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
416
+ : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
417
+ )
418
+ : null,
419
+ ),
420
+ ),
421
+ // 路径输入框(带自动补全)+ 打开浏览弹层按钮
422
+ React.createElement('div', { style: { position: 'relative', display: 'flex', gap: 6 } },
423
+ React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
424
+ React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
425
+ // 自动补全下拉
426
+ (suggestOpen && suggest.length)
427
+ ? 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)' } },
428
+ 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)),
429
+ )
430
+ : null,
431
+ ),
432
+ err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
433
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
434
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
435
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
436
+ ),
437
+ // 悬浮浏览弹层:选中的路径回填到输入框(不直接提交)
438
+ popOpen ? renderDirPopup() : null,
439
+ ),
440
+ (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
441
+ ),
442
+ )
443
+ }
444
+
445
+ function apply(ctx) {
446
+ const slots = ctx.get('slots')
447
+ if (slots === undefined) return
448
+ WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
449
+ slots.inject('settings.section', () =>
450
+ slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
451
+ )
452
+ slots.inject(
453
+ 'conversation.hero.workspace.directoryFlow',
454
+ () => slots.inject('sidebar.workspaces.directoryFlow',
455
+ function* () {
456
+ yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
457
+ yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
458
+ },
459
+ ),
460
+ )
461
+ }
462
+
463
+ exports.name = name
464
+ exports.apply = apply
465
+ return module.exports
466
+ },
261
467
  })