dsh-remote 0.8.8 → 0.8.10

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 CHANGED
@@ -1,1310 +1,1496 @@
1
- // dsh-remote — client half.
2
- // Settings → 远程工作区: a multi-machine SSH registry (add/edit/select current;
3
- // passphrase / host-key mode / proxy jump / agent / keychain password; password
4
- // stored host-side and kept private). Plus a port-forward panel, the command
5
- // audit log view, quick workspace switching, and an ~/.ssh/config importer.
6
- //
7
- // A unified workspace directory picker fills ui-workspace's two directory-flow
8
- // holes (sidebar + conversation hero):
9
- // • 本机 tab → opens the NATIVE OS folder chooser (ctx.workspaces.pickDirectory)
10
- // and returns the picked local path (works with local workspaces).
11
- // • 远程 tab → pick a machine (dropdown), list its directories over
12
- // /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
13
- // real LOCAL mirror → onPicked(localMirror) so host adopts it as a workspace.
14
- //
15
- // better-sidebar integration (optional): a live remote file explorer tab and a
16
- // read-only-by-default remote file tab with an explicit「编辑 → 保存到远程」flow
17
- // (mtime optimistic lock via POST /dsh-remote/write).
18
- //
19
- // Client entries must be classic scripts registered via window.__ModuleLoader__.load
20
- // ({ id, factory }); the factory receives a synchronous `require`.
21
- window.__ModuleLoader__.load({
22
- id: 'dsh-remote',
23
- factory: (require) => {
24
- var module = { exports: {} }
25
- var exports = module.exports
26
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
27
-
28
- const React = require('react')
29
- const name = 'dsh-remote'
30
-
31
- async function api(method, path, body) {
32
- const opts = { method, headers: {} }
33
- if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
34
- const res = await fetch(path, opts)
35
- const data = await res.json().catch(() => ({}))
36
- if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
37
- return data
38
- }
39
-
40
- // Like api() but returns {status, data} so callers can handle 409 etc.
41
- async function apiRaw(method, path, body) {
42
- const opts = { method, headers: {} }
43
- if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
44
- const res = await fetch(path, opts)
45
- const data = await res.json().catch(() => ({}))
46
- return { status: res.status, data }
47
- }
48
-
49
- // Theme via DSH design tokens so this follows the harness light/dark theme.
50
- const v = (name, fb) => `var(${name}, ${fb})`
51
- const T = {
52
- bg: v('--dsw-alias-bg-layer-1', 'rgba(128,128,128,0.07)'),
53
- bg2: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.10)'),
54
- border: v('--dsw-alias-border-l2', 'rgba(128,128,128,0.35)'),
55
- borderStrong: v('--dsw-alias-border-l3', 'rgba(128,128,128,0.5)'),
56
- danger: v('--dsw-static-red-500', '#e06c75'),
57
- dangerText: v('--dsw-static-red-400', '#e06c75'),
58
- ok: v('--dsw-static-green-500', '#4caf7d'),
59
- warn: v('--dsw-static-yellow-500', '#e6c07b'),
60
- radius: 8,
61
- muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
62
- label: v('--dsw-alias-label-primary', '#e4e4e7'),
63
- primary: v('--dsw-alias-button-primary-fill', '#2563eb'),
64
- onPrimary: v('--dsw-alias-button-contrast-fill', '#fff'),
65
- hoverBg: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.14)'),
66
- }
67
- const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
68
- const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
69
- const primaryBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: T.primary, color: T.onPrimary, cursor: 'pointer', fontWeight: 600 }
70
- const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
71
- const boxS = box
72
-
73
- function humanSize(n) {
74
- if (n == null) return ''
75
- if (n < 1024) return n + ' B'
76
- const units = ['KB', 'MB', 'GB', 'TB']
77
- let i = -1
78
- let v = n
79
- while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
80
- return v.toFixed(v >= 100 ? 0 : 1) + ' ' + units[i]
81
- }
82
- function fmtTime(ts) {
83
- if (!ts) return ''
84
- const d = new Date(ts * 1000)
85
- const pad = (x) => String(x).padStart(2, '0')
86
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
87
- }
88
-
89
- let WORKSPACES = null // ctx.get('workspaces'), set in apply()
90
- let SESSIONS = null // ctx.get('sessions'), set in apply()
91
-
92
- // ── Settings → 远程工作区 (machine registry + forwards + audit) ──────────
93
- function RemoteWorkspacePage() {
94
- const [machines, setMachines] = React.useState([])
95
- const [currentId, setCurrentId] = React.useState('')
96
- const [form, setForm] = React.useState({
97
- name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '',
98
- passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false,
99
- encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '',
100
- })
101
- const [busy, setBusy] = React.useState(false)
102
- const [msg, setMsg] = React.useState('')
103
- const [err, setErr] = React.useState('')
104
- const [tst, setTst] = React.useState('') // idle | testing | ok | failing
105
- const [forwards, setForwards] = React.useState([])
106
- const [fwdForm, setFwdForm] = React.useState({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false })
107
- const [auditLines, setAuditLines] = React.useState([])
108
- const [sshEntries, setSshEntries] = React.useState(null)
109
- const [workspaces, setWorkspaces] = React.useState([])
110
- const [advOpen, setAdvOpen] = React.useState(false) // 高级配置折叠
111
-
112
- const testConn = () => {
113
- if (!form.host.trim() || tst === 'testing') return
114
- setTst('testing'); setErr(''); setMsg('')
115
- api('POST', '/dsh-remote/test-connect', {
116
- host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
117
- password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
118
- proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
119
- })
120
- .then((r) => {
121
- if (r && r.ok) { setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)` : '连接成功'); refresh() }
122
- else { setTst('failing'); setErr((r && r.error) || '连接失败') }
123
- })
124
- .catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
125
- }
126
-
127
- const refresh = () => api('GET', '/dsh-remote/machines').then((r) => {
128
- setMachines(r.machines || []); setCurrentId(r.currentId || '')
129
- const cur = (r.machines || []).find((m) => m.id === r.currentId)
130
- setWorkspaces((cur && cur.recentWorkspaces) || [])
131
- })
132
- React.useEffect(() => { refresh() }, [])
133
-
134
- // ── update state ────────────────────────────────────────────────────────
135
- const [upd, setUpd] = React.useState(null) // { current, latest, updateAvailable, updateMode, updatedMarker }
136
- const [updBusy, setUpdBusy] = React.useState(false)
137
- const [updMsg, setUpdMsg] = React.useState('')
138
- const [updMode, setUpdMode] = React.useState('manual')
139
- const checkUpdate = (quiet) => {
140
- if (updBusy) return
141
- setUpdBusy(true); setUpdMsg('')
142
- api('GET', '/dsh-remote/update-check')
143
- .then((r) => {
144
- if (!r || !r.ok) { setUpdMsg((r && r.error) || '检查更新失败'); return }
145
- setUpd(r)
146
- if (r.updateMode) setUpdMode(r.updateMode)
147
- if (!quiet) setUpdMsg(r.updateAvailable ? '发现新版本 v' + r.latest : '已是最新版本(v' + r.current + ')')
148
- })
149
- .catch((e) => setUpdMsg(String((e && e.message) || e)))
150
- .finally(() => setUpdBusy(false))
151
- }
152
- const applyUpdateNow = () => {
153
- if (!upd || !upd.latest) return
154
- if (!window.confirm('将更新 dsh-remote 到 v' + upd.latest + ',替换本机插件文件。更新后需重启 Harness 生效。继续?')) return
155
- setUpdBusy(true); setUpdMsg('')
156
- api('POST', '/dsh-remote/update-apply', { version: upd.latest })
157
- .then((r) => {
158
- if (r && r.ok) setUpdMsg('✅ 已更新到 v' + r.to + '(' + (r.from || '') + ' → ' + r.to + ')。重启 Harness 生效。')
159
- else setUpdMsg((r && r.error) || '更新失败')
160
- checkUpdate(true)
161
- })
162
- .catch((e) => setUpdMsg('更新失败: ' + String((e && e.message) || e)))
163
- .finally(() => setUpdBusy(false))
164
- }
165
- const saveUpdateMode = (mode) => {
166
- setUpdMode(mode); setUpdMsg('更新模式已设为「' + (mode === 'auto' ? '自动' : mode === 'off' ? '关闭' : '手动') + '」')
167
- api('POST', '/dsh-remote/update-mode', { mode }).catch((e) => setUpdMsg('模式保存失败: ' + String((e && e.message) || e)))
168
- }
169
- React.useEffect(() => { checkUpdate(true) }, [])
170
-
171
- const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
172
- React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
173
-
174
- const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
175
- const setFb = (k) => (ev) => setForm({ ...form, [k]: ev.target.checked })
176
- const startEdit = (m) => {
177
- setForm({
178
- name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root',
179
- password: '', privateKeyPath: m.privateKeyPath || '', passphrase: '', workspace: m.workspace || '',
180
- hostKeyMode: m.hostKeyMode || '', useAgent: !!m.useAgent, keyboardInteractive: !!m.keyboardInteractive,
181
- encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
182
- proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
183
- proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
184
- id: m.id,
185
- })
186
- // 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串等),自动展开折叠区
187
- if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
188
- setAdvOpen(true)
189
- }
190
- }
191
- const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '' })
192
- const save = (action) => {
193
- setBusy(true); setErr(''); setMsg('')
194
- if (action === 'delete') {
195
- api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { clearForm(); setMsg('已删除') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
196
- return
197
- }
198
- api('POST', '/dsh-remote/machines', {
199
- action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
200
- username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
201
- workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
202
- encryptPassword: form.encryptPassword,
203
- proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
204
- }).then((r) => { refresh(); clearForm(); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
205
- }
206
- 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)) }
207
- // Issue #13: "active remote = none" — saved machines stay, but no machine
208
- // is current, so local sessions are never dragged into a remote context.
209
- const clearCurrent = () => { setBusy(true); api('POST', '/dsh-remote/current', { id: '' }).then((r) => { setCurrentId(''); setMsg('已取消当前远程机 — 本地会话不再自动使用远程上下文') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
210
- 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))) } }
211
-
212
- const importSsh = () => {
213
- api('GET', '/dsh-remote/ssh-config').then((r) => setSshEntries(r && r.present ? (r.entries || []) : []))
214
- .catch((e) => setErr('读取 ~/.ssh/config 失败: ' + String((e && e.message) || e)))
215
- }
216
- const applySshEntry = (e) => {
217
- setForm({ ...form,
218
- name: e.host, host: e.hostName || e.host, port: String(e.port || 22),
219
- username: e.user || 'root', privateKeyPath: e.identityFile || '',
220
- })
221
- setSshEntries(null)
222
- setMsg('已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)')
223
- }
224
-
225
- const setWorkspaceNow = (p) => {
226
- api('POST', '/dsh-remote/workspace', { path: p })
227
- .then(() => setMsg('已切换到工作区 ' + p))
228
- .catch((e) => setErr(String((e && e.message) || e)))
229
- }
230
-
231
- // ── forwards panel ────────────────────────────────────────────────────
232
- const fwdAction = (action, id) => {
233
- api('POST', '/dsh-remote/forwards', { action, id })
234
- .then((r) => { setForwards(r.forwards || []); setErr(''); setMsg('') })
235
- .catch((e) => setErr(String((e && e.message) || e)))
236
- }
237
- const fwdDefine = () => {
238
- if (!fwdForm.listenPort) return
239
- api('POST', '/dsh-remote/forwards', { action: 'define', listenPort: Number(fwdForm.listenPort), targetHost: fwdForm.targetHost || '127.0.0.1', targetPort: Number(fwdForm.targetPort) || Number(fwdForm.listenPort), direction: fwdForm.direction, autoStart: fwdForm.autoStart })
240
- .then((r) => { setForwards(r.forwards || []); setFwdForm({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false }) })
241
- .catch((e) => setErr(String((e && e.message) || e)))
242
- }
243
-
244
- const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 } },
245
- React.createElement('label', { style: { width: 84, fontSize: 12, opacity: 0.8, flexShrink: 0 } }, label), ctrl)
246
-
247
- return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 860 } },
248
- React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
249
- React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
250
- '维护多台 SSH 机器(密码/私钥/agent/跳板机/钥匙串)。路径在新建/选择工作区时选:「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
251
- React.createElement('div', { style: boxS },
252
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
253
- machines.length
254
- ? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
255
- React.createElement('div', { style: { flex: 1, fontSize: 13 } },
256
- m.name + ' ',
257
- React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
258
- m.passwordSet ? ' 🔒' : '',
259
- (m.credentialBackend && m.credentialBackend !== 'plain') ? ' 🗝' : '',
260
- m.proxy && m.proxy.host ? ' ⛳' : '',
261
- m.latencyMs != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, ` · ${m.latencyMs}ms`) : null,
262
- m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
263
- React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
264
- React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
265
- React.createElement('button', { style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' }, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
266
- : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
267
- currentId
268
- ? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 6, fontSize: 12, color: T.ok } },
269
- '当前远程机:' + ((machines.find((m) => m.id === currentId) || {}).name || currentId),
270
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px', fontSize: 12, marginLeft: 'auto' }, onClick: clearCurrent }, '取消设为当前(active remote = none)'))
271
- : React.createElement('div', { style: { paddingTop: 6, fontSize: 12, opacity: 0.6 } }, '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。'),
272
- ),
273
- React.createElement('div', { style: boxS },
274
- React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
275
- React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
276
- React.createElement('button', { style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? '收起' : '从 ~/.ssh/config 导入')),
277
- sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 160, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
278
- sshEntries.length ? sshEntries.map((e) => React.createElement('div', { key: e.host, onClick: () => applySshEntry(e), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, borderBottom: '1px solid ' + T.border } },
279
- e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')))
280
- : React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, '~/.ssh/config 里没有可导入的 Host 条目'))
281
- : null,
282
- row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
283
- row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
284
- row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
285
- row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
286
- row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显)', style: inputS }), 'w'),
287
- // 高级配置折叠开关
288
- React.createElement('button', {
289
- type: 'button',
290
- onClick: () => setAdvOpen(!advOpen),
291
- style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
292
- }, advOpen ? '▲ 收起高级配置' : '▼ 高级配置(私钥 / 跳板机 / agent 等)'),
293
- advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
294
- row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用密码或 agent', style: inputS }), 'k'),
295
- row('Passphrase', React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: '私钥加密时使用', style: inputS }), 'pp'),
296
- row('默认工作区', React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: '可选:该机器的默认远程目录', style: inputS }), 'ws'),
297
- row('HostKey 模式', React.createElement('select', { value: form.hostKeyMode, onChange: setF('hostKeyMode'), style: { ...inputS, maxWidth: 220 } },
298
- React.createElement('option', { value: '' }, '(默认 accept-new)'),
299
- React.createElement('option', { value: 'accept-new' }, 'accept-new(信任首次,之后校验)'),
300
- React.createElement('option', { value: 'verify' }, 'verify(严格:拒绝陌生主机)'),
301
- React.createElement('option', { value: 'off' }, 'off(不校验,不推荐)')), 'hkm'),
302
- React.createElement('div', { style: { display: 'flex', gap: 16, paddingLeft: 90, flexWrap: 'wrap', marginBottom: 8 } },
303
- React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
304
- React.createElement('input', { type: 'checkbox', checked: form.useAgent, onChange: setFb('useAgent') }), 'SSH agent(SSH_AUTH_SOCK)'),
305
- React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
306
- React.createElement('input', { type: 'checkbox', checked: form.keyboardInteractive, onChange: setFb('keyboardInteractive') }), 'keyboard-interactive(OTP/动态码)'),
307
- React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
308
- React.createElement('input', { type: 'checkbox', checked: form.encryptPassword, onChange: setFb('encryptPassword') }), '加密保存密码(系统钥匙串)')),
309
- row('跳板机', React.createElement('input', { value: form.proxyHost, onChange: setF('proxyHost'), placeholder: '经此主机中转(可选)', style: inputS }), 'ph'),
310
- row('端口/用户', React.createElement('div', { style: { display: 'flex', gap: 6, flex: 1, flexWrap: 'wrap' } },
311
- React.createElement('input', { value: form.proxyPort, onChange: setF('proxyPort'), placeholder: '端口(22)', style: { ...inputS, minWidth: 90, maxWidth: 120 } }),
312
- React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: '跳板用户', style: { ...inputS, minWidth: 140 } }),
313
- React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: '跳板密码', style: { ...inputS, minWidth: 140 } })), 'pu'),
314
- row('跳板私钥', React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: '跳板私钥路径(可选)', style: inputS }), 'pk'),
315
- ) : null,
316
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', flexWrap: 'wrap', marginTop: 4 } },
317
- msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
318
- form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
319
- React.createElement('button', { style: buttonS, onClick: () => { clearForm(); setErr(''); setTst('idle'); setAdvOpen(false) } }, form.id ? '取消编辑' : '清空'),
320
- React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
321
- tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
322
- React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
323
- ),
324
- ),
325
- // 快捷切换工作区(该机器的最近工作区)
326
- workspaces.length ? React.createElement('div', { style: boxS },
327
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '当前机器最近工作区(一键切换)'),
328
- workspaces.map((w, i) => React.createElement('div', { key: w + i, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
329
- React.createElement('code', { style: { flex: 1, fontSize: 12, fontFamily: 'monospace' } }, w),
330
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, '切换')))) : null,
331
- // 端口转发面板
332
- React.createElement('div', { style: boxS },
333
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '端口转发(SSH 隧道)'),
334
- forwards.length ? forwards.map((f) => React.createElement('div', { key: f.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0', fontSize: 12, fontFamily: 'monospace' } },
335
- React.createElement('span', { style: { color: f.active ? T.ok : T.muted } }, f.active ? '●' : '○'),
336
- React.createElement('span', { style: { flex: 1 } }, `${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}${f.autoStart ? ' [自启]' : ''}`),
337
- f.active
338
- ? React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('stop', f.id) }, '停止')
339
- : React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('start', f.id) }, '启动'),
340
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('remove', f.id) }, '删除'))) : null,
341
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' } },
342
- React.createElement('select', { value: fwdForm.direction, onChange: (e) => setFwdForm({ ...fwdForm, direction: e.target.value }), style: { ...inputS, maxWidth: 110 } },
343
- React.createElement('option', { value: 'local' }, '本地→远端'),
344
- React.createElement('option', { value: 'reverse' }, '远端→本地')),
345
- React.createElement('input', { value: fwdForm.listenPort, onChange: (e) => setFwdForm({ ...fwdForm, listenPort: e.target.value }), placeholder: '监听端口', style: { ...inputS, maxWidth: 90 } }),
346
- React.createElement('input', { value: fwdForm.targetHost, onChange: (e) => setFwdForm({ ...fwdForm, targetHost: e.target.value }), placeholder: '目标主机', style: { ...inputS, maxWidth: 120 } }),
347
- React.createElement('input', { value: fwdForm.targetPort, onChange: (e) => setFwdForm({ ...fwdForm, targetPort: e.target.value }), placeholder: '目标端口', style: { ...inputS, maxWidth: 90 } }),
348
- React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
349
- React.createElement('input', { type: 'checkbox', checked: fwdForm.autoStart, onChange: (e) => setFwdForm({ ...fwdForm, autoStart: e.target.checked }) }), '自动重连'),
350
- React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: fwdDefine, disabled: !fwdForm.listenPort }, '添加转发')),
351
- ),
352
- // 审计日志
353
- React.createElement('div', { style: boxS },
354
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '最近执行的远程命令(审计日志)'),
355
- auditLines.length
356
- ? React.createElement('div', { style: { maxHeight: 180, overflowY: 'auto', fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 } },
357
- auditLines.map((l, i) => React.createElement('div', { key: i, style: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, l)))
358
- : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)'),
359
- ),
360
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
361
- React.createElement('div', { style: boxS },
362
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '更新'),
363
- React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 } },
364
- upd
365
- ? React.createElement('span', { style: { opacity: 0.85 } },
366
- '当前 v' + upd.current,
367
- upd.updateAvailable ? React.createElement('b', { style: { color: T.ok, marginLeft: 6 } }, '→ 新版本 v' + upd.latest) : null)
368
- : React.createElement('span', { style: { opacity: 0.6 } }, '版本信息加载中…'),
369
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => checkUpdate(false), disabled: updBusy }, updBusy ? '检查中…' : '检查更新'),
370
- upd && upd.updateAvailable
371
- ? React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontWeight: 700, color: T.ok }, onClick: applyUpdateNow, disabled: updBusy }, '立即更新')
372
- : null,
373
- ),
374
- React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
375
- React.createElement('span', { style: { opacity: 0.8 } }, '更新模式:'),
376
- ['manual', 'auto', 'off'].map((m) =>
377
- React.createElement('button', {
378
- key: m, style: { ...buttonS, padding: '2px 10px', fontWeight: updMode === m ? 700 : 400, color: updMode === m ? T.ok : 'inherit' },
379
- onClick: () => saveUpdateMode(m), disabled: updBusy,
380
- }, m === 'manual' ? '手动' : m === 'auto' ? '自动' : '关闭'),
381
- ),
382
- ),
383
- updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') || updMsg.startsWith('已是最新') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
384
- ),
385
- React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11, opacity: 0.65, borderTop: '1px solid ' + T.border, paddingTop: 10, marginTop: 4 } },
386
- React.createElement('span', {}, '觉得好用?'),
387
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
388
- React.createElement('span', {}, '·'),
389
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
390
- React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v' + (upd && upd.current ? upd.current : '?.?.?')),
391
- ),
392
- )
393
- }
394
-
395
- // ── Unified picker (fill the directory-flow holes) ───────────────────
396
- function parseLs(text) {
397
- const out = []
398
- for (const ln of String(text || '').split('\n')) {
399
- const t = ln.trim()
400
- if (!t || !t.length || /^total\b/i.test(t)) continue
401
- const parts = t.split(/\s+/).filter(Boolean)
402
- if (parts.length < 8) continue
403
- const mode = parts[0]
404
- const name = parts.slice(8).join(' ')
405
- if (!name || name === '.' || name === '..') continue
406
- const isDir = mode.charAt(0) === 'd'
407
- out.push({ name, dir: isDir })
408
- }
409
- return out
410
- }
411
-
412
- // Clickable breadcrumb of the current remote path.
413
- function breadcrumb(active, cur, jumpTo) {
414
- const norm = String(cur || '').replace(/\/+$/, '')
415
- const segs = norm === '' || norm === '/' ? [] : norm.split('/')
416
- const crumbs = []
417
- crumbs.push(React.createElement('span', { key: 'root', style: { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted }, onClick: active ? () => jumpTo('/') : undefined }, '/'))
418
- let acc = ''
419
- for (const s of segs) {
420
- if (!s) continue
421
- acc += '/' + s
422
- crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
423
- crumbs.push(React.createElement('span', {
424
- key: acc,
425
- style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === norm ? 700 : 400, whiteSpace: 'nowrap' },
426
- onClick: active ? () => jumpTo(acc) : undefined,
427
- }, s))
428
- }
429
- if (!crumbs.length) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, '/'))
430
- return React.createElement('span', { key: 'crumb' }, crumbs)
431
- }
432
-
433
- function DirPicker(props) {
434
- const { open, busy, onPicked, onCancel } = props
435
- const [tab, setTab] = React.useState('local')
436
- const [machines, setMachines] = React.useState([])
437
- const [machineId, setMachineId] = React.useState('')
438
- const [path, setPath] = React.useState('')
439
- const [items, setItems] = React.useState(null)
440
- const [err, setErr] = React.useState('')
441
- const [loading, setLoading] = React.useState(false)
442
- const [levels, setLevels] = React.useState(null)
443
- const [popOpen, setPopOpen] = React.useState(false)
444
- const [suggest, setSuggest] = React.useState([])
445
- const [suggestOpen, setSuggestOpen] = React.useState(false)
446
- const [recent, setRecent] = React.useState([])
447
- // 本机浏览浮层(browse 后端):{ path, home, crumbs, entries, truncated }。
448
- const [localPop, setLocalPop] = React.useState(null)
449
- const [newDirName, setNewDirName] = React.useState('')
450
- const suggestTimer = React.useRef(null)
451
- const suggestRef = React.useRef(null)
452
- React.useEffect(() => {
453
- if (!suggestOpen) return
454
- const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
455
- document.addEventListener('mousedown', onDown)
456
- return () => document.removeEventListener('mousedown', onDown)
457
- }, [suggestOpen])
458
- const [mOpen, setMOpen] = React.useState(false)
459
- const ddRef = React.useRef(null)
460
- React.useEffect(() => {
461
- if (!mOpen) return
462
- const onDown = (ev) => { if (ddRef.current && !ddRef.current.contains(ev.target)) setMOpen(false) }
463
- document.addEventListener('mousedown', onDown)
464
- return () => document.removeEventListener('mousedown', onDown)
465
- }, [mOpen])
466
-
467
- const sortItems = (list) => {
468
- const dirs = list.filter((it) => it.dir)
469
- const files = list.filter((it) => !it.dir)
470
- dirs.sort((a, b) => a.name.localeCompare(b.name))
471
- files.sort((a, b) => a.name.localeCompare(b.name))
472
- return dirs.concat(files)
473
- }
474
-
475
- const loadLevels = (id, p, toIndex) => {
476
- if (!id) return
477
- setLoading(true); setErr('')
478
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
479
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
480
- .then((res) => {
481
- const real = res && res.path ? res.path : (p || '')
482
- const list = Array.isArray(res && res.items)
483
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime }))
484
- : parseLs(res && res.text)
485
- const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
486
- setLevels((prev) => {
487
- const base = prev && prev.length ? prev.slice() : []
488
- let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
489
- if (idx >= base.length) return base.concat([node])
490
- base[idx] = node
491
- return base.slice(0, idx + 1)
492
- })
493
- })
494
- .catch((e) => setErr(String((e && e.message) || e)))
495
- .finally(() => setLoading(false))
496
- }
497
-
498
- React.useEffect(() => { if (open) {
499
- setLocalPop(null); setNewDirName('')
500
- api('GET', '/dsh-remote/machines').then((r) => {
501
- setMachines(r.machines || [])
502
- const cur = (r.machines || []).find((m) => m.id === r.currentId)
503
- setRecent((cur && cur.recentWorkspaces) || [])
504
- setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '')
505
- })
506
- } }, [open])
507
-
508
- // 本机目录浏览(browse 后端兜底):列出 path 的子目录;path 为空 → 宿主家目录。
509
- const loadLocal = (p) => {
510
- setLoading(true); setErr('')
511
- api('GET', '/dsh-remote/local-list' + (p ? '?path=' + encodeURIComponent(p) : ''))
512
- .then((r) => setLocalPop({ path: (r && r.path) || p || '', home: (r && r.home) || '', crumbs: (r && r.crumbs) || [], entries: (r && r.entries) || [], truncated: !!(r && r.truncated), drives: (r && r.drives) || [] }))
513
- .catch((e) => setErr(String((e && e.message) || e)))
514
- .finally(() => setLoading(false))
515
- }
516
-
517
- const chooseLocal = () => {
518
- setLoading(true); setErr('')
519
- api('POST', '/dsh-remote/local-pick')
520
- .then((r) => {
521
- if (r && r.path) { setLoading(false); onPicked(String(r.path)) }
522
- // browse 后端(DSH Desktop / 无显示的远程宿主):没有 OS 对话框可开
523
- // → 打开内置本机目录浏览器。
524
- else if (r && r.kind === 'browse') loadLocal('')
525
- else if (r && r.cancelled) { setLoading(false); setErr('已取消选择') }
526
- else { setLoading(false); setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径') }
527
- })
528
- .catch((e) => { setLoading(false); setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径') })
529
- }
530
-
531
- // 在浮层当前目录下新建文件夹(browse 后端 createDirectory),成功后刷新列表。
532
- const mkdirLocal = () => {
533
- const cur = localPop
534
- const name = newDirName.trim()
535
- if (!cur || !cur.path || !name || loading) return
536
- setLoading(true); setErr('')
537
- api('POST', '/dsh-remote/local-mkdir', { path: cur.path, name })
538
- .then(() => { setNewDirName(''); loadLocal(cur.path) })
539
- .catch((e) => { setErr(String((e && e.message) || e)); setLoading(false) })
540
- }
541
-
542
- // 选用浮层中的本机目录:回填输入框(不直接提交),复核后再点「选用此本地路径」。
543
- const acceptLocalPick = (p) => {
544
- setPath(String(p || ''))
545
- setErr('')
546
- setLocalPop(null)
547
- }
548
-
549
- const switchTab = (t) => {
550
- setTab(t); setErr('')
551
- if (t === 'remote') {
552
- if (machineId) loadLevels(machineId, '', 0)
553
- if (!path.trim()) { setPath('/'); loadSuggestions('/') }
554
- }
555
- }
556
-
557
- const enterDir = (name) => {
558
- if (busy || loading) return
559
- const last = levels && levels.length ? levels[levels.length - 1] : null
560
- const base = last && last.path ? last.path : ''
561
- const sep = base.includes('\\') ? '\\' : '/'
562
- const next = base === '/' ? '/' + name : (base ? base + sep + name : name)
563
- loadLevels(machineId, next, (levels ? levels.length : 0))
564
- }
565
-
566
- const loadSuggestions = (raw, mid) => {
567
- const id = mid || machineId
568
- if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
569
- const t = String(raw || '').trim()
570
- if (!t) { setSuggest([]); setSuggestOpen(false); return }
571
- const isWin = t.includes('\\')
572
- const sep = isWin ? '\\' : '/'
573
- const slash = t.lastIndexOf(sep)
574
- const parent = slash <= 0 ? (isWin ? t.match(/^[a-zA-Z]:\\?/) ? t : '' : '/') : t.slice(0, slash)
575
- const lastSeg = slash < 0 ? t : t.slice(slash + 1)
576
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
577
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
578
- .then((res) => {
579
- const list = Array.isArray(res && res.items)
580
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
581
- : parseLs(res && res.text)
582
- const base = parent === '/' ? '/' : parent
583
- const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
584
- .slice(0, 40).map((it) => (base === '/' || !base ? sep + it.name : base + sep + it.name))
585
- setSuggest(matches)
586
- setSuggestOpen(!!matches.length)
587
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
588
- }
589
-
590
- const onPathChange = (raw) => {
591
- setPath(raw); setErr('')
592
- if (suggestTimer.current) clearTimeout(suggestTimer.current)
593
- suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
594
- }
595
-
596
- const continueSuggest = (dir) => {
597
- if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
598
- setSuggestOpen(false)
599
- const sep = String(dir).includes('\\') ? '\\' : '/'
600
- api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
601
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
602
- .then((res) => {
603
- const list = Array.isArray(res && res.items)
604
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
605
- : parseLs(res && res.text)
606
- const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
607
- // Show every entry (dirs + files), matching loadSuggestions — the
608
- // previous dir-only filter made the next-level list look truncated.
609
- const kids = list.slice(0, 40).map((it) => (base === '/' ? sep + it.name : base + sep + it.name))
610
- setSuggest(kids)
611
- setSuggestOpen(!!kids.length)
612
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
613
- }
614
-
615
- const selectSuggestion = (s) => {
616
- // Auto-append the trailing separator so the path reads as a directory
617
- // and the user can keep typing the next segment right away.
618
- const sep = String(s).includes('\\') ? '\\' : '/'
619
- const withSep = String(s).replace(/[\\/]+$/, '') + sep
620
- setPath(withSep); setErr(''); setSuggestOpen(false)
621
- continueSuggest(withSep)
622
- }
623
-
624
- const commitPath = (p) => {
625
- const target = String(p || '').trim()
626
- if (!target || !machineId || busy) return
627
- setPopOpen(false); setSuggestOpen(false)
628
- 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)))
629
- }
630
-
631
- const acceptBrowserPick = (p) => {
632
- setPath(String(p || ''))
633
- setSuggestOpen(false)
634
- setPopOpen(false)
635
- }
636
-
637
- const pickHome = () => {
638
- api('POST', '/dsh-remote/home').then((r) => {
639
- if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
640
- else setErr((r && (r.hint || r.error)) || '无法解析主目录')
641
- }).catch((e) => setErr(String((e && e.message) || e)))
642
- }
643
-
644
- const mkdirHere = () => {
645
- const last = levels && levels.length ? levels[levels.length - 1] : null
646
- const base = (last && last.path) || '/'
647
- const nm = window.prompt('新建目录名(在 ' + base + ' 下)')
648
- if (!nm || !nm.trim()) return
649
- const sep = base.includes('\\') ? '\\' : '/'
650
- const full = base === '/' ? '/' + nm.trim() : base + sep + nm.trim()
651
- api('POST', '/dsh-remote/fs', { op: 'mkdir', path: full })
652
- .then(() => { setErr(''); loadLevels(machineId, base, (levels ? levels.length - 1 : 0)) })
653
- .catch((e) => setErr(String((e && e.message) || e)))
654
- }
655
-
656
- // 本机浏览浮层的面包屑:browse 后端给出完整祖先链(含盘符根),可点击跳转。
657
- const localCrumbs = (cur) => {
658
- const crumbs = (cur && cur.crumbs) || []
659
- if (!crumbs.length) return React.createElement('span', null, '/')
660
- const out = []
661
- crumbs.forEach((c, i) => {
662
- if (i > 0) out.push(React.createElement('span', { key: 'sep' + i, style: { color: T.muted } }, ' › '))
663
- out.push(React.createElement('span', {
664
- key: c.path || i,
665
- title: c.path,
666
- onClick: () => loadLocal(c.path),
667
- style: { cursor: 'pointer', color: i === crumbs.length - 1 ? T.label : T.ok, fontWeight: i === crumbs.length - 1 ? 700 : 400 },
668
- }, c.name))
669
- })
670
- return React.createElement('span', null, out)
671
- }
672
-
673
- // 本机目录浏览浮层(browse 后端):进入子目录 / 面包屑跳转 / 回家目录 /
674
- // 新建文件夹 / 选用回填。与远程浏览弹层同一套交互。
675
- function renderLocalPopup() {
676
- const cur = localPop
677
- if (!cur) return null
678
- const crumbs = cur.crumbs || []
679
- const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
680
- return React.createElement('div', {
681
- style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
682
- onClick: () => setLocalPop(null),
683
- },
684
- 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(460px, 84vh)', overflow: 'hidden' }, onClick: (e) => e.stopPropagation() },
685
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
686
- React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => parent && loadLocal(parent), disabled: !parent || loading, title: parent || '' }, '回上一级 ▴'),
687
- cur.home ? React.createElement('button', { style: { ...buttonS, padding: '3px 8px' }, onClick: () => loadLocal(cur.home), disabled: loading, title: cur.home }, '🏠') : null,
688
- React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, localCrumbs(cur)),
689
- React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setLocalPop(null) }, '关闭 ✕'),
690
- ),
691
- // 盘符切换行(Windows 无统一根目录,browse 面包屑到不了其他盘)。
692
- (cur.drives && cur.drives.length > 1) ? React.createElement('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', padding: '6px 12px', borderBottom: '1px solid ' + T.border } },
693
- cur.drives.map((d) => {
694
- const active = String(cur.path).toUpperCase().startsWith(String(d.name).toUpperCase())
695
- return React.createElement('button', {
696
- key: d.path,
697
- onClick: () => loadLocal(d.path),
698
- disabled: loading,
699
- title: d.path,
700
- style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace', fontWeight: active ? 700 : 400, color: active ? T.ok : T.label },
701
- }, d.name)
702
- }),
703
- ) : null,
704
- React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
705
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
706
- : (cur.entries.length ? cur.entries.slice(0, 400).map((it, i) => React.createElement('div', {
707
- key: it.path || i, title: '进入 ' + it.name,
708
- onClick: () => loadLocal(it.path),
709
- style: { padding: '7px 12px', cursor: 'pointer', color: T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
710
- },
711
- React.createElement('span', null, '📁'),
712
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
713
- )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(没有子目录 — 可直接选用当前目录)')),
714
- cur.truncated ? React.createElement('div', { style: { opacity: 0.6, padding: '6px 12px', fontSize: 11 } }, '(条目过多已截断)') : null,
715
- ),
716
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 12, padding: '4px 12px' } }, err) : null,
717
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
718
- React.createElement('input', { value: newDirName, onChange: (e) => setNewDirName(e.target.value), placeholder: '新建文件夹', style: { ...inputS, width: 110, flex: '0 0 auto', padding: '4px 8px', fontSize: 12 } }),
719
- React.createElement('button', { style: { ...buttonS, padding: '4px 10px', whiteSpace: 'nowrap' }, onClick: mkdirLocal, disabled: loading || !newDirName.trim() || !cur.path }, '新建'),
720
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + cur.path),
721
- React.createElement('button', { style: { ...buttonS, fontWeight: 600, whiteSpace: 'nowrap' }, onClick: () => acceptLocalPick(cur.path) }, '选用此路径'),
722
- ),
723
- ),
724
- )
725
- }
726
-
727
- function renderDirPopup() {
728
- if (!levels || !levels.length) {
729
- return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
730
- }
731
- const last = levels[levels.length - 1]
732
- const entries = last.all || []
733
- return React.createElement('div', { style: { border: '1px solid ' + T.borderStrong, borderRadius: 10, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), boxShadow: '0 8px 32px rgba(0,0,0,0.4)', display: 'flex', flexDirection: 'column', maxHeight: 'min(300px, 46vh)', overflow: 'hidden' } },
734
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
735
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setLevels((p) => p && p.length > 1 ? p.slice(0, p.length - 1) : p), disabled: levels.length <= 1 || loading }, '回上一级 ▴'),
736
- 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 }))),
737
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: mkdirHere }, '新建目录'),
738
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
739
- ),
740
- React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
741
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
742
- : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
743
- key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
744
- onClick: it.dir ? () => enterDir(it.name) : undefined,
745
- style: { padding: '6px 10px', cursor: it.dir ? 'pointer' : 'default', color: T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
746
- },
747
- React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label } }, it.dir
748
- ? React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor' }, React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' }))
749
- : React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', opacity: 0.85 }, React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' }))),
750
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
751
- React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
752
- )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
753
- ),
754
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
755
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
756
- React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
757
- ),
758
- )
759
- }
760
-
761
- if (!open) return null
762
- const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
763
- 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() } },
764
- 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)', maxHeight: 'min(680px, 92vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
765
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
766
- React.createElement('div', null, '选择工作目录'),
767
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
768
- ),
769
- React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
770
- tabBtn('local', '本机'),
771
- tabBtn('remote', '远程'),
772
- ),
773
- tab === 'local'
774
- ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
775
- React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '弹出系统文件夹对话框选择;不可用时用内置目录浏览,也可直接输入本机目录。'),
776
- React.createElement('div', { style: { display: 'flex', gap: 6 } },
777
- React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
778
- React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
779
- ),
780
- React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
781
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
782
- localPop ? renderLocalPopup() : null,
783
- )
784
- : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
785
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
786
- React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
787
- React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
788
- React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
789
- (machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
790
- ),
791
- mOpen
792
- ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 300, maxHeight: 260, overflow: 'auto', background: v('--dsw-alias-bg-overlay', '#1f1f23'), border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
793
- machines.length
794
- ? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setRecent(m.recentWorkspaces || []); setLevels(null); setMOpen(false); if (m.id) { loadLevels(m.id, '', 0); if (!path.trim()) { setPath('/'); loadSuggestions('/', 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 + ')'))
795
- : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
796
- )
797
- : null,
798
- ),
799
- ),
800
- // 最近工作区快捷入口
801
- recent.length ? React.createElement('div', { style: { display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' } },
802
- React.createElement('span', { style: { fontSize: 11, opacity: 0.6, whiteSpace: 'nowrap' } }, '最近:'),
803
- recent.map((w, i) => React.createElement('button', { key: w + i, onClick: () => { setPath(w); setErr(''); continueSuggest(w) }, style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace' } }, w))) : null,
804
- React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
805
- React.createElement('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
806
- React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
807
- React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
808
- React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, '~ 主目录'),
809
- ),
810
- (suggestOpen && suggest.length)
811
- ? React.createElement('div', { style: { marginTop: 6, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
812
- 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)),
813
- )
814
- : null,
815
- ),
816
- popOpen ? renderDirPopup() : null,
817
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
818
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
819
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
820
- React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
821
- ),
822
- ),
823
- (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
824
- React.createElement('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center', paddingTop: 6, fontSize: 10, opacity: 0.5 } },
825
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ Star dsh-remote'),
826
- React.createElement('span', {}, '·'),
827
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '提建议 / 报问题'),
828
- ),
829
- ),
830
- )
831
- }
832
-
833
- // ── better-sidebar integration (optional) ────────────────────────────────
834
- const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
835
- const SIDEBAR_FILE_ID = 'dsh-remote:file'
836
-
837
- function fetchRemoteStatus() {
838
- return api('GET', '/dsh-remote/status').catch(() => null)
839
- }
840
-
841
- // Resolve the remote path this session shows. Issue #13: the ONLY source
842
- // of truth is the session's own cwd mapped through /dsh-remote/resolve-
843
- // mirror — a cwd outside any mirror dir means "this session is NOT a
844
- // remote session", and the sidebar must NOT fall back to the machine
845
- // workspace (that made an unrelated saved machine leak into local
846
- // sessions).
847
- function resolveSessionRemote(sessionId) {
848
- if (!sessionId) return Promise.resolve('')
849
- // 1) Prefer the client-side session list cwd (mirror dir under
850
- // $DSH_HOME/remote-workspaces/...) passed as ?local=.
851
- let local = ''
852
- try {
853
- const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
854
- ? SESSIONS.list.getSnapshot()
855
- : null
856
- const entry = list && list.byId && list.byId[sessionId]
857
- if (entry && entry.cwd) local = String(entry.cwd)
858
- } catch { /* sessions store shape differs */ }
859
- if (local) {
860
- return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(local))
861
- .then((r) => (r && r.remotePath) || '')
862
- .catch(() => '')
863
- }
864
- // 2) Fall back to host-side session header cwd.
865
- return api('GET', '/dsh-remote/resolve-mirror?sessionId=' + encodeURIComponent(sessionId))
866
- .then((r) => (r && r.remotePath) || '')
867
- .catch(() => '')
868
- }
869
-
870
- function readRemoteFile(path, maxBytes) {
871
- return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
872
- throw new Error('远程读取失败: ' + ((e && e.message) || e))
873
- })
874
- }
875
-
876
- function listRemoteDir(path) {
877
- return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
878
- throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
879
- })
880
- }
881
-
882
- function joinRemote(base, name) {
883
- if (!base) return name
884
- return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
885
- }
886
-
887
- // ── Remote explorer tab ────────────────────────────────────────────────
888
- // Inline monochrome icons (fill: currentColor → follows the row text color,
889
- // i.e. black on the sidebar). Folder open/closed + file.
890
- const IconFolder = (open) => React.createElement('svg', {
891
- width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0 },
892
- },
893
- open
894
- ? React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' })
895
- : React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' }),
896
- )
897
- const IconFile = () => React.createElement('svg', {
898
- width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0, opacity: 0.85 },
899
- },
900
- React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' }),
901
- )
902
-
903
- // Tree-style remote file explorer. Uses the same `expanded` / `onToggleDir`
904
- // contract as the built-in local file tree (better-sidebar framework
905
- // manages + persists the expanded set), so interaction and look match the
906
- // local files tab. Data is listed live from the remote host over
907
- // /dsh-remote/ls, rooted at this session's remote workspace dir.
908
- function RemoteExplorerTab(props) {
909
- const [status, setStatus] = React.useState(null)
910
- const [root, setRoot] = React.useState('') // remote root path for this session
911
- const [data, setData] = React.useState({}) // dir -> { entries | error | loading }
912
- const dataRef = React.useRef(data)
913
- const [err, setErr] = React.useState('')
914
- const [busy, setBusy] = React.useState('')
915
- const [picker, setPicker] = React.useState(false)
916
- const [menu, setMenu] = React.useState(null) // { x, y, path, isDir, name }
917
- const [rootOpen, setRootOpen] = React.useState(true) // first level expanded by default
918
- const [hoverPath, setHoverPath] = React.useState('') // row hover highlight
919
- const expanded = Array.isArray(props.expanded) ? props.expanded : []
920
-
921
- const storeLevel = (path, level) => {
922
- dataRef.current = { ...dataRef.current, [path]: level }
923
- setData(dataRef.current)
924
- }
925
-
926
- const loadDir = React.useCallback((dir) => {
927
- const cur = dataRef.current
928
- if (cur[dir] !== undefined && !cur[dir].loading) return
929
- storeLevel(dir, { loading: true })
930
- listRemoteDir(dir)
931
- .then((res) => {
932
- const items = Array.isArray(res && res.items) ? res.items : []
933
- const sorted = items.slice().sort((a, b) => {
934
- if (a.type === 'dir' && b.type !== 'dir') return -1
935
- if (a.type !== 'dir' && b.type === 'dir') return 1
936
- return String(a.name).localeCompare(String(b.name))
937
- })
938
- storeLevel(dir, { entries: sorted })
939
- })
940
- .catch((e) => storeLevel(dir, { error: String((e && e.message) || e) }))
941
- }, [])
942
-
943
- // Resolve this session's remote root, then load it and every expanded dir.
944
- // Re-runs whenever the session scope changes (switching conversations, or
945
- // the async sessionCwd fetch landing), so the sidebar follows the session's
946
- // own workspace dir.
947
- const scopeCwd = props.scope && props.scope.cwd
948
- const sessionId = props.scope && props.scope.sessionId
949
- const refreshStatus = React.useCallback(() => {
950
- const applyRoot = (r) => {
951
- setRoot(r)
952
- setData({})
953
- dataRef.current = {}
954
- if (r) {
955
- storeLevel(r, { loading: true })
956
- listRemoteDir(r)
957
- .then((res) => {
958
- const items = Array.isArray(res && res.items) ? res.items : []
959
- const sorted = items.slice().sort((a, b) => {
960
- if (a.type === 'dir' && b.type !== 'dir') return -1
961
- if (a.type !== 'dir' && b.type === 'dir') return 1
962
- return String(a.name).localeCompare(String(b.name))
963
- })
964
- storeLevel(r, { entries: sorted })
965
- for (const dir of expanded) loadDir(dir)
966
- })
967
- .catch((e) => storeLevel(r, { error: String((e && e.message) || e) }))
968
- }
969
- }
970
- const resolveRoot = () => {
971
- if (scopeCwd) {
972
- return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(scopeCwd))
973
- .then((r) => (r && r.remotePath) || '')
974
- .catch(() => '')
975
- }
976
- if (sessionId) return resolveSessionRemote(sessionId)
977
- return Promise.resolve('')
978
- }
979
- resolveRoot().then((r) => {
980
- fetchRemoteStatus().then((s) => {
981
- setStatus(s)
982
- // Issue #13: NEVER fall back to the machine-level workspace here —
983
- // a session whose cwd is not a mirror is a LOCAL session and shows
984
- // the "no remote workspace" empty state instead.
985
- applyRoot(r || '')
986
- })
987
- })
988
- }, [scopeCwd, sessionId])
989
-
990
- React.useEffect(() => { refreshStatus() }, [refreshStatus])
991
- React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
992
- // Load dirs as they get expanded.
993
- React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir) }, [root, expanded, loadDir])
994
- React.useEffect(() => {
995
- if (!menu) return
996
- const close = () => setMenu(null)
997
- document.addEventListener('mousedown', close)
998
- return () => document.removeEventListener('mousedown', close)
999
- }, [menu])
1000
-
1001
- const openFile = (p) => {
1002
- const name = String(p).split(/[\\/]/).pop() || p
1003
- props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
1004
- }
1005
-
1006
- const doFs = (op, payload, refreshParent) => {
1007
- setBusy(op)
1008
- return api('POST', '/dsh-remote/fs', { op, ...payload })
1009
- .then(() => {
1010
- setErr('')
1011
- if (refreshParent) {
1012
- dataRef.current = {}
1013
- setData({})
1014
- if (root) { storeLevel(root, { loading: true }); loadDir(root) }
1015
- for (const dir of expanded) loadDir(dir)
1016
- }
1017
- })
1018
- .catch((e) => setErr(String((e && e.message) || e)))
1019
- .finally(() => setBusy(''))
1020
- }
1021
-
1022
- const onCtx = (e, path, isDir, name) => {
1023
- e.preventDefault(); e.stopPropagation()
1024
- setMenu({ x: e.clientX, y: e.clientY, path, isDir, name })
1025
- }
1026
- const ctxItem = (label, danger, fn) => React.createElement('div', {
1027
- onClick: (e) => { e.stopPropagation(); setMenu(null); fn() },
1028
- style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
1029
- }, label)
1030
-
1031
- // One tree row (dir toggles via the framework's onToggleDir; file opens).
1032
- // Text uses the normal foreground (theme-following) — no accent green.
1033
- const row = (entry, depth) => {
1034
- const pad = depth * 22 + 6
1035
- const hovered = hoverPath === entry.path
1036
- const base = {
1037
- display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: pad,
1038
- borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap',
1039
- overflow: 'hidden', textOverflow: 'ellipsis', transition: 'background 0.12s ease',
1040
- background: hovered ? 'rgba(128,128,128,0.14)' : 'transparent',
1041
- color: T.label,
1042
- }
1043
- if (entry.type === 'dir') {
1044
- const isOpen = expanded.includes(entry.path)
1045
- return React.createElement('div', { key: entry.path },
1046
- React.createElement('div', {
1047
- role: 'button', tabIndex: 0,
1048
- onClick: () => { if (props.onToggleDir) props.onToggleDir(entry.path) },
1049
- onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.onToggleDir) props.onToggleDir(entry.path) } },
1050
- onContextMenu: (e) => onCtx(e, entry.path, true, entry.name),
1051
- onMouseEnter: () => setHoverPath(entry.path),
1052
- onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
1053
- style: base,
1054
- title: entry.path,
1055
- },
1056
- React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(isOpen)),
1057
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, fontWeight: 500 } }, entry.name),
1058
- ),
1059
- isOpen ? renderLevel(entry.path, depth + 1) : null,
1060
- )
1061
- }
1062
- return React.createElement('div', {
1063
- role: 'button', tabIndex: 0, key: entry.path,
1064
- onClick: () => openFile(entry.path),
1065
- onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(entry.path) } },
1066
- onContextMenu: (e) => onCtx(e, entry.path, false, entry.name),
1067
- onMouseEnter: () => setHoverPath(entry.path),
1068
- onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
1069
- style: base,
1070
- title: entry.path,
1071
- },
1072
- React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex' } }, IconFile()),
1073
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, entry.name),
1074
- entry.size != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, humanSize(entry.size)) : null,
1075
- )
1076
- }
1077
-
1078
- const renderLevel = (dir, depth) => {
1079
- const level = data[dir]
1080
- if (level === undefined || (level.loading && !level.entries)) {
1081
- return React.createElement('div', { key: dir + ':loading', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '加载中…')
1082
- }
1083
- if (level.error !== undefined) {
1084
- return React.createElement('div', { key: dir + ':err', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, color: '#e06c75' } }, level.error)
1085
- }
1086
- const items = level.entries || []
1087
- if (!items.length) return null
1088
- return React.createElement('div', { key: dir + ':list' }, items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)))
1089
- }
1090
-
1091
- const rootName = root ? String(root).split(/[\\/]/).filter(Boolean).pop() || root : ''
1092
- const rootLevel = data[root]
1093
-
1094
- return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
1095
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
1096
- React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: refreshStatus, title: '刷新' }, '↻'),
1097
- React.createElement('button', {
1098
- style: { ...buttonS, padding: '3px 8px', fontSize: 12 },
1099
- onClick: () => { const nm = window.prompt('新建目录名(在 ' + (root || '/') + ' 下)'); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(root, nm.trim()) }, true) },
1100
- }, '新建目录'),
1101
- React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
1102
- ),
1103
- status && status.workspace && root
1104
- ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + root + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
1105
- : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, root ? '未设置远程工作区' : '当前会话未使用远程工作区'),
1106
- picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
1107
- open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
1108
- })) : null,
1109
- React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
1110
- !root
1111
- ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。')
1112
- : React.createElement('div', { key: root },
1113
- React.createElement('div', {
1114
- role: 'button', tabIndex: 0,
1115
- onClick: () => setRootOpen((v) => !v),
1116
- onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRootOpen((v) => !v) } },
1117
- onContextMenu: (e) => onCtx(e, root, true, rootName),
1118
- style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: 6, borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: T.label, fontWeight: 600 },
1119
- title: root,
1120
- },
1121
- React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(rootOpen)),
1122
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, rootName || root),
1123
- ),
1124
- rootLevel && rootOpen ? renderLevel(root, 1) : null,
1125
- ),
1126
- ),
1127
- // context menu
1128
- menu ? React.createElement('div', { style: { position: 'fixed', left: menu.x, top: menu.y, zIndex: 9998, background: v('--dsw-alias-bg-overlay', '#1f1f23'), border: '1px solid ' + T.borderStrong, borderRadius: 8, padding: 4, boxShadow: '0 8px 24px rgba(0,0,0,0.4)', minWidth: 170 }, onClick: (e) => e.stopPropagation() },
1129
- ctxItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }),
1130
- ctxItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })),
1131
- ctxItem('✏ 重命名', false, () => {
1132
- const nm = window.prompt('重命名为(新名字)', menu.name)
1133
- if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true)
1134
- }),
1135
- ctxItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => {
1136
- if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true)
1137
- }),
1138
- ) : null,
1139
- err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
1140
- )
1141
- }
1142
-
1143
- // ── Remote file tab (opened by the explorer; live read + explicit edit) ──
1144
- function RemoteFileTab(props) {
1145
- const { tab, scope } = props
1146
- const path = tab.path || ''
1147
- const [data, setData] = React.useState(null)
1148
- const [err, setErr] = React.useState('')
1149
- const [loading, setLoading] = React.useState(true)
1150
- const [edit, setEdit] = React.useState(false)
1151
- const [draft, setDraft] = React.useState('')
1152
- const [saving, setSaving] = React.useState(false)
1153
-
1154
- const load = () => {
1155
- if (!path) { setLoading(false); return }
1156
- let cancelled = false
1157
- setLoading(true); setErr(''); setData(null); setEdit(false)
1158
- readRemoteFile(path, 256 * 1024)
1159
- .then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
1160
- .catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
1161
- return () => { cancelled = true }
1162
- }
1163
- React.useEffect(load, [path])
1164
-
1165
- const startEdit = () => {
1166
- if (!data || data.binary) return
1167
- setDraft((data.content != null ? data.content : '').replace(/\n…\[truncated:.*$/, ''))
1168
- setEdit(true); setErr('')
1169
- }
1170
- const saveRemote = () => {
1171
- setSaving(true); setErr('')
1172
- apiRaw('POST', '/dsh-remote/write', { path, content: draft })
1173
- .then((r) => {
1174
- if (r.status === 409) {
1175
- setErr(String((r.data && r.data.error) || '远端文件已变化,请重新读取'))
1176
- return
1177
- }
1178
- if (r.status >= 400) throw new Error((r.data && (r.data.error || r.data.message)) || 'HTTP ' + r.status)
1179
- setEdit(false)
1180
- load()
1181
- })
1182
- .catch((e) => setErr(String((e && e.message) || e)))
1183
- .finally(() => setSaving(false))
1184
- }
1185
- const downloadMirror = () => {
1186
- setSaving(true); setErr('')
1187
- api('POST', '/dsh-remote/fs', { op: 'download', path })
1188
- .then((r) => setErr('已下载到本地镜像' + (r && r.local ? ': ' + r.local : '')))
1189
- .catch((e) => setErr(String((e && e.message) || e)))
1190
- .finally(() => setSaving(false))
1191
- }
1192
-
1193
- const baseName = path.split(/[\\/]/).pop() || path
1194
- return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
1195
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,0.25)', fontSize: 12 } },
1196
- React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
1197
- React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
1198
- React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, edit ? '编辑中' : '只读'),
1199
- edit
1200
- ? React.createElement(React.Fragment, null,
1201
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving }, saving ? '保存中…' : '保存到远程'),
1202
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, '取消'))
1203
- : React.createElement(React.Fragment, null,
1204
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, '编辑'),
1205
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, '下载')),
1206
- ),
1207
- React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
1208
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
1209
- : err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
1210
- : (data && data.binary
1211
- ? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
1212
- '二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 请用「下载」或 rw_download / 本地镜像查看。')
1213
- : (edit
1214
- ? React.createElement('textarea', { value: draft, onChange: (e) => setDraft(e.target.value), spellCheck: false, style: { width: '100%', height: '100%', boxSizing: 'border-box', border: 'none', outline: 'none', resize: 'none', background: 'transparent', color: '#e4e4e7', fontSize: 13, lineHeight: 1.55, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', padding: '10px 12px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } })
1215
- : React.createElement('div', { style: { fontSize: 13, lineHeight: 1.55, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', padding: '10px 12px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } },
1216
- (data && data.content != null) ? data.content : '(空文件)',
1217
- (data && data.truncated)
1218
- ? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
1219
- : null,
1220
- ))),
1221
- ),
1222
- )
1223
- }
1224
-
1225
- // ── better-sidebar registration (guarded: plugin may be absent) ────────
1226
- function registerSidebarIntegration(ctx) {
1227
- const bs = (ctx && ctx.get && ctx.get('betterSidebar')) || null
1228
- if (!bs || typeof bs.registerTab !== 'function') return
1229
- const disposers = []
1230
- try {
1231
- disposers.push(bs.registerTab({
1232
- id: SIDEBAR_EXPLORER_ID,
1233
- title: () => '远程文件',
1234
- icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
1235
- order: 55,
1236
- single: true,
1237
- component: (props) => React.createElement(RemoteExplorerTab, props),
1238
- }))
1239
- disposers.push(bs.registerTab({
1240
- id: SIDEBAR_FILE_ID,
1241
- title: () => '远程文件',
1242
- hidden: true, // not in the + menu; opened from the explorer tree
1243
- dedupeKey: (tab) => tab.path,
1244
- component: (props) => React.createElement(RemoteFileTab, props),
1245
- }))
1246
- } catch (e) {
1247
- console.warn('[dsh-remote] better-sidebar integration skipped:', e)
1248
- disposers.forEach((d) => { try { d() } catch {} })
1249
- return
1250
- }
1251
- let autoOpenedFor = new Set()
1252
- let disposeAuto = null
1253
- const tryAutoOpen = () => {
1254
- if (typeof bs.subscribeState !== 'function') return
1255
- const snap = bs.getSnapshot && bs.getSnapshot()
1256
- if (!snap || !snap.sessionId) return
1257
- if (autoOpenedFor.has(snap.sessionId)) return
1258
- const sessionId = snap.sessionId
1259
- // Issue #13: only auto-open the Remote Files tab for sessions that are
1260
- // genuinely remote (cwd inside a mirror). A plain local session must
1261
- // not get a remote tab popped open by a machine that happens to be
1262
- // saved as "current".
1263
- resolveSessionRemote(sessionId).then((remotePath) => {
1264
- if (!remotePath || autoOpenedFor.has(sessionId)) return
1265
- autoOpenedFor.add(sessionId)
1266
- try {
1267
- bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: remotePath })
1268
- } catch (e2) {
1269
- console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
1270
- }
1271
- })
1272
- }
1273
- tryAutoOpen()
1274
- if (typeof bs.subscribeState === 'function') {
1275
- disposeAuto = bs.subscribeState(tryAutoOpen)
1276
- disposers.push(disposeAuto)
1277
- }
1278
- return () => disposers.forEach((d) => { try { d() } catch {} })
1279
- }
1280
-
1281
- function apply(ctx) {
1282
- const slots = ctx.get('slots')
1283
- if (slots === undefined) return
1284
- WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
1285
- SESSIONS = (ctx && ((ctx.get && ctx.get('sessions')) || null)) || null
1286
- slots.inject('settings.section', () =>
1287
- slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
1288
- )
1289
- slots.inject(
1290
- 'conversation.hero.workspace.directoryFlow',
1291
- () => slots.inject('sidebar.workspaces.directoryFlow',
1292
- function* () {
1293
- yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
1294
- yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
1295
- },
1296
- ),
1297
- )
1298
- ctx.inject(['betterSidebar'], (inner) => {
1299
- const bsDispose = registerSidebarIntegration(inner)
1300
- if (bsDispose) {
1301
- inner.effect(() => bsDispose, 'dsh-remote.betterSidebar')
1302
- }
1303
- })
1304
- }
1305
-
1306
- exports.name = name
1307
- exports.apply = apply
1308
- return module.exports
1309
- },
1310
- })
1
+ // dsh-remote — client half.
2
+ // Settings → 远程工作区: a multi-machine SSH registry (add/edit/select current;
3
+ // passphrase / host-key mode / proxy jump / agent / keychain password; password
4
+ // stored host-side and kept private). Plus a port-forward panel, the command
5
+ // audit log view, quick workspace switching, and an ~/.ssh/config importer.
6
+ //
7
+ // A unified workspace directory picker fills ui-workspace's two directory-flow
8
+ // holes (sidebar + conversation hero):
9
+ // • 本机 tab → opens the NATIVE OS folder chooser (ctx.workspaces.pickDirectory)
10
+ // and returns the picked local path (works with local workspaces).
11
+ // • 远程 tab → pick a machine (dropdown), list its directories over
12
+ // /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
13
+ // real LOCAL mirror → onPicked(localMirror) so host adopts it as a workspace.
14
+ //
15
+ // better-sidebar integration (optional): a live remote file explorer tab and a
16
+ // read-only-by-default remote file tab with an explicit「编辑 → 保存到远程」flow
17
+ // (mtime optimistic lock via POST /dsh-remote/write).
18
+ //
19
+ // Client entries must be classic scripts registered via window.__ModuleLoader__.load
20
+ // ({ id, factory }); the factory receives a synchronous `require`.
21
+ window.__ModuleLoader__.load({
22
+ id: 'dsh-remote',
23
+ factory: (require) => {
24
+ var module = { exports: {} }
25
+ var exports = module.exports
26
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
27
+
28
+ const React = require('react')
29
+ const name = 'dsh-remote'
30
+
31
+ async function api(method, path, body) {
32
+ const opts = { method, headers: {} }
33
+ if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
34
+ const res = await fetch(path, opts)
35
+ const data = await res.json().catch(() => ({}))
36
+ if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
37
+ return data
38
+ }
39
+
40
+ // Like api() but returns {status, data} so callers can handle 409 etc.
41
+ async function apiRaw(method, path, body) {
42
+ const opts = { method, headers: {} }
43
+ if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
44
+ const res = await fetch(path, opts)
45
+ const data = await res.json().catch(() => ({}))
46
+ return { status: res.status, data }
47
+ }
48
+
49
+ // Theme via DSH design tokens so this follows the harness light/dark theme.
50
+ const v = (name, fb) => `var(${name}, ${fb})`
51
+ const T = {
52
+ bg: v('--dsw-alias-bg-layer-1', 'rgba(128,128,128,0.07)'),
53
+ bg2: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.10)'),
54
+ border: v('--dsw-alias-border-l2', 'rgba(128,128,128,0.35)'),
55
+ borderStrong: v('--dsw-alias-border-l3', 'rgba(128,128,128,0.5)'),
56
+ danger: v('--dsw-static-red-500', '#e06c75'),
57
+ dangerText: v('--dsw-static-red-400', '#e06c75'),
58
+ ok: v('--dsw-static-green-500', '#4caf7d'),
59
+ warn: v('--dsw-static-yellow-500', '#e6c07b'),
60
+ radius: 8,
61
+ muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
62
+ label: v('--dsw-alias-label-primary', '#e4e4e7'),
63
+ primary: v('--dsw-alias-button-primary-fill', '#2563eb'),
64
+ onPrimary: v('--dsw-alias-button-contrast-fill', '#fff'),
65
+ hoverBg: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.14)'),
66
+ }
67
+ const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
68
+ const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
69
+ const primaryBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: T.primary, color: T.onPrimary, cursor: 'pointer', fontWeight: 600 }
70
+ const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
71
+ const boxS = box
72
+
73
+ function humanSize(n) {
74
+ if (n == null) return ''
75
+ if (n < 1024) return n + ' B'
76
+ const units = ['KB', 'MB', 'GB', 'TB']
77
+ let i = -1
78
+ let v = n
79
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
80
+ return v.toFixed(v >= 100 ? 0 : 1) + ' ' + units[i]
81
+ }
82
+ function fmtTime(ts) {
83
+ if (!ts) return ''
84
+ const d = new Date(ts * 1000)
85
+ const pad = (x) => String(x).padStart(2, '0')
86
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
87
+ }
88
+
89
+ // Write text to the clipboard. Prefers the async Clipboard API (works on
90
+ // http://127.0.0.1 secure contexts) and falls back to the legacy
91
+ // execCommand('copy') path, mirroring primitives' writeClipboard semantics.
92
+ function copyToClipboard(text) {
93
+ if (navigator.clipboard && window.isSecureContext) {
94
+ return navigator.clipboard.writeText(text).then(() => true).catch(() => false)
95
+ }
96
+ try {
97
+ const ta = document.createElement('textarea')
98
+ ta.value = String(text)
99
+ ta.style.position = 'fixed'
100
+ ta.style.opacity = '0'
101
+ document.body.appendChild(ta)
102
+ ta.focus()
103
+ ta.select()
104
+ const ok = document.execCommand('copy')
105
+ ta.remove()
106
+ return Promise.resolve(ok)
107
+ } catch (e) {
108
+ return Promise.resolve(false)
109
+ }
110
+ }
111
+
112
+ let WORKSPACES = null // ctx.get('workspaces'), set in apply()
113
+ let SESSIONS = null // ctx.get('sessions'), set in apply()
114
+
115
+ // ── Settings → 远程工作区 (machine registry + forwards + audit) ──────────
116
+ function RemoteWorkspacePage() {
117
+ const [machines, setMachines] = React.useState([])
118
+ const [currentId, setCurrentId] = React.useState('')
119
+ const [form, setForm] = React.useState({
120
+ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '',
121
+ passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false,
122
+ encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '',
123
+ })
124
+ const [busy, setBusy] = React.useState(false)
125
+ const [msg, setMsg] = React.useState('')
126
+ const [err, setErr] = React.useState('')
127
+ const [tst, setTst] = React.useState('') // idle | testing | ok | failing
128
+ const [forwards, setForwards] = React.useState([])
129
+ const [fwdForm, setFwdForm] = React.useState({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false })
130
+ const [auditLines, setAuditLines] = React.useState([])
131
+ const [sshEntries, setSshEntries] = React.useState(null)
132
+ const [workspaces, setWorkspaces] = React.useState([])
133
+ const [advOpen, setAdvOpen] = React.useState(false) // 高级配置折叠
134
+
135
+ const testConn = () => {
136
+ if (!form.host.trim() || tst === 'testing') return
137
+ setTst('testing'); setErr(''); setMsg('')
138
+ api('POST', '/dsh-remote/test-connect', {
139
+ host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
140
+ password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
141
+ proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
142
+ })
143
+ .then((r) => {
144
+ if (r && r.ok) {
145
+ const shellTag = r.platform === 'windows' ? (r.gitBash ? '(Windows · Git Bash)' : '(Windows · 未找到 Git Bash)') : (r.platform === 'posix' ? '(POSIX)' : '')
146
+ setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)${shellTag}` : '连接成功'); refresh()
147
+ }
148
+ else { setTst('failing'); setErr((r && r.error) || '连接失败') }
149
+ })
150
+ .catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
151
+ }
152
+
153
+ const refresh = () => api('GET', '/dsh-remote/machines').then((r) => {
154
+ setMachines(r.machines || []); setCurrentId(r.currentId || '')
155
+ const cur = (r.machines || []).find((m) => m.id === r.currentId)
156
+ setWorkspaces((cur && cur.recentWorkspaces) || [])
157
+ })
158
+ React.useEffect(() => { refresh() }, [])
159
+
160
+ // ── update state ────────────────────────────────────────────────────────
161
+ const [upd, setUpd] = React.useState(null) // { current, latest, updateAvailable, updateMode, updatedMarker }
162
+ const [updBusy, setUpdBusy] = React.useState(false)
163
+ const [updMsg, setUpdMsg] = React.useState('')
164
+ const [updMode, setUpdMode] = React.useState('manual')
165
+ const checkUpdate = (quiet) => {
166
+ if (updBusy) return
167
+ setUpdBusy(true); setUpdMsg('')
168
+ api('GET', '/dsh-remote/update-check')
169
+ .then((r) => {
170
+ if (!r || !r.ok) { setUpdMsg((r && r.error) || '检查更新失败'); return }
171
+ setUpd(r)
172
+ if (r.updateMode) setUpdMode(r.updateMode)
173
+ if (!quiet) setUpdMsg(r.updateAvailable ? '发现新版本 v' + r.latest : '已是最新版本(v' + r.current + ')')
174
+ })
175
+ .catch((e) => setUpdMsg(String((e && e.message) || e)))
176
+ .finally(() => setUpdBusy(false))
177
+ }
178
+ const applyUpdateNow = () => {
179
+ if (!upd || !upd.latest) return
180
+ if (!window.confirm('将更新 dsh-remote 到 v' + upd.latest + ',替换本机插件文件。更新后需重启 Harness 生效。继续?')) return
181
+ setUpdBusy(true); setUpdMsg('')
182
+ api('POST', '/dsh-remote/update-apply', { version: upd.latest })
183
+ .then((r) => {
184
+ if (r && r.ok) setUpdMsg('✅ 已更新到 v' + r.to + '(' + (r.from || '') + ' → ' + r.to + ')。重启 Harness 生效。')
185
+ else setUpdMsg((r && r.error) || '更新失败')
186
+ checkUpdate(true)
187
+ })
188
+ .catch((e) => setUpdMsg('更新失败: ' + String((e && e.message) || e)))
189
+ .finally(() => setUpdBusy(false))
190
+ }
191
+ const saveUpdateMode = (mode) => {
192
+ setUpdMode(mode); setUpdMsg('更新模式已设为「' + (mode === 'auto' ? '自动' : mode === 'off' ? '关闭' : '手动') + '」')
193
+ api('POST', '/dsh-remote/update-mode', { mode }).catch((e) => setUpdMsg('模式保存失败: ' + String((e && e.message) || e)))
194
+ }
195
+ React.useEffect(() => { checkUpdate(true) }, [])
196
+
197
+ const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
198
+ React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
199
+
200
+ const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
201
+ const setFb = (k) => (ev) => setForm({ ...form, [k]: ev.target.checked })
202
+ const startEdit = (m) => {
203
+ setForm({
204
+ name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root',
205
+ password: '', privateKeyPath: m.privateKeyPath || '', passphrase: '', workspace: m.workspace || '',
206
+ hostKeyMode: m.hostKeyMode || '', useAgent: !!m.useAgent, keyboardInteractive: !!m.keyboardInteractive,
207
+ encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
208
+ proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
209
+ proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
210
+ id: m.id,
211
+ })
212
+ // 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串等),自动展开折叠区
213
+ if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
214
+ setAdvOpen(true)
215
+ }
216
+ }
217
+ const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '' })
218
+ const save = (action) => {
219
+ setBusy(true); setErr(''); setMsg('')
220
+ if (action === 'delete') {
221
+ api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { clearForm(); setMsg('已删除') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
222
+ return
223
+ }
224
+ api('POST', '/dsh-remote/machines', {
225
+ action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
226
+ username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
227
+ workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
228
+ encryptPassword: form.encryptPassword,
229
+ proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
230
+ }).then((r) => { refresh(); clearForm(); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
231
+ }
232
+ 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)) }
233
+ // Issue #13: "active remote = none" — saved machines stay, but no machine
234
+ // is current, so local sessions are never dragged into a remote context.
235
+ const clearCurrent = () => { setBusy(true); api('POST', '/dsh-remote/current', { id: '' }).then((r) => { setCurrentId(''); setMsg('已取消当前远程机 — 本地会话不再自动使用远程上下文') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
236
+ 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))) } }
237
+
238
+ const importSsh = () => {
239
+ api('GET', '/dsh-remote/ssh-config').then((r) => setSshEntries(r && r.present ? (r.entries || []) : []))
240
+ .catch((e) => setErr('读取 ~/.ssh/config 失败: ' + String((e && e.message) || e)))
241
+ }
242
+ const applySshEntry = (e) => {
243
+ setForm({ ...form,
244
+ name: e.host, host: e.hostName || e.host, port: String(e.port || 22),
245
+ username: e.user || 'root', privateKeyPath: e.identityFile || '',
246
+ })
247
+ setSshEntries(null)
248
+ setMsg('已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)')
249
+ }
250
+
251
+ const setWorkspaceNow = (p) => {
252
+ api('POST', '/dsh-remote/workspace', { path: p })
253
+ .then(() => setMsg('已切换到工作区 ' + p))
254
+ .catch((e) => setErr(String((e && e.message) || e)))
255
+ }
256
+
257
+ // ── forwards panel ────────────────────────────────────────────────────
258
+ const fwdAction = (action, id) => {
259
+ api('POST', '/dsh-remote/forwards', { action, id })
260
+ .then((r) => { setForwards(r.forwards || []); setErr(''); setMsg('') })
261
+ .catch((e) => setErr(String((e && e.message) || e)))
262
+ }
263
+ const fwdDefine = () => {
264
+ if (!fwdForm.listenPort) return
265
+ api('POST', '/dsh-remote/forwards', { action: 'define', listenPort: Number(fwdForm.listenPort), targetHost: fwdForm.targetHost || '127.0.0.1', targetPort: Number(fwdForm.targetPort) || Number(fwdForm.listenPort), direction: fwdForm.direction, autoStart: fwdForm.autoStart })
266
+ .then((r) => { setForwards(r.forwards || []); setFwdForm({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false }) })
267
+ .catch((e) => setErr(String((e && e.message) || e)))
268
+ }
269
+
270
+ const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 } },
271
+ React.createElement('label', { style: { width: 84, fontSize: 12, opacity: 0.8, flexShrink: 0 } }, label), ctrl)
272
+
273
+ return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 860 } },
274
+ React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
275
+ React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
276
+ '维护多台 SSH 机器(密码/私钥/agent/跳板机/钥匙串)。路径在新建/选择工作区时选:「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
277
+ React.createElement('div', { style: boxS },
278
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
279
+ machines.length
280
+ ? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
281
+ React.createElement('div', { style: { flex: 1, fontSize: 13 } },
282
+ m.name + ' ',
283
+ React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
284
+ m.passwordSet ? ' 🔒' : '',
285
+ (m.credentialBackend && m.credentialBackend !== 'plain') ? ' 🗝' : '',
286
+ m.proxy && m.proxy.host ? ' ⛳' : '',
287
+ m.latencyMs != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, ` · ${m.latencyMs}ms`) : null,
288
+ m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
289
+ React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
290
+ React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
291
+ React.createElement('button', { style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' }, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
292
+ : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
293
+ currentId
294
+ ? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 6, fontSize: 12, color: T.ok } },
295
+ '当前远程机:' + ((machines.find((m) => m.id === currentId) || {}).name || currentId),
296
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px', fontSize: 12, marginLeft: 'auto' }, onClick: clearCurrent }, '取消设为当前(active remote = none)'))
297
+ : React.createElement('div', { style: { paddingTop: 6, fontSize: 12, opacity: 0.6 } }, '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。'),
298
+ ),
299
+ React.createElement('div', { style: boxS },
300
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
301
+ React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
302
+ React.createElement('button', { style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? '收起' : '从 ~/.ssh/config 导入')),
303
+ sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 160, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
304
+ sshEntries.length ? sshEntries.map((e) => React.createElement('div', { key: e.host, onClick: () => applySshEntry(e), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, borderBottom: '1px solid ' + T.border } },
305
+ e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')))
306
+ : React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, '~/.ssh/config 里没有可导入的 Host 条目'))
307
+ : null,
308
+ row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
309
+ row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
310
+ row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
311
+ row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
312
+ row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显)', style: inputS }), 'w'),
313
+ // 高级配置折叠开关
314
+ React.createElement('button', {
315
+ type: 'button',
316
+ onClick: () => setAdvOpen(!advOpen),
317
+ style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
318
+ }, advOpen ? '▲ 收起高级配置' : '▼ 高级配置(私钥 / 跳板机 / agent 等)'),
319
+ advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
320
+ row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用密码或 agent', style: inputS }), 'k'),
321
+ row('Passphrase', React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: '私钥加密时使用', style: inputS }), 'pp'),
322
+ row('默认工作区', React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: '可选:该机器的默认远程目录', style: inputS }), 'ws'),
323
+ row('HostKey 模式', React.createElement('select', { value: form.hostKeyMode, onChange: setF('hostKeyMode'), style: { ...inputS, maxWidth: 220 } },
324
+ React.createElement('option', { value: '' }, '(默认 accept-new)'),
325
+ React.createElement('option', { value: 'accept-new' }, 'accept-new(信任首次,之后校验)'),
326
+ React.createElement('option', { value: 'verify' }, 'verify(严格:拒绝陌生主机)'),
327
+ React.createElement('option', { value: 'off' }, 'off(不校验,不推荐)')), 'hkm'),
328
+ React.createElement('div', { style: { display: 'flex', gap: 16, paddingLeft: 90, flexWrap: 'wrap', marginBottom: 8 } },
329
+ React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
330
+ React.createElement('input', { type: 'checkbox', checked: form.useAgent, onChange: setFb('useAgent') }), 'SSH agent(SSH_AUTH_SOCK)'),
331
+ React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
332
+ React.createElement('input', { type: 'checkbox', checked: form.keyboardInteractive, onChange: setFb('keyboardInteractive') }), 'keyboard-interactive(OTP/动态码)'),
333
+ React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
334
+ React.createElement('input', { type: 'checkbox', checked: form.encryptPassword, onChange: setFb('encryptPassword') }), '加密保存密码(系统钥匙串)')),
335
+ row('跳板机', React.createElement('input', { value: form.proxyHost, onChange: setF('proxyHost'), placeholder: '经此主机中转(可选)', style: inputS }), 'ph'),
336
+ row('端口/用户', React.createElement('div', { style: { display: 'flex', gap: 6, flex: 1, flexWrap: 'wrap' } },
337
+ React.createElement('input', { value: form.proxyPort, onChange: setF('proxyPort'), placeholder: '端口(22)', style: { ...inputS, minWidth: 90, maxWidth: 120 } }),
338
+ React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: '跳板用户', style: { ...inputS, minWidth: 140 } }),
339
+ React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: '跳板密码', style: { ...inputS, minWidth: 140 } })), 'pu'),
340
+ row('跳板私钥', React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: '跳板私钥路径(可选)', style: inputS }), 'pk'),
341
+ ) : null,
342
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', flexWrap: 'wrap', marginTop: 4 } },
343
+ msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
344
+ form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
345
+ React.createElement('button', { style: buttonS, onClick: () => { clearForm(); setErr(''); setTst('idle'); setAdvOpen(false) } }, form.id ? '取消编辑' : '清空'),
346
+ React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
347
+ tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
348
+ React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
349
+ ),
350
+ ),
351
+ // 快捷切换工作区(该机器的最近工作区)
352
+ workspaces.length ? React.createElement('div', { style: boxS },
353
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '当前机器最近工作区(一键切换)'),
354
+ workspaces.map((w, i) => React.createElement('div', { key: w + i, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
355
+ React.createElement('code', { style: { flex: 1, fontSize: 12, fontFamily: 'monospace' } }, w),
356
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, '切换')))) : null,
357
+ // 端口转发面板
358
+ React.createElement('div', { style: boxS },
359
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '端口转发(SSH 隧道)'),
360
+ forwards.length ? forwards.map((f) => React.createElement('div', { key: f.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0', fontSize: 12, fontFamily: 'monospace' } },
361
+ React.createElement('span', { style: { color: f.active ? T.ok : T.muted } }, f.active ? '●' : '○'),
362
+ React.createElement('span', { style: { flex: 1 } }, `${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}${f.autoStart ? ' [自启]' : ''}`),
363
+ f.active
364
+ ? React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('stop', f.id) }, '停止')
365
+ : React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('start', f.id) }, '启动'),
366
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('remove', f.id) }, '删除'))) : null,
367
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' } },
368
+ React.createElement('select', { value: fwdForm.direction, onChange: (e) => setFwdForm({ ...fwdForm, direction: e.target.value }), style: { ...inputS, maxWidth: 110 } },
369
+ React.createElement('option', { value: 'local' }, '本地→远端'),
370
+ React.createElement('option', { value: 'reverse' }, '远端→本地')),
371
+ React.createElement('input', { value: fwdForm.listenPort, onChange: (e) => setFwdForm({ ...fwdForm, listenPort: e.target.value }), placeholder: '监听端口', style: { ...inputS, maxWidth: 90 } }),
372
+ React.createElement('input', { value: fwdForm.targetHost, onChange: (e) => setFwdForm({ ...fwdForm, targetHost: e.target.value }), placeholder: '目标主机', style: { ...inputS, maxWidth: 120 } }),
373
+ React.createElement('input', { value: fwdForm.targetPort, onChange: (e) => setFwdForm({ ...fwdForm, targetPort: e.target.value }), placeholder: '目标端口', style: { ...inputS, maxWidth: 90 } }),
374
+ React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
375
+ React.createElement('input', { type: 'checkbox', checked: fwdForm.autoStart, onChange: (e) => setFwdForm({ ...fwdForm, autoStart: e.target.checked }) }), '自动重连'),
376
+ React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: fwdDefine, disabled: !fwdForm.listenPort }, '添加转发')),
377
+ ),
378
+ // 审计日志
379
+ React.createElement('div', { style: boxS },
380
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '最近执行的远程命令(审计日志)'),
381
+ auditLines.length
382
+ ? React.createElement('div', { style: { maxHeight: 180, overflowY: 'auto', fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 } },
383
+ auditLines.map((l, i) => React.createElement('div', { key: i, style: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, l)))
384
+ : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)'),
385
+ ),
386
+ err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
387
+ React.createElement('div', { style: boxS },
388
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '更新'),
389
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 } },
390
+ upd
391
+ ? React.createElement('span', { style: { opacity: 0.85 } },
392
+ '当前 v' + upd.current,
393
+ upd.updateAvailable ? React.createElement('b', { style: { color: T.ok, marginLeft: 6 } }, '→ 新版本 v' + upd.latest) : null)
394
+ : React.createElement('span', { style: { opacity: 0.6 } }, '版本信息加载中…'),
395
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => checkUpdate(false), disabled: updBusy }, updBusy ? '检查中…' : '检查更新'),
396
+ upd && upd.updateAvailable
397
+ ? React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontWeight: 700, color: T.ok }, onClick: applyUpdateNow, disabled: updBusy }, '立即更新')
398
+ : null,
399
+ ),
400
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
401
+ React.createElement('span', { style: { opacity: 0.8 } }, '更新模式:'),
402
+ ['manual', 'auto', 'off'].map((m) =>
403
+ React.createElement('button', {
404
+ key: m, style: { ...buttonS, padding: '2px 10px', fontWeight: updMode === m ? 700 : 400, color: updMode === m ? T.ok : 'inherit' },
405
+ onClick: () => saveUpdateMode(m), disabled: updBusy,
406
+ }, m === 'manual' ? '手动' : m === 'auto' ? '自动' : '关闭'),
407
+ ),
408
+ ),
409
+ updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') || updMsg.startsWith('已是最新') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
410
+ ),
411
+ React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11, opacity: 0.65, borderTop: '1px solid ' + T.border, paddingTop: 10, marginTop: 4 } },
412
+ React.createElement('span', {}, '觉得好用?'),
413
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
414
+ React.createElement('span', {}, '·'),
415
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
416
+ React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v' + (upd && upd.current ? upd.current : '?.?.?')),
417
+ ),
418
+ )
419
+ }
420
+
421
+ // ── Unified picker (fill the directory-flow holes) ───────────────────
422
+ function parseLs(text) {
423
+ const out = []
424
+ for (const ln of String(text || '').split('\n')) {
425
+ const t = ln.trim()
426
+ if (!t || !t.length || /^total\b/i.test(t)) continue
427
+ const parts = t.split(/\s+/).filter(Boolean)
428
+ if (parts.length < 8) continue
429
+ const mode = parts[0]
430
+ const name = parts.slice(8).join(' ')
431
+ if (!name || name === '.' || name === '..') continue
432
+ const isDir = mode.charAt(0) === 'd'
433
+ out.push({ name, dir: isDir })
434
+ }
435
+ return out
436
+ }
437
+
438
+ // Clickable breadcrumb of the current remote path. Windows remotes show a
439
+ // "This PC" root and `\` separators (此电脑 / C:\ / Users / dev); POSIX `/`.
440
+ function breadcrumb(active, cur, win, jumpTo) {
441
+ const raw = String(cur || '')
442
+ const isWinPath = win && (raw === '' || /^[a-zA-Z]:/.test(raw))
443
+ const rootLabel = isWinPath ? '此电脑' : '/'
444
+ const crumbs = []
445
+ const rootStyle = { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted, whiteSpace: 'nowrap' }
446
+ crumbs.push(React.createElement('span', { key: 'root', style: rootStyle, onClick: active ? () => jumpTo('') : undefined }, rootLabel))
447
+ if (isWinPath && raw !== '') {
448
+ const driveM = raw.match(/^([a-zA-Z]):(.*)$/)
449
+ const drive = driveM[1].toUpperCase() + ':\\'
450
+ crumbs.push(React.createElement('span', { key: 'sep0', style: { color: T.muted } }, '\\'))
451
+ crumbs.push(React.createElement('span', { key: 'drive', style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: raw === drive ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(drive) : undefined }, drive))
452
+ let acc = drive
453
+ for (const s of (driveM[2] || '').split('\\')) {
454
+ if (!s) continue
455
+ acc += s + '\\'
456
+ const target = acc.slice(0, -1)
457
+ crumbs.push(React.createElement('span', { key: 's|' + target, style: { color: T.muted } }, '\\'))
458
+ crumbs.push(React.createElement('span', { key: target, style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: target === raw ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(target) : undefined }, s))
459
+ }
460
+ } else if (!isWinPath && raw !== '' && raw !== '/') {
461
+ const segs = raw.replace(/\/+$/, '').split('/')
462
+ let acc = ''
463
+ for (const s of segs) {
464
+ if (!s) continue
465
+ acc += '/' + s
466
+ crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
467
+ crumbs.push(React.createElement('span', { key: acc, style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === raw ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(acc) : undefined }, s))
468
+ }
469
+ }
470
+ if (crumbs.length === 1) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, isWinPath ? ' 选择驱动器' : ''))
471
+ return React.createElement('span', { key: 'crumb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block', maxWidth: '100%' } }, crumbs)
472
+ }
473
+
474
+ function DirPicker(props) {
475
+ const { open, busy, onPicked, onCancel } = props
476
+ const [tab, setTab] = React.useState('local')
477
+ const [machines, setMachines] = React.useState([])
478
+ const [machineId, setMachineId] = React.useState('')
479
+ const [path, setPath] = React.useState('')
480
+ const [items, setItems] = React.useState(null)
481
+ const [err, setErr] = React.useState('')
482
+ const [loading, setLoading] = React.useState(false)
483
+ const [levels, setLevels] = React.useState(null)
484
+ const [popOpen, setPopOpen] = React.useState(false)
485
+ const [suggest, setSuggest] = React.useState([])
486
+ const [suggestOpen, setSuggestOpen] = React.useState(false)
487
+ const [recent, setRecent] = React.useState([])
488
+ const [win, setWin] = React.useState(false) // remote platform: windows = Git-Bash host
489
+ // 本机浏览浮层(browse 后端):{ path, home, crumbs, entries, truncated }。
490
+ const [localPop, setLocalPop] = React.useState(null)
491
+ const [newDirName, setNewDirName] = React.useState('')
492
+ const suggestTimer = React.useRef(null)
493
+ const suggestRef = React.useRef(null)
494
+ React.useEffect(() => {
495
+ if (!suggestOpen) return
496
+ const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
497
+ document.addEventListener('mousedown', onDown)
498
+ return () => document.removeEventListener('mousedown', onDown)
499
+ }, [suggestOpen])
500
+ const [mOpen, setMOpen] = React.useState(false)
501
+ const ddRef = React.useRef(null)
502
+ React.useEffect(() => {
503
+ if (!mOpen) return
504
+ const onDown = (ev) => { if (ddRef.current && !ddRef.current.contains(ev.target)) setMOpen(false) }
505
+ document.addEventListener('mousedown', onDown)
506
+ return () => document.removeEventListener('mousedown', onDown)
507
+ }, [mOpen])
508
+
509
+ const sortItems = (list) => {
510
+ const dirs = list.filter((it) => it.dir)
511
+ const files = list.filter((it) => !it.dir)
512
+ dirs.sort((a, b) => a.name.localeCompare(b.name))
513
+ files.sort((a, b) => a.name.localeCompare(b.name))
514
+ return dirs.concat(files)
515
+ }
516
+
517
+ // Client-side full-path fallback: when the server did not attach a `path`
518
+ // (older host half), join base + name with the base's own separator.
519
+ const joinDisplay = (base, name) => {
520
+ const b = String(base || '')
521
+ const n = String(name || '')
522
+ if (!b || b === '/') return '/' + n
523
+ const sep = /^[a-zA-Z]:/.test(b) || b.includes('\\') ? '\\' : '/'
524
+ return b.replace(/[\\/]+$/, '') + sep + n
525
+ }
526
+
527
+ const loadLevels = (id, p, toIndex) => {
528
+ if (!id) return
529
+ setLoading(true); setErr('')
530
+ api('POST', '/dsh-remote/current', { id }).catch(() => {})
531
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
532
+ .then((res) => {
533
+ setWin(res && res.platform === 'windows')
534
+ const real = res && res.path ? res.path : (p || '')
535
+ // Server returns [{ type, name, size, mtime, path }] where `path` is
536
+ // the FULL display-form path (C:\Users\dev on Windows) — the client
537
+ // never joins paths itself, so drives and Windows paths just work.
538
+ const list = Array.isArray(res && res.items)
539
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime, path: it.path || joinDisplay(real, it.name), drive: !!it.drive }))
540
+ : parseLs(res && res.text)
541
+ const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
542
+ setLevels((prev) => {
543
+ const base = prev && prev.length ? prev.slice() : []
544
+ let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
545
+ if (idx >= base.length) return base.concat([node])
546
+ base[idx] = node
547
+ return base.slice(0, idx + 1)
548
+ })
549
+ })
550
+ .catch((e) => setErr(String((e && e.message) || e)))
551
+ .finally(() => setLoading(false))
552
+ }
553
+
554
+ React.useEffect(() => { if (open) {
555
+ // Bug fix: the picker must NOT reopen with the previous selection's
556
+ // path — reset the path bar (and the browse state) on every open.
557
+ setPath(''); setLevels(null); setWin(false)
558
+ setLocalPop(null); setNewDirName('')
559
+ api('GET', '/dsh-remote/machines').then((r) => {
560
+ setMachines(r.machines || [])
561
+ const cur = (r.machines || []).find((m) => m.id === r.currentId)
562
+ setRecent((cur && cur.recentWorkspaces) || [])
563
+ setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '')
564
+ })
565
+ } }, [open])
566
+
567
+ // 本机目录浏览(browse 后端兜底):列出 path 的子目录;path 为空 → 宿主家目录。
568
+ const loadLocal = (p) => {
569
+ setLoading(true); setErr('')
570
+ api('GET', '/dsh-remote/local-list' + (p ? '?path=' + encodeURIComponent(p) : ''))
571
+ .then((r) => setLocalPop({ path: (r && r.path) || p || '', home: (r && r.home) || '', crumbs: (r && r.crumbs) || [], entries: (r && r.entries) || [], truncated: !!(r && r.truncated), drives: (r && r.drives) || [] }))
572
+ .catch((e) => setErr(String((e && e.message) || e)))
573
+ .finally(() => setLoading(false))
574
+ }
575
+
576
+ const chooseLocal = () => {
577
+ setLoading(true); setErr('')
578
+ api('POST', '/dsh-remote/local-pick')
579
+ .then((r) => {
580
+ if (r && r.path) { setLoading(false); onPicked(String(r.path)) }
581
+ // browse 后端(DSH Desktop / 无显示的远程宿主):没有 OS 对话框可开
582
+ // → 打开内置本机目录浏览器。
583
+ else if (r && r.kind === 'browse') loadLocal('')
584
+ else if (r && r.cancelled) { setLoading(false); setErr('已取消选择') }
585
+ else { setLoading(false); setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径') }
586
+ })
587
+ .catch((e) => { setLoading(false); setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径') })
588
+ }
589
+
590
+ // 在浮层当前目录下新建文件夹(browse 后端 createDirectory),成功后刷新列表。
591
+ const mkdirLocal = () => {
592
+ const cur = localPop
593
+ const name = newDirName.trim()
594
+ if (!cur || !cur.path || !name || loading) return
595
+ setLoading(true); setErr('')
596
+ api('POST', '/dsh-remote/local-mkdir', { path: cur.path, name })
597
+ .then(() => { setNewDirName(''); loadLocal(cur.path) })
598
+ .catch((e) => { setErr(String((e && e.message) || e)); setLoading(false) })
599
+ }
600
+
601
+ // 选用浮层中的本机目录:回填输入框(不直接提交),复核后再点「选用此本地路径」。
602
+ const acceptLocalPick = (p) => {
603
+ setPath(String(p || ''))
604
+ setErr('')
605
+ setLocalPop(null)
606
+ }
607
+
608
+ const switchTab = (t) => {
609
+ setTab(t); setErr('')
610
+ // each tab keeps its own path — never leak the other tab's selection
611
+ setPath('')
612
+ if (t === 'remote') {
613
+ // Windows remotes show a "This PC" drive view at the root; POSIX shows
614
+ // the filesystem root. Either way loadLevels('') lists that root.
615
+ if (machineId) loadLevels(machineId, '', 0)
616
+ }
617
+ }
618
+
619
+ // Parent path of a display-form remote path (client-side, both separators).
620
+ const parentOf = (p) => {
621
+ const t = String(p || '')
622
+ if (!t || t === '/') return null
623
+ const win = /^[a-zA-Z]:/.test(t)
624
+ if (win && /^[a-zA-Z]:\\?$/.test(t)) return null // drive root: no parent
625
+ const sep = win ? '\\' : '/'
626
+ const idx = t.lastIndexOf(sep)
627
+ if (idx <= 0) return null
628
+ const par = t.slice(0, idx)
629
+ if (win && /^[a-zA-Z]:$/.test(par)) return par + '\\'
630
+ return par || '/'
631
+ }
632
+
633
+ // enterDir(it): drive into the given entry — `it.path` is the FULL
634
+ // display-form path from the server (drives C:\, Windows and POSIX alike).
635
+ const enterDir = (it) => {
636
+ if (busy || loading || !it || !it.dir) return
637
+ loadLevels(machineId, it.path, (levels ? levels.length : 0))
638
+ }
639
+
640
+ // Navigate one level up in the browse popup. When the current level was
641
+ // opened directly at a deep path (levels.length === 1 — e.g. the popup
642
+ // was opened at the path bar's value) the parent is loaded as the new top
643
+ // level instead of being disabled.
644
+ const goUp = () => {
645
+ if (loading) return
646
+ const last = levels && levels.length ? levels[levels.length - 1] : null
647
+ if (!last) return
648
+ if (levels.length > 1) { setLevels((p) => p.slice(0, p.length - 1)); return }
649
+ const par = parentOf(last.path)
650
+ if (par) loadLevels(machineId, par, 1)
651
+ }
652
+
653
+ const loadSuggestions = (raw, mid) => {
654
+ const id = mid || machineId
655
+ if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
656
+ const t = String(raw || '').trim()
657
+ if (!t) { setSuggest([]); setSuggestOpen(false); return }
658
+ // Separator-aware split: `\` when the input looks like a Windows path.
659
+ const backslash = t.includes('\\') || /^[a-zA-Z]:/.test(t)
660
+ const sep = backslash ? '\\' : '/'
661
+ const idx = t.lastIndexOf(sep)
662
+ let parent, lastSeg
663
+ if (idx < 0) { parent = backslash ? t + '\\' : '/'; lastSeg = backslash ? '' : t }
664
+ else if (idx === t.length - 1) { parent = t.slice(0, idx) || '/'; lastSeg = '' }
665
+ else { parent = t.slice(0, idx) || '/'; lastSeg = t.slice(idx + 1) }
666
+ api('POST', '/dsh-remote/current', { id }).catch(() => {})
667
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
668
+ .then((res) => {
669
+ const list = Array.isArray(res && res.items)
670
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(parent, it.name) }))
671
+ : []
672
+ const matches = list
673
+ .filter((it) => it.dir && (!lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase())))
674
+ .slice(0, 40).map((it) => it.path)
675
+ setSuggest(matches)
676
+ setSuggestOpen(!!matches.length)
677
+ }).catch(() => { setSuggest([]); setSuggestOpen(false) })
678
+ }
679
+
680
+ const onPathChange = (raw) => {
681
+ setPath(raw); setErr('')
682
+ if (suggestTimer.current) clearTimeout(suggestTimer.current)
683
+ suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
684
+ }
685
+
686
+ const continueSuggest = (dir) => {
687
+ if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
688
+ setSuggestOpen(false)
689
+ api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
690
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
691
+ .then((res) => {
692
+ const list = Array.isArray(res && res.items)
693
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(dir, it.name) }))
694
+ : []
695
+ const kids = list.slice(0, 40).map((it) => it.path)
696
+ setSuggest(kids)
697
+ setSuggestOpen(!!kids.length)
698
+ }).catch(() => { setSuggest([]); setSuggestOpen(false) })
699
+ }
700
+
701
+ const selectSuggestion = (s) => {
702
+ if (!s) { setSuggestOpen(false); return }
703
+ // Auto-append the trailing separator so the path reads as a directory
704
+ // and the user can keep typing the next segment right away.
705
+ const sep = String(s).includes('\\') ? '\\' : '/'
706
+ const withSep = String(s).replace(/[\\/]+$/, '') + sep
707
+ setPath(withSep); setErr(''); setSuggestOpen(false)
708
+ continueSuggest(withSep)
709
+ }
710
+
711
+ const commitPath = (p) => {
712
+ const target = String(p || '').trim()
713
+ if (!target || !machineId || busy) return
714
+ setPopOpen(false); setSuggestOpen(false)
715
+ 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)))
716
+ }
717
+
718
+ const acceptBrowserPick = (p) => {
719
+ setPath(String(p || ''))
720
+ setSuggestOpen(false)
721
+ setPopOpen(false)
722
+ }
723
+
724
+ const pickHome = () => {
725
+ api('POST', '/dsh-remote/home').then((r) => {
726
+ if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
727
+ else setErr((r && (r.hint || r.error)) || '无法解析主目录')
728
+ }).catch((e) => setErr(String((e && e.message) || e)))
729
+ }
730
+
731
+ const mkdirHere = () => {
732
+ const last = levels && levels.length ? levels[levels.length - 1] : null
733
+ const base = (last && last.path) || '/'
734
+ const nm = window.prompt('新建目录名(在 ' + base + ' 下)')
735
+ if (!nm || !nm.trim()) return
736
+ const sep = base.includes('\\') ? '\\' : '/'
737
+ const full = base === '/' ? '/' + nm.trim() : base + sep + nm.trim()
738
+ api('POST', '/dsh-remote/fs', { op: 'mkdir', path: full })
739
+ .then(() => { setErr(''); loadLevels(machineId, base, (levels ? levels.length - 1 : 0)) })
740
+ .catch((e) => setErr(String((e && e.message) || e)))
741
+ }
742
+
743
+ // 本机浏览浮层的面包屑:browse 后端给出完整祖先链(含盘符根),可点击跳转。
744
+ const localCrumbs = (cur) => {
745
+ const crumbs = (cur && cur.crumbs) || []
746
+ if (!crumbs.length) return React.createElement('span', null, '/')
747
+ const out = []
748
+ crumbs.forEach((c, i) => {
749
+ if (i > 0) out.push(React.createElement('span', { key: 'sep' + i, style: { color: T.muted } }, ' › '))
750
+ out.push(React.createElement('span', {
751
+ key: c.path || i,
752
+ title: c.path,
753
+ onClick: () => loadLocal(c.path),
754
+ style: { cursor: 'pointer', color: i === crumbs.length - 1 ? T.label : T.ok, fontWeight: i === crumbs.length - 1 ? 700 : 400 },
755
+ }, c.name))
756
+ })
757
+ return React.createElement('span', null, out)
758
+ }
759
+
760
+ // 本机目录浏览浮层(browse 后端):进入子目录 / 面包屑跳转 / 回家目录 /
761
+ // 新建文件夹 / 选用回填。与远程浏览弹层同一套交互。
762
+ function renderLocalPopup() {
763
+ const cur = localPop
764
+ if (!cur) return null
765
+ const crumbs = cur.crumbs || []
766
+ const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
767
+ return React.createElement('div', {
768
+ style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
769
+ onClick: () => setLocalPop(null),
770
+ },
771
+ 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(460px, 84vh)', overflow: 'hidden' }, onClick: (e) => e.stopPropagation() },
772
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
773
+ React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => parent && loadLocal(parent), disabled: !parent || loading, title: parent || '' }, '回上一级 ▴'),
774
+ cur.home ? React.createElement('button', { style: { ...buttonS, padding: '3px 8px' }, onClick: () => loadLocal(cur.home), disabled: loading, title: cur.home }, '🏠') : null,
775
+ React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, localCrumbs(cur)),
776
+ React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setLocalPop(null) }, '关闭 ✕'),
777
+ ),
778
+ // 盘符切换行(Windows 无统一根目录,browse 面包屑到不了其他盘)。
779
+ (cur.drives && cur.drives.length > 1) ? React.createElement('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', padding: '6px 12px', borderBottom: '1px solid ' + T.border } },
780
+ cur.drives.map((d) => {
781
+ const active = String(cur.path).toUpperCase().startsWith(String(d.name).toUpperCase())
782
+ return React.createElement('button', {
783
+ key: d.path,
784
+ onClick: () => loadLocal(d.path),
785
+ disabled: loading,
786
+ title: d.path,
787
+ style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace', fontWeight: active ? 700 : 400, color: active ? T.ok : T.label },
788
+ }, d.name)
789
+ }),
790
+ ) : null,
791
+ React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
792
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
793
+ : (cur.entries.length ? cur.entries.slice(0, 400).map((it, i) => React.createElement('div', {
794
+ key: it.path || i, title: '进入 ' + it.name,
795
+ onClick: () => loadLocal(it.path),
796
+ style: { padding: '7px 12px', cursor: 'pointer', color: T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
797
+ },
798
+ React.createElement('span', null, '📁'),
799
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
800
+ )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(没有子目录 — 可直接选用当前目录)')),
801
+ cur.truncated ? React.createElement('div', { style: { opacity: 0.6, padding: '6px 12px', fontSize: 11 } }, '(条目过多已截断)') : null,
802
+ ),
803
+ err ? React.createElement('div', { style: { color: T.danger, fontSize: 12, padding: '4px 12px' } }, err) : null,
804
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
805
+ React.createElement('input', { value: newDirName, onChange: (e) => setNewDirName(e.target.value), placeholder: '新建文件夹', style: { ...inputS, width: 110, flex: '0 0 auto', padding: '4px 8px', fontSize: 12 } }),
806
+ React.createElement('button', { style: { ...buttonS, padding: '4px 10px', whiteSpace: 'nowrap' }, onClick: mkdirLocal, disabled: loading || !newDirName.trim() || !cur.path }, '新建'),
807
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + cur.path),
808
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600, whiteSpace: 'nowrap' }, onClick: () => acceptLocalPick(cur.path) }, '选用此路径'),
809
+ ),
810
+ ),
811
+ )
812
+ }
813
+
814
+ function renderDirPopup() {
815
+ if (!levels || !levels.length) {
816
+ return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
817
+ }
818
+ const last = levels[levels.length - 1]
819
+ const entries = last.all || []
820
+ return React.createElement('div', { style: { border: '1px solid ' + T.borderStrong, borderRadius: 10, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), boxShadow: '0 8px 32px rgba(0,0,0,0.4)', display: 'flex', flexDirection: 'column', maxHeight: 'min(300px, 46vh)', overflow: 'hidden' } },
821
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
822
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: goUp, disabled: loading || (levels.length <= 1 && !parentOf(last.path)) }, '回上一级 ▴'),
823
+ React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, breadcrumb(!!machineId, last.path, win, (p) => setLevels((prev) => { const cut = (prev || []).findIndex((lv) => lv.path === p); return cut >= 0 ? prev.slice(0, cut + 1) : prev }))),
824
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: mkdirHere }, '新建目录'),
825
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
826
+ ),
827
+ React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
828
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
829
+ : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
830
+ key: i, title: it.drive ? '进入驱动器 ' + it.name : (it.dir ? '进入 ' + it.name : '文件: ' + it.name),
831
+ onClick: it.dir ? () => enterDir(it) : undefined,
832
+ style: { padding: '6px 10px', cursor: it.dir ? 'pointer' : 'default', color: T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
833
+ },
834
+ React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label, fontSize: 13 } }, it.drive ? '💽' : (it.dir
835
+ ? React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor' }, React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' }))
836
+ : React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', opacity: 0.85 }, React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' })))),
837
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
838
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
839
+ )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
840
+ ),
841
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
842
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + (last.path || (win ? '(驱动器根)' : '/'))),
843
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
844
+ ),
845
+ )
846
+ }
847
+
848
+ if (!open) return null
849
+ const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
850
+ 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() } },
851
+ 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)', maxHeight: 'min(680px, 92vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
852
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
853
+ React.createElement('div', null, '选择工作目录'),
854
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
855
+ ),
856
+ React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
857
+ tabBtn('local', '本机'),
858
+ tabBtn('remote', '远程'),
859
+ ),
860
+ tab === 'local'
861
+ ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
862
+ React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '弹出系统文件夹对话框选择;不可用时用内置目录浏览,也可直接输入本机目录。'),
863
+ React.createElement('div', { style: { display: 'flex', gap: 6 } },
864
+ React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
865
+ React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
866
+ ),
867
+ React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
868
+ err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
869
+ localPop ? renderLocalPopup() : null,
870
+ )
871
+ : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
872
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
873
+ React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
874
+ React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
875
+ React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
876
+ (machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
877
+ ),
878
+ mOpen
879
+ ? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, zIndex: 1000, marginTop: 2, minWidth: 300, maxHeight: 260, overflow: 'auto', background: v('--dsw-alias-bg-overlay', '#1f1f23'), border: '1px solid ' + T.border, borderRadius: T.radius, padding: 4 } },
880
+ machines.length
881
+ ? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setRecent(m.recentWorkspaces || []); setLevels(null); setPath(''); setMOpen(false); if (m.id) loadLevels(m.id, '', 0) }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
882
+ : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
883
+ )
884
+ : null,
885
+ ),
886
+ ),
887
+ // 最近工作区快捷入口
888
+ recent.length ? React.createElement('div', { style: { display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' } },
889
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.6, whiteSpace: 'nowrap' } }, '最近:'),
890
+ recent.map((w, i) => React.createElement('button', { key: w + i, onClick: () => { setPath(w); setErr(''); continueSuggest(w) }, style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace' } }, w))) : null,
891
+ React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
892
+ React.createElement('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
893
+ React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? (win ? '输入远程路径,如 C:\\Users\\…(自动补全)' : '输入远程路径(自动补全)') : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
894
+ React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (!machineId) return; const p = path.trim(); loadLevels(machineId, p || '', 0); setPopOpen(true) }, disabled: !machineId }, '浏览…'),
895
+ React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, '~ 主目录'),
896
+ ),
897
+ (suggestOpen && suggest.length)
898
+ ? React.createElement('div', { style: { marginTop: 6, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
899
+ 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)),
900
+ )
901
+ : null,
902
+ ),
903
+ popOpen ? renderDirPopup() : null,
904
+ err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
905
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
906
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
907
+ React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
908
+ ),
909
+ ),
910
+ (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
911
+ React.createElement('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center', paddingTop: 6, fontSize: 10, opacity: 0.5 } },
912
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ Star dsh-remote'),
913
+ React.createElement('span', {}, '·'),
914
+ React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '提建议 / 报问题'),
915
+ ),
916
+ ),
917
+ )
918
+ }
919
+
920
+ // ── better-sidebar integration (optional) ────────────────────────────────
921
+ const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
922
+ const SIDEBAR_FILE_ID = 'dsh-remote:file'
923
+
924
+ function fetchRemoteStatus() {
925
+ return api('GET', '/dsh-remote/status').catch(() => null)
926
+ }
927
+
928
+ // Resolve the remote path this session shows. Issue #13: the ONLY source
929
+ // of truth is the session's own cwd mapped through /dsh-remote/resolve-
930
+ // mirror — a cwd outside any mirror dir means "this session is NOT a
931
+ // remote session", and the sidebar must NOT fall back to the machine
932
+ // workspace (that made an unrelated saved machine leak into local
933
+ // sessions).
934
+ function resolveSessionRemote(sessionId) {
935
+ if (!sessionId) return Promise.resolve('')
936
+ // 1) Prefer the client-side session list cwd (mirror dir under
937
+ // $DSH_HOME/remote-workspaces/...) passed as ?local=.
938
+ let local = ''
939
+ try {
940
+ const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
941
+ ? SESSIONS.list.getSnapshot()
942
+ : null
943
+ const entry = list && list.byId && list.byId[sessionId]
944
+ if (entry && entry.cwd) local = String(entry.cwd)
945
+ } catch { /* sessions store shape differs */ }
946
+ if (local) {
947
+ return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(local))
948
+ .then((r) => (r && r.remotePath) || '')
949
+ .catch(() => '')
950
+ }
951
+ // 2) Fall back to host-side session header cwd.
952
+ return api('GET', '/dsh-remote/resolve-mirror?sessionId=' + encodeURIComponent(sessionId))
953
+ .then((r) => (r && r.remotePath) || '')
954
+ .catch(() => '')
955
+ }
956
+
957
+ function readRemoteFile(path, maxBytes) {
958
+ return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
959
+ throw new Error('远程读取失败: ' + ((e && e.message) || e))
960
+ })
961
+ }
962
+
963
+ function listRemoteDir(path) {
964
+ return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
965
+ throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
966
+ })
967
+ }
968
+
969
+ function joinRemote(base, name) {
970
+ if (!base) return name
971
+ return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
972
+ }
973
+
974
+ // ── Remote explorer tab ────────────────────────────────────────────────
975
+ // Inline monochrome icons (fill: currentColor → follows the row text color,
976
+ // i.e. black on the sidebar). Folder open/closed + file.
977
+ const IconFolder = (open) => React.createElement('svg', {
978
+ width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0 },
979
+ },
980
+ open
981
+ ? React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' })
982
+ : React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' }),
983
+ )
984
+ const IconFile = () => React.createElement('svg', {
985
+ width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0, opacity: 0.85 },
986
+ },
987
+ React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' }),
988
+ )
989
+
990
+ // Tree-style remote file explorer. Uses the same `expanded` / `onToggleDir`
991
+ // contract as the built-in local file tree (better-sidebar framework
992
+ // manages + persists the expanded set), so interaction and look match the
993
+ // local files tab. Data is listed live from the remote host over
994
+ // /dsh-remote/ls, rooted at this session's remote workspace dir.
995
+ function RemoteExplorerTab(props) {
996
+ const [status, setStatus] = React.useState(null)
997
+ const [root, setRoot] = React.useState('') // remote root path for this session
998
+ const [data, setData] = React.useState({}) // dir -> { entries | error | loading }
999
+ const dataRef = React.useRef(data)
1000
+ const [err, setErr] = React.useState('')
1001
+ const [busy, setBusy] = React.useState('')
1002
+ const [picker, setPicker] = React.useState(false)
1003
+ const [menu, setMenu] = React.useState(null) // { x, y, path, isDir, name }
1004
+ const [rootOpen, setRootOpen] = React.useState(true) // first level expanded by default
1005
+ const [hoverPath, setHoverPath] = React.useState('') // row hover highlight
1006
+ /** Row whose path was just copied — shows a brief「已复制」label. */
1007
+ const [copiedPath, setCopiedPath] = React.useState('')
1008
+ const expanded = Array.isArray(props.expanded) ? props.expanded : []
1009
+
1010
+ const storeLevel = (path, level) => {
1011
+ dataRef.current = { ...dataRef.current, [path]: level }
1012
+ setData(dataRef.current)
1013
+ }
1014
+
1015
+ const loadDir = React.useCallback((dir, opts) => {
1016
+ const cur = dataRef.current
1017
+ const existing = cur[dir]
1018
+ // If we already have data (entries loaded) and this is a background
1019
+ // refresh, keep what we have — don't flash a loading state over a
1020
+ // populated tree.
1021
+ if (existing && !existing.loading && opts && opts.keep) return
1022
+ if (cur[dir] !== undefined && !cur[dir].loading) return
1023
+ storeLevel(dir, { loading: true })
1024
+ listRemoteDir(dir)
1025
+ .then((res) => {
1026
+ const items = Array.isArray(res && res.items) ? res.items : []
1027
+ const sorted = items.slice().sort((a, b) => {
1028
+ if (a.type === 'dir' && b.type !== 'dir') return -1
1029
+ if (a.type !== 'dir' && b.type === 'dir') return 1
1030
+ return String(a.name).localeCompare(String(b.name))
1031
+ })
1032
+ storeLevel(dir, { entries: sorted })
1033
+ })
1034
+ .catch((e) => {
1035
+ // Keep any previously-loaded entries under this dir (so a transient
1036
+ // SFTP failure doesn't collapse the whole subtree); surface a
1037
+ // message only when there was nothing to show before.
1038
+ const prev = dataRef.current[dir]
1039
+ if (prev && prev.entries) {
1040
+ storeLevel(dir, { ...prev, softError: String((e && e.message) || e) })
1041
+ } else {
1042
+ storeLevel(dir, { error: String((e && e.message) || e) })
1043
+ }
1044
+ })
1045
+ }, [])
1046
+
1047
+ // Resolve this session's remote root, then load it and every expanded dir.
1048
+ // Re-runs whenever the session scope changes (switching conversations, or
1049
+ // the async sessionCwd fetch landing), so the sidebar follows the session's
1050
+ // own workspace dir.
1051
+ const scopeCwd = props.scope && props.scope.cwd
1052
+ const sessionId = props.scope && props.scope.sessionId
1053
+ const rootRef = React.useRef('')
1054
+ const refreshStatus = React.useCallback((opts) => {
1055
+ const applyRoot = (r, connected) => {
1056
+ const prevRoot = rootRef.current
1057
+ rootRef.current = r
1058
+ setRoot(r)
1059
+ // Full reset ONLY when the session's remote root actually changed
1060
+ // (different workspace / different conversation). When we just come
1061
+ // back to this tab with the same workspace, keep the loaded tree so
1062
+ // expanded dirs and their contents stay put.
1063
+ const sameRoot = prevRoot && r && prevRoot === r
1064
+ if (!sameRoot) {
1065
+ setData({})
1066
+ dataRef.current = {}
1067
+ }
1068
+ if (r) {
1069
+ // Not connected (e.g. freshly restarted, machine no longer active):
1070
+ // show a clear disconnected state instead of firing ls requests
1071
+ // that all fail with AggregateError / 500 in a loop.
1072
+ if (!connected) {
1073
+ storeLevel(r, { error: '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)' })
1074
+ return
1075
+ }
1076
+ if (!sameRoot) {
1077
+ storeLevel(r, { loading: true })
1078
+ }
1079
+ listRemoteDir(r)
1080
+ .then((res) => {
1081
+ const items = Array.isArray(res && res.items) ? res.items : []
1082
+ const sorted = items.slice().sort((a, b) => {
1083
+ if (a.type === 'dir' && b.type !== 'dir') return -1
1084
+ if (a.type !== 'dir' && b.type === 'dir') return 1
1085
+ return String(a.name).localeCompare(String(b.name))
1086
+ })
1087
+ storeLevel(r, { entries: sorted, missing: !!(res && res.missing) })
1088
+ for (const dir of expanded) loadDir(dir, { keep: true })
1089
+ })
1090
+ .catch((e) => {
1091
+ // Transient connection failure: keep the previously visible
1092
+ // root level so the tree doesn't collapse to an error.
1093
+ const prev = dataRef.current[r]
1094
+ if (prev && prev.entries) {
1095
+ storeLevel(r, { ...prev, softError: String((e && e.message) || e) })
1096
+ } else {
1097
+ storeLevel(r, { error: String((e && e.message) || e) })
1098
+ }
1099
+ })
1100
+ }
1101
+ }
1102
+ const resolveRoot = () => {
1103
+ if (scopeCwd) {
1104
+ return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(scopeCwd))
1105
+ .then((r) => (r && r.remotePath) || '')
1106
+ .catch(() => '')
1107
+ }
1108
+ if (sessionId) return resolveSessionRemote(sessionId)
1109
+ return Promise.resolve('')
1110
+ }
1111
+ resolveRoot().then((r) => {
1112
+ fetchRemoteStatus().then((s) => {
1113
+ setStatus(s)
1114
+ // Issue #13: NEVER fall back to the machine-level workspace here —
1115
+ // a session whose cwd is not a mirror is a LOCAL session and shows
1116
+ // the "no remote workspace" empty state instead.
1117
+ applyRoot(r || '', !!(s && s.connected))
1118
+ })
1119
+ })
1120
+ }, [scopeCwd, sessionId])
1121
+
1122
+ React.useEffect(() => { refreshStatus() }, [refreshStatus])
1123
+ React.useEffect(() => { if (props.visible) refreshStatus({ visible: true }) }, [props.visible])
1124
+ // Load dirs as they get expanded (keep already-loaded content).
1125
+ React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir, { keep: true }) }, [root, expanded, loadDir])
1126
+ // ── Context-menu portal: append to document.body so the sidebar's
1127
+ // overflow/transform cannot clip it. Pure DOM, no React portal needed.
1128
+ React.useEffect(() => {
1129
+ if (!menu) return
1130
+ const host = document.createElement('div')
1131
+ host.style.cssText = 'position:fixed;z-index:999999;pointer-events:auto'
1132
+ const box = document.createElement('div')
1133
+ box.style.cssText = 'position:fixed;left:' + menu.x + 'px;top:' + menu.y + 'px;z-index:999999;background:#1f1f23;border:1px solid rgba(128,128,128,0.5);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.4);min-width:170;font-family:system-ui,sans-serif'
1134
+ const mkItem = (label, danger, fn) => {
1135
+ const d = document.createElement('div')
1136
+ d.textContent = label
1137
+ d.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;color:' + (danger ? '#e06c75' : '#e4e4e7') + ';border-radius:4px;white-space:nowrap;transition:background 0.1s'
1138
+ d.onmouseenter = () => { d.style.background = 'rgba(128,128,128,0.14)' }
1139
+ d.onmouseleave = () => { d.style.background = 'transparent' }
1140
+ d.addEventListener('click', (e) => { e.stopPropagation(); setMenu(null); fn() })
1141
+ return d
1142
+ }
1143
+ box.appendChild(mkItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }))
1144
+ box.appendChild(mkItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })))
1145
+ box.appendChild(mkItem('📋 复制相对地址', false, () => copyPath(relPath(menu.path), menu.path)))
1146
+ box.appendChild(mkItem('📋 复制绝对地址', false, () => copyPath(menu.path, menu.path)))
1147
+ box.appendChild(mkItem('✏ 重命名', false, () => { const nm = window.prompt('重命名为(新名字)', menu.name); if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true) }))
1148
+ box.appendChild(mkItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => { if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true) }))
1149
+ host.appendChild(box)
1150
+ document.body.appendChild(host)
1151
+ const close = (e) => { if (!box.contains(e.target)) { setMenu(null) } }
1152
+ // Delay adding listener so the same mousedown that opened the menu doesn't close it
1153
+ const tid = setTimeout(() => document.addEventListener('mousedown', close), 0)
1154
+ return () => { clearTimeout(tid); document.removeEventListener('mousedown', close); host.remove() }
1155
+ }, [menu])
1156
+
1157
+ const openFile = (p) => {
1158
+ const name = String(p).split(/[\\/]/).pop() || p
1159
+ props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
1160
+ }
1161
+
1162
+ const doFs = (op, payload, refreshParent) => {
1163
+ setBusy(op)
1164
+ return api('POST', '/dsh-remote/fs', { op, ...payload })
1165
+ .then(() => {
1166
+ setErr('')
1167
+ if (refreshParent) {
1168
+ dataRef.current = {}
1169
+ setData({})
1170
+ if (root) { storeLevel(root, { loading: true }); loadDir(root) }
1171
+ for (const dir of expanded) loadDir(dir)
1172
+ }
1173
+ })
1174
+ .catch((e) => setErr(String((e && e.message) || e)))
1175
+ .finally(() => setBusy(''))
1176
+ }
1177
+
1178
+ const onCtx = (e, path, isDir, name) => {
1179
+ e.preventDefault(); e.stopPropagation()
1180
+ setMenu({ x: e.clientX, y: e.clientY, path, isDir, name })
1181
+ }
1182
+ const ctxItem = (label, danger, fn) => React.createElement('div', {
1183
+ onClick: (e) => { e.stopPropagation(); setMenu(null); fn() },
1184
+ style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
1185
+ }, label)
1186
+
1187
+ // The path shown by「复制相对地址」: relative to this session's remote
1188
+ // workspace root (mirror of the built-in local tree's cwd-relative copy).
1189
+ const relPath = (p) => {
1190
+ if (!root) return p
1191
+ const r = String(root).replace(/[\\/]+$/, '')
1192
+ const s = String(p)
1193
+ if (s === r) return '.'
1194
+ if (s.startsWith(r + '/')) return s.slice(r.length + 1)
1195
+ if (s.startsWith(r + '\\')) return s.slice(r.length + 1)
1196
+ return s
1197
+ }
1198
+ // Copy `text`, then briefly flag the row whose path was copied.
1199
+ const copyPath = (text, path) => {
1200
+ copyToClipboard(text).then((ok) => {
1201
+ if (!ok) return
1202
+ setCopiedPath(path)
1203
+ window.setTimeout(() => {
1204
+ setCopiedPath((cur) => (cur === path ? '' : cur))
1205
+ }, 1200)
1206
+ })
1207
+ }
1208
+ /** Trailing「已复制」feedback for a row (after a successful copy). */
1209
+ const copiedMark = (path) => (copiedPath === path
1210
+ ? React.createElement('span', { style: { fontSize: 11, opacity: 0.75, flexShrink: 0, color: T.ok } }, '已复制')
1211
+ : null)
1212
+
1213
+ // One tree row (dir toggles via the framework's onToggleDir; file opens).
1214
+ // Text uses the normal foreground (theme-following) — no accent green.
1215
+ const row = (entry, depth) => {
1216
+ const pad = depth * 22 + 6
1217
+ const hovered = hoverPath === entry.path
1218
+ const base = {
1219
+ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: pad,
1220
+ borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap',
1221
+ overflow: 'hidden', textOverflow: 'ellipsis', transition: 'background 0.12s ease',
1222
+ background: hovered ? 'rgba(128,128,128,0.14)' : 'transparent',
1223
+ color: T.label,
1224
+ }
1225
+ if (entry.type === 'dir') {
1226
+ const isOpen = expanded.includes(entry.path)
1227
+ return React.createElement('div', { key: entry.path },
1228
+ React.createElement('div', {
1229
+ role: 'button', tabIndex: 0,
1230
+ onClick: () => { if (props.onToggleDir) props.onToggleDir(entry.path) },
1231
+ onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.onToggleDir) props.onToggleDir(entry.path) } },
1232
+ onContextMenu: (e) => onCtx(e, entry.path, true, entry.name),
1233
+ onMouseEnter: () => setHoverPath(entry.path),
1234
+ onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
1235
+ style: base,
1236
+ title: entry.path,
1237
+ },
1238
+ React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(isOpen)),
1239
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, fontWeight: 500 } }, entry.name),
1240
+ copiedMark(entry.path),
1241
+ ),
1242
+ isOpen ? renderLevel(entry.path, depth + 1) : null,
1243
+ )
1244
+ }
1245
+ return React.createElement('div', {
1246
+ role: 'button', tabIndex: 0, key: entry.path,
1247
+ onClick: () => openFile(entry.path),
1248
+ onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(entry.path) } },
1249
+ onContextMenu: (e) => onCtx(e, entry.path, false, entry.name),
1250
+ onMouseEnter: () => setHoverPath(entry.path),
1251
+ onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
1252
+ style: base,
1253
+ title: entry.path,
1254
+ },
1255
+ React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex' } }, IconFile()),
1256
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, entry.name),
1257
+ copiedMark(entry.path),
1258
+ (entry.size != null || entry.mtime)
1259
+ ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } },
1260
+ humanSize(entry.size) + (entry.mtime ? ' ' + fmtTime(entry.mtime) : ''))
1261
+ : null,
1262
+ )
1263
+ }
1264
+
1265
+ const renderLevel = (dir, depth) => {
1266
+ const level = data[dir]
1267
+ if (level === undefined || (level.loading && !level.entries)) {
1268
+ return React.createElement('div', { key: dir + ':loading', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '加载中…')
1269
+ }
1270
+ if (level.missing) {
1271
+ return React.createElement('div', { key: dir + ':missing', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '目录不存在或已被删除')
1272
+ }
1273
+ if (level.error !== undefined) {
1274
+ return React.createElement('div', { key: dir + ':err', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, color: '#e06c75' } }, level.error)
1275
+ }
1276
+ const items = level.entries || []
1277
+ const soft = level.softError
1278
+ ? React.createElement('div', { key: dir + ':softerr', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 11, opacity: 0.6, color: '#e6c07b' } }, '⚠ ' + level.softError + '(显示缓存,可点 ↻ 刷新)')
1279
+ : null
1280
+ if (!items.length) return soft || null
1281
+ return React.createElement('div', { key: dir + ':list' },
1282
+ items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)),
1283
+ soft,
1284
+ )
1285
+ }
1286
+
1287
+ const rootName = root ? String(root).split(/[\\/]/).filter(Boolean).pop() || root : ''
1288
+ const rootLevel = data[root]
1289
+
1290
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
1291
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
1292
+ React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: refreshStatus, title: '刷新' }, '↻'),
1293
+ React.createElement('button', {
1294
+ style: { ...buttonS, padding: '3px 8px', fontSize: 12 },
1295
+ onClick: () => { const nm = window.prompt('新建目录名(在 ' + (root || '/') + ' 下)'); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(root, nm.trim()) }, true) },
1296
+ }, '新建目录'),
1297
+ React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
1298
+ ),
1299
+ status && status.workspace && root
1300
+ ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + root + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
1301
+ : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, root ? '未设置远程工作区' : '当前会话未使用远程工作区'),
1302
+ picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
1303
+ open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
1304
+ })) : null,
1305
+ React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
1306
+ !root
1307
+ ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。')
1308
+ : React.createElement('div', { key: root },
1309
+ React.createElement('div', {
1310
+ role: 'button', tabIndex: 0,
1311
+ onClick: () => setRootOpen((v) => !v),
1312
+ onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRootOpen((v) => !v) } },
1313
+ onContextMenu: (e) => onCtx(e, root, true, rootName),
1314
+ style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: 6, borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: T.label, fontWeight: 600 },
1315
+ title: root,
1316
+ },
1317
+ React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(rootOpen)),
1318
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, rootName || root),
1319
+ ),
1320
+ rootLevel && rootOpen ? renderLevel(root, 1) : null,
1321
+ ),
1322
+ ),
1323
+ // context menu — now rendered via DOM portal in useEffect above
1324
+ null,
1325
+ err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
1326
+ )
1327
+ }
1328
+
1329
+ // ── Remote file tab (opened by the explorer; live read + explicit edit) ──
1330
+ function RemoteFileTab(props) {
1331
+ const { tab, scope } = props
1332
+ const path = tab.path || ''
1333
+ const [data, setData] = React.useState(null)
1334
+ const [err, setErr] = React.useState('')
1335
+ const [loading, setLoading] = React.useState(true)
1336
+ const [edit, setEdit] = React.useState(false)
1337
+ const [draft, setDraft] = React.useState('')
1338
+ const [saving, setSaving] = React.useState(false)
1339
+
1340
+ const load = () => {
1341
+ if (!path) { setLoading(false); return }
1342
+ let cancelled = false
1343
+ setLoading(true); setErr(''); setData(null); setEdit(false)
1344
+ readRemoteFile(path, 256 * 1024)
1345
+ .then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
1346
+ .catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
1347
+ return () => { cancelled = true }
1348
+ }
1349
+ React.useEffect(load, [path])
1350
+
1351
+ const startEdit = () => {
1352
+ if (!data || data.binary) return
1353
+ setDraft((data.content != null ? data.content : '').replace(/\n…\[truncated:.*$/, ''))
1354
+ setEdit(true); setErr('')
1355
+ }
1356
+ const saveRemote = () => {
1357
+ setSaving(true); setErr('')
1358
+ apiRaw('POST', '/dsh-remote/write', { path, content: draft })
1359
+ .then((r) => {
1360
+ if (r.status === 409) {
1361
+ setErr(String((r.data && r.data.error) || '远端文件已变化,请重新读取'))
1362
+ return
1363
+ }
1364
+ if (r.status >= 400) throw new Error((r.data && (r.data.error || r.data.message)) || 'HTTP ' + r.status)
1365
+ setEdit(false)
1366
+ load()
1367
+ })
1368
+ .catch((e) => setErr(String((e && e.message) || e)))
1369
+ .finally(() => setSaving(false))
1370
+ }
1371
+ const downloadMirror = () => {
1372
+ setSaving(true); setErr('')
1373
+ api('POST', '/dsh-remote/fs', { op: 'download', path })
1374
+ .then((r) => setErr('已下载到本地镜像' + (r && r.local ? ': ' + r.local : '')))
1375
+ .catch((e) => setErr(String((e && e.message) || e)))
1376
+ .finally(() => setSaving(false))
1377
+ }
1378
+
1379
+ const baseName = path.split(/[\\/]/).pop() || path
1380
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
1381
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,0.25)', fontSize: 12 } },
1382
+ React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
1383
+ React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
1384
+ React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, edit ? '编辑中' : '只读'),
1385
+ edit
1386
+ ? React.createElement(React.Fragment, null,
1387
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving }, saving ? '保存中…' : '保存到远程'),
1388
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, '取消'))
1389
+ : React.createElement(React.Fragment, null,
1390
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, '编辑'),
1391
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, '下载')),
1392
+ ),
1393
+ React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
1394
+ loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
1395
+ : err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
1396
+ : (data && data.binary
1397
+ ? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
1398
+ '二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 请用「下载」或 rw_download / 本地镜像查看。')
1399
+ : (edit
1400
+ ? React.createElement('textarea', { value: draft, onChange: (e) => setDraft(e.target.value), spellCheck: false, style: { width: '100%', height: '100%', boxSizing: 'border-box', border: 'none', outline: 'none', resize: 'none', background: 'transparent', color: '#e4e4e7', fontSize: 13, lineHeight: 1.55, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', padding: '10px 12px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } })
1401
+ : React.createElement('div', { style: { fontSize: 13, lineHeight: 1.55, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', padding: '10px 12px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } },
1402
+ (data && data.content != null) ? data.content : '(空文件)',
1403
+ (data && data.truncated)
1404
+ ? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
1405
+ : null,
1406
+ ))),
1407
+ ),
1408
+ )
1409
+ }
1410
+
1411
+ // ── better-sidebar registration (guarded: plugin may be absent) ────────
1412
+ function registerSidebarIntegration(ctx) {
1413
+ const bs = (ctx && ctx.get && ctx.get('betterSidebar')) || null
1414
+ if (!bs || typeof bs.registerTab !== 'function') return
1415
+ const disposers = []
1416
+ try {
1417
+ disposers.push(bs.registerTab({
1418
+ id: SIDEBAR_EXPLORER_ID,
1419
+ title: () => '远程文件',
1420
+ icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
1421
+ order: 55,
1422
+ single: true,
1423
+ component: (props) => React.createElement(RemoteExplorerTab, props),
1424
+ }))
1425
+ disposers.push(bs.registerTab({
1426
+ id: SIDEBAR_FILE_ID,
1427
+ title: () => '远程文件',
1428
+ hidden: true, // not in the + menu; opened from the explorer tree
1429
+ dedupeKey: (tab) => tab.path,
1430
+ component: (props) => React.createElement(RemoteFileTab, props),
1431
+ }))
1432
+ } catch (e) {
1433
+ console.warn('[dsh-remote] better-sidebar integration skipped:', e)
1434
+ disposers.forEach((d) => { try { d() } catch {} })
1435
+ return
1436
+ }
1437
+ let autoOpenedFor = new Set()
1438
+ let disposeAuto = null
1439
+ const tryAutoOpen = () => {
1440
+ if (typeof bs.subscribeState !== 'function') return
1441
+ const snap = bs.getSnapshot && bs.getSnapshot()
1442
+ if (!snap || !snap.sessionId) return
1443
+ if (autoOpenedFor.has(snap.sessionId)) return
1444
+ const sessionId = snap.sessionId
1445
+ // Issue #13: only auto-open the Remote Files tab for sessions that are
1446
+ // genuinely remote (cwd inside a mirror). A plain local session must
1447
+ // not get a remote tab popped open by a machine that happens to be
1448
+ // saved as "current".
1449
+ resolveSessionRemote(sessionId).then((remotePath) => {
1450
+ if (!remotePath || autoOpenedFor.has(sessionId)) return
1451
+ autoOpenedFor.add(sessionId)
1452
+ try {
1453
+ bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: remotePath })
1454
+ } catch (e2) {
1455
+ console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
1456
+ }
1457
+ })
1458
+ }
1459
+ tryAutoOpen()
1460
+ if (typeof bs.subscribeState === 'function') {
1461
+ disposeAuto = bs.subscribeState(tryAutoOpen)
1462
+ disposers.push(disposeAuto)
1463
+ }
1464
+ return () => disposers.forEach((d) => { try { d() } catch {} })
1465
+ }
1466
+
1467
+ function apply(ctx) {
1468
+ const slots = ctx.get('slots')
1469
+ if (slots === undefined) return
1470
+ WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
1471
+ SESSIONS = (ctx && ((ctx.get && ctx.get('sessions')) || null)) || null
1472
+ slots.inject('settings.section', () =>
1473
+ slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
1474
+ )
1475
+ slots.inject(
1476
+ 'conversation.hero.workspace.directoryFlow',
1477
+ () => slots.inject('sidebar.workspaces.directoryFlow',
1478
+ function* () {
1479
+ yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
1480
+ yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
1481
+ },
1482
+ ),
1483
+ )
1484
+ ctx.inject(['betterSidebar'], (inner) => {
1485
+ const bsDispose = registerSidebarIntegration(inner)
1486
+ if (bsDispose) {
1487
+ inner.effect(() => bsDispose, 'dsh-remote.betterSidebar')
1488
+ }
1489
+ })
1490
+ }
1491
+
1492
+ exports.name = name
1493
+ exports.apply = apply
1494
+ return module.exports
1495
+ },
1496
+ })