dsh-remote 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1,1188 +1,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
- 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
- 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))) } }
208
-
209
- const importSsh = () => {
210
- api('GET', '/dsh-remote/ssh-config').then((r) => setSshEntries(r && r.present ? (r.entries || []) : []))
211
- .catch((e) => setErr('读取 ~/.ssh/config 失败: ' + String((e && e.message) || e)))
212
- }
213
- const applySshEntry = (e) => {
214
- setForm({ ...form,
215
- name: e.host, host: e.hostName || e.host, port: String(e.port || 22),
216
- username: e.user || 'root', privateKeyPath: e.identityFile || '',
217
- })
218
- setSshEntries(null)
219
- setMsg('已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)')
220
- }
221
-
222
- const setWorkspaceNow = (p) => {
223
- api('POST', '/dsh-remote/workspace', { path: p })
224
- .then(() => setMsg('已切换到工作区 ' + p))
225
- .catch((e) => setErr(String((e && e.message) || e)))
226
- }
227
-
228
- // ── forwards panel ────────────────────────────────────────────────────
229
- const fwdAction = (action, id) => {
230
- api('POST', '/dsh-remote/forwards', { action, id })
231
- .then((r) => { setForwards(r.forwards || []); setErr(''); setMsg('') })
232
- .catch((e) => setErr(String((e && e.message) || e)))
233
- }
234
- const fwdDefine = () => {
235
- if (!fwdForm.listenPort) return
236
- 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 })
237
- .then((r) => { setForwards(r.forwards || []); setFwdForm({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false }) })
238
- .catch((e) => setErr(String((e && e.message) || e)))
239
- }
240
-
241
- const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 } },
242
- React.createElement('label', { style: { width: 84, fontSize: 12, opacity: 0.8, flexShrink: 0 } }, label), ctrl)
243
-
244
- return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 860 } },
245
- React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
246
- React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
247
- '维护多台 SSH 机器(密码/私钥/agent/跳板机/钥匙串)。路径在新建/选择工作区时选:「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
248
- React.createElement('div', { style: boxS },
249
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
250
- machines.length
251
- ? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
252
- React.createElement('div', { style: { flex: 1, fontSize: 13 } },
253
- m.name + ' ',
254
- React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
255
- m.passwordSet ? ' 🔒' : '',
256
- (m.credentialBackend && m.credentialBackend !== 'plain') ? ' 🗝' : '',
257
- m.proxy && m.proxy.host ? ' ⛳' : '',
258
- m.latencyMs != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, ` · ${m.latencyMs}ms`) : null,
259
- m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
260
- React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
261
- React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
262
- React.createElement('button', { style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' }, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
263
- : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
264
- ),
265
- React.createElement('div', { style: boxS },
266
- React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
267
- React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
268
- React.createElement('button', { style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? '收起' : '从 ~/.ssh/config 导入')),
269
- sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 160, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
270
- 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 } },
271
- e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')))
272
- : React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, '~/.ssh/config 里没有可导入的 Host 条目'))
273
- : null,
274
- row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
275
- row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
276
- row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
277
- row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
278
- row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显)', style: inputS }), 'w'),
279
- // 高级配置折叠开关
280
- React.createElement('button', {
281
- type: 'button',
282
- onClick: () => setAdvOpen(!advOpen),
283
- style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
284
- }, advOpen ? '▲ 收起高级配置' : '▼ 高级配置(私钥 / 跳板机 / agent 等)'),
285
- advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
286
- row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用密码或 agent', style: inputS }), 'k'),
287
- row('Passphrase', React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: '私钥加密时使用', style: inputS }), 'pp'),
288
- row('默认工作区', React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: '可选:该机器的默认远程目录', style: inputS }), 'ws'),
289
- row('HostKey 模式', React.createElement('select', { value: form.hostKeyMode, onChange: setF('hostKeyMode'), style: { ...inputS, maxWidth: 220 } },
290
- React.createElement('option', { value: '' }, '(默认 accept-new)'),
291
- React.createElement('option', { value: 'accept-new' }, 'accept-new(信任首次,之后校验)'),
292
- React.createElement('option', { value: 'verify' }, 'verify(严格:拒绝陌生主机)'),
293
- React.createElement('option', { value: 'off' }, 'off(不校验,不推荐)')), 'hkm'),
294
- React.createElement('div', { style: { display: 'flex', gap: 16, paddingLeft: 90, flexWrap: 'wrap', marginBottom: 8 } },
295
- React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
296
- React.createElement('input', { type: 'checkbox', checked: form.useAgent, onChange: setFb('useAgent') }), 'SSH agent(SSH_AUTH_SOCK)'),
297
- React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
298
- React.createElement('input', { type: 'checkbox', checked: form.keyboardInteractive, onChange: setFb('keyboardInteractive') }), 'keyboard-interactive(OTP/动态码)'),
299
- React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
300
- React.createElement('input', { type: 'checkbox', checked: form.encryptPassword, onChange: setFb('encryptPassword') }), '加密保存密码(系统钥匙串)')),
301
- row('跳板机', React.createElement('input', { value: form.proxyHost, onChange: setF('proxyHost'), placeholder: '经此主机中转(可选)', style: inputS }), 'ph'),
302
- row('端口/用户', React.createElement('div', { style: { display: 'flex', gap: 6, flex: 1, flexWrap: 'wrap' } },
303
- React.createElement('input', { value: form.proxyPort, onChange: setF('proxyPort'), placeholder: '端口(22)', style: { ...inputS, minWidth: 90, maxWidth: 120 } }),
304
- React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: '跳板用户', style: { ...inputS, minWidth: 140 } }),
305
- React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: '跳板密码', style: { ...inputS, minWidth: 140 } })), 'pu'),
306
- row('跳板私钥', React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: '跳板私钥路径(可选)', style: inputS }), 'pk'),
307
- ) : null,
308
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', flexWrap: 'wrap', marginTop: 4 } },
309
- msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
310
- form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
311
- React.createElement('button', { style: buttonS, onClick: () => { clearForm(); setErr(''); setTst('idle'); setAdvOpen(false) } }, form.id ? '取消编辑' : '清空'),
312
- React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
313
- tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
314
- React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
315
- ),
316
- ),
317
- // 快捷切换工作区(该机器的最近工作区)
318
- workspaces.length ? React.createElement('div', { style: boxS },
319
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '当前机器最近工作区(一键切换)'),
320
- workspaces.map((w, i) => React.createElement('div', { key: w + i, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
321
- React.createElement('code', { style: { flex: 1, fontSize: 12, fontFamily: 'monospace' } }, w),
322
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, '切换')))) : null,
323
- // 端口转发面板
324
- React.createElement('div', { style: boxS },
325
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '端口转发(SSH 隧道)'),
326
- 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' } },
327
- React.createElement('span', { style: { color: f.active ? T.ok : T.muted } }, f.active ? '●' : '○'),
328
- React.createElement('span', { style: { flex: 1 } }, `${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}${f.autoStart ? ' [自启]' : ''}`),
329
- f.active
330
- ? React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('stop', f.id) }, '停止')
331
- : React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('start', f.id) }, '启动'),
332
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('remove', f.id) }, '删除'))) : null,
333
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' } },
334
- React.createElement('select', { value: fwdForm.direction, onChange: (e) => setFwdForm({ ...fwdForm, direction: e.target.value }), style: { ...inputS, maxWidth: 110 } },
335
- React.createElement('option', { value: 'local' }, '本地→远端'),
336
- React.createElement('option', { value: 'reverse' }, '远端→本地')),
337
- React.createElement('input', { value: fwdForm.listenPort, onChange: (e) => setFwdForm({ ...fwdForm, listenPort: e.target.value }), placeholder: '监听端口', style: { ...inputS, maxWidth: 90 } }),
338
- React.createElement('input', { value: fwdForm.targetHost, onChange: (e) => setFwdForm({ ...fwdForm, targetHost: e.target.value }), placeholder: '目标主机', style: { ...inputS, maxWidth: 120 } }),
339
- React.createElement('input', { value: fwdForm.targetPort, onChange: (e) => setFwdForm({ ...fwdForm, targetPort: e.target.value }), placeholder: '目标端口', style: { ...inputS, maxWidth: 90 } }),
340
- React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
341
- React.createElement('input', { type: 'checkbox', checked: fwdForm.autoStart, onChange: (e) => setFwdForm({ ...fwdForm, autoStart: e.target.checked }) }), '自动重连'),
342
- React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: fwdDefine, disabled: !fwdForm.listenPort }, '添加转发')),
343
- ),
344
- // 审计日志
345
- React.createElement('div', { style: boxS },
346
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '最近执行的远程命令(审计日志)'),
347
- auditLines.length
348
- ? React.createElement('div', { style: { maxHeight: 180, overflowY: 'auto', fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 } },
349
- auditLines.map((l, i) => React.createElement('div', { key: i, style: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, l)))
350
- : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)'),
351
- ),
352
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
353
- React.createElement('div', { style: boxS },
354
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '更新'),
355
- React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 } },
356
- upd
357
- ? React.createElement('span', { style: { opacity: 0.85 } },
358
- '当前 v' + upd.current,
359
- upd.updateAvailable ? React.createElement('b', { style: { color: T.ok, marginLeft: 6 } }, '→ 新版本 v' + upd.latest) : null)
360
- : React.createElement('span', { style: { opacity: 0.6 } }, '版本信息加载中…'),
361
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => checkUpdate(false), disabled: updBusy }, updBusy ? '检查中…' : '检查更新'),
362
- upd && upd.updateAvailable
363
- ? React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontWeight: 700, color: T.ok }, onClick: applyUpdateNow, disabled: updBusy }, '立即更新')
364
- : null,
365
- ),
366
- React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
367
- React.createElement('span', { style: { opacity: 0.8 } }, '更新模式:'),
368
- ['manual', 'auto', 'off'].map((m) =>
369
- React.createElement('button', {
370
- key: m, style: { ...buttonS, padding: '2px 10px', fontWeight: updMode === m ? 700 : 400, color: updMode === m ? T.ok : 'inherit' },
371
- onClick: () => saveUpdateMode(m), disabled: updBusy,
372
- }, m === 'manual' ? '手动' : m === 'auto' ? '自动' : '关闭'),
373
- ),
374
- ),
375
- updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') || updMsg.startsWith('已是最新') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
376
- ),
377
- 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 } },
378
- React.createElement('span', {}, '觉得好用?'),
379
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
380
- React.createElement('span', {}, '·'),
381
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
382
- React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v' + (upd && upd.current ? upd.current : '?.?.?')),
383
- ),
384
- )
385
- }
386
-
387
- // ── Unified picker (fill the directory-flow holes) ───────────────────
388
- function parseLs(text) {
389
- const out = []
390
- for (const ln of String(text || '').split('\n')) {
391
- const t = ln.trim()
392
- if (!t || !t.length || /^total\b/i.test(t)) continue
393
- const parts = t.split(/\s+/).filter(Boolean)
394
- if (parts.length < 8) continue
395
- const mode = parts[0]
396
- const name = parts.slice(8).join(' ')
397
- if (!name || name === '.' || name === '..') continue
398
- const isDir = mode.charAt(0) === 'd'
399
- out.push({ name, dir: isDir })
400
- }
401
- return out
402
- }
403
-
404
- // Clickable breadcrumb of the current remote path.
405
- function breadcrumb(active, cur, jumpTo) {
406
- const norm = String(cur || '').replace(/\/+$/, '')
407
- const segs = norm === '' || norm === '/' ? [] : norm.split('/')
408
- const crumbs = []
409
- crumbs.push(React.createElement('span', { key: 'root', style: { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted }, onClick: active ? () => jumpTo('/') : undefined }, '/'))
410
- let acc = ''
411
- for (const s of segs) {
412
- if (!s) continue
413
- acc += '/' + s
414
- crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
415
- crumbs.push(React.createElement('span', {
416
- key: acc,
417
- style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === norm ? 700 : 400, whiteSpace: 'nowrap' },
418
- onClick: active ? () => jumpTo(acc) : undefined,
419
- }, s))
420
- }
421
- if (!crumbs.length) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, '/'))
422
- return React.createElement('span', { key: 'crumb' }, crumbs)
423
- }
424
-
425
- function DirPicker(props) {
426
- const { open, busy, onPicked, onCancel } = props
427
- const [tab, setTab] = React.useState('local')
428
- const [machines, setMachines] = React.useState([])
429
- const [machineId, setMachineId] = React.useState('')
430
- const [path, setPath] = React.useState('')
431
- const [items, setItems] = React.useState(null)
432
- const [err, setErr] = React.useState('')
433
- const [loading, setLoading] = React.useState(false)
434
- const [levels, setLevels] = React.useState(null)
435
- const [popOpen, setPopOpen] = React.useState(false)
436
- const [suggest, setSuggest] = React.useState([])
437
- const [suggestOpen, setSuggestOpen] = React.useState(false)
438
- const [recent, setRecent] = React.useState([])
439
- const suggestTimer = React.useRef(null)
440
- const suggestRef = React.useRef(null)
441
- React.useEffect(() => {
442
- if (!suggestOpen) return
443
- const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
444
- document.addEventListener('mousedown', onDown)
445
- return () => document.removeEventListener('mousedown', onDown)
446
- }, [suggestOpen])
447
- const [mOpen, setMOpen] = React.useState(false)
448
- const ddRef = React.useRef(null)
449
- React.useEffect(() => {
450
- if (!mOpen) return
451
- const onDown = (ev) => { if (ddRef.current && !ddRef.current.contains(ev.target)) setMOpen(false) }
452
- document.addEventListener('mousedown', onDown)
453
- return () => document.removeEventListener('mousedown', onDown)
454
- }, [mOpen])
455
-
456
- const sortItems = (list) => {
457
- const dirs = list.filter((it) => it.dir)
458
- const files = list.filter((it) => !it.dir)
459
- dirs.sort((a, b) => a.name.localeCompare(b.name))
460
- files.sort((a, b) => a.name.localeCompare(b.name))
461
- return dirs.concat(files)
462
- }
463
-
464
- const loadLevels = (id, p, toIndex) => {
465
- if (!id) return
466
- setLoading(true); setErr('')
467
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
468
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
469
- .then((res) => {
470
- const real = res && res.path ? res.path : (p || '')
471
- const list = Array.isArray(res && res.items)
472
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime }))
473
- : parseLs(res && res.text)
474
- const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
475
- setLevels((prev) => {
476
- const base = prev && prev.length ? prev.slice() : []
477
- let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
478
- if (idx >= base.length) return base.concat([node])
479
- base[idx] = node
480
- return base.slice(0, idx + 1)
481
- })
482
- })
483
- .catch((e) => setErr(String((e && e.message) || e)))
484
- .finally(() => setLoading(false))
485
- }
486
-
487
- React.useEffect(() => { if (open) {
488
- api('GET', '/dsh-remote/machines').then((r) => {
489
- setMachines(r.machines || [])
490
- const cur = (r.machines || []).find((m) => m.id === r.currentId)
491
- setRecent((cur && cur.recentWorkspaces) || [])
492
- setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '')
493
- })
494
- } }, [open])
495
-
496
- const chooseLocal = () => {
497
- setLoading(true); setErr('')
498
- api('POST', '/dsh-remote/local-pick')
499
- .then((r) => {
500
- if (r && r.path) onPicked(String(r.path))
501
- else if (r && r.cancelled) setErr('已取消选择')
502
- else setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径')
503
- })
504
- .catch((e) => setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径'))
505
- .finally(() => setLoading(false))
506
- }
507
-
508
- const switchTab = (t) => {
509
- setTab(t); setErr('')
510
- if (t === 'remote') {
511
- if (machineId) loadLevels(machineId, '', 0)
512
- if (!path.trim()) { setPath('/'); loadSuggestions('/') }
513
- }
514
- }
515
-
516
- const enterDir = (name) => {
517
- if (busy || loading) return
518
- const last = levels && levels.length ? levels[levels.length - 1] : null
519
- const base = last && last.path ? last.path : ''
520
- const sep = base.includes('\\') ? '\\' : '/'
521
- const next = base === '/' ? '/' + name : (base ? base + sep + name : name)
522
- loadLevels(machineId, next, (levels ? levels.length : 0))
523
- }
524
-
525
- const loadSuggestions = (raw, mid) => {
526
- const id = mid || machineId
527
- if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
528
- const t = String(raw || '').trim()
529
- if (!t) { setSuggest([]); setSuggestOpen(false); return }
530
- const isWin = t.includes('\\')
531
- const sep = isWin ? '\\' : '/'
532
- const slash = t.lastIndexOf(sep)
533
- const parent = slash <= 0 ? (isWin ? t.match(/^[a-zA-Z]:\\?/) ? t : '' : '/') : t.slice(0, slash)
534
- const lastSeg = slash < 0 ? t : t.slice(slash + 1)
535
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
536
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
537
- .then((res) => {
538
- const list = Array.isArray(res && res.items)
539
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
540
- : parseLs(res && res.text)
541
- const base = parent === '/' ? '/' : parent
542
- const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
543
- .slice(0, 40).map((it) => (base === '/' || !base ? sep + it.name : base + sep + it.name))
544
- setSuggest(matches)
545
- setSuggestOpen(!!matches.length)
546
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
547
- }
548
-
549
- const onPathChange = (raw) => {
550
- setPath(raw); setErr('')
551
- if (suggestTimer.current) clearTimeout(suggestTimer.current)
552
- suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
553
- }
554
-
555
- const continueSuggest = (dir) => {
556
- if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
557
- setSuggestOpen(false)
558
- const sep = String(dir).includes('\\') ? '\\' : '/'
559
- api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
560
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
561
- .then((res) => {
562
- const list = Array.isArray(res && res.items)
563
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
564
- : parseLs(res && res.text)
565
- const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
566
- // Show every entry (dirs + files), matching loadSuggestions — the
567
- // previous dir-only filter made the next-level list look truncated.
568
- const kids = list.slice(0, 40).map((it) => (base === '/' ? sep + it.name : base + sep + it.name))
569
- setSuggest(kids)
570
- setSuggestOpen(!!kids.length)
571
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
572
- }
573
-
574
- const selectSuggestion = (s) => {
575
- // Auto-append the trailing separator so the path reads as a directory
576
- // and the user can keep typing the next segment right away.
577
- const sep = String(s).includes('\\') ? '\\' : '/'
578
- const withSep = String(s).replace(/[\\/]+$/, '') + sep
579
- setPath(withSep); setErr(''); setSuggestOpen(false)
580
- continueSuggest(withSep)
581
- }
582
-
583
- const commitPath = (p) => {
584
- const target = String(p || '').trim()
585
- if (!target || !machineId || busy) return
586
- setPopOpen(false); setSuggestOpen(false)
587
- 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)))
588
- }
589
-
590
- const acceptBrowserPick = (p) => {
591
- setPath(String(p || ''))
592
- setSuggestOpen(false)
593
- setPopOpen(false)
594
- }
595
-
596
- const pickHome = () => {
597
- api('POST', '/dsh-remote/home').then((r) => {
598
- if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
599
- else setErr((r && (r.hint || r.error)) || '无法解析主目录')
600
- }).catch((e) => setErr(String((e && e.message) || e)))
601
- }
602
-
603
- const mkdirHere = () => {
604
- const last = levels && levels.length ? levels[levels.length - 1] : null
605
- const base = (last && last.path) || '/'
606
- const nm = window.prompt('新建目录名(在 ' + base + ' 下)')
607
- if (!nm || !nm.trim()) return
608
- const sep = base.includes('\\') ? '\\' : '/'
609
- const full = base === '/' ? '/' + nm.trim() : base + sep + nm.trim()
610
- api('POST', '/dsh-remote/fs', { op: 'mkdir', path: full })
611
- .then(() => { setErr(''); loadLevels(machineId, base, (levels ? levels.length - 1 : 0)) })
612
- .catch((e) => setErr(String((e && e.message) || e)))
613
- }
614
-
615
- function renderDirPopup() {
616
- if (!levels || !levels.length) {
617
- return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
618
- }
619
- const last = levels[levels.length - 1]
620
- const entries = last.all || []
621
- 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' } },
622
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
623
- 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 }, '回上一级 ▴'),
624
- 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 }))),
625
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: mkdirHere }, '新建目录'),
626
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
627
- ),
628
- React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
629
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
630
- : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
631
- key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
632
- onClick: it.dir ? () => enterDir(it.name) : undefined,
633
- 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 },
634
- },
635
- React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label } }, it.dir
636
- ? 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' }))
637
- : 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' }))),
638
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
639
- React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
640
- )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
641
- ),
642
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
643
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
644
- React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
645
- ),
646
- )
647
- }
648
-
649
- if (!open) return null
650
- const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
651
- 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() } },
652
- 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() },
653
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
654
- React.createElement('div', null, '选择工作目录'),
655
- React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
656
- ),
657
- React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
658
- tabBtn('local', '本机'),
659
- tabBtn('remote', '远程'),
660
- ),
661
- tab === 'local'
662
- ? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
663
- React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '系统选择器优先;不可用时直接输入本机目录。'),
664
- React.createElement('div', { style: { display: 'flex', gap: 6 } },
665
- React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
666
- React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
667
- ),
668
- React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
669
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
670
- )
671
- : React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
672
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
673
- React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
674
- React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
675
- React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
676
- (machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
677
- ),
678
- mOpen
679
- ? 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 } },
680
- machines.length
681
- ? 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 + ')'))
682
- : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
683
- )
684
- : null,
685
- ),
686
- ),
687
- // 最近工作区快捷入口
688
- recent.length ? React.createElement('div', { style: { display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' } },
689
- React.createElement('span', { style: { fontSize: 11, opacity: 0.6, whiteSpace: 'nowrap' } }, '最近:'),
690
- 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,
691
- React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
692
- React.createElement('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
693
- React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
694
- React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
695
- React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, '~ 主目录'),
696
- ),
697
- (suggestOpen && suggest.length)
698
- ? 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)' } },
699
- 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)),
700
- )
701
- : null,
702
- ),
703
- popOpen ? renderDirPopup() : null,
704
- err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
705
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
706
- React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
707
- React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
708
- ),
709
- ),
710
- (tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
711
- React.createElement('div', { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center', paddingTop: 6, fontSize: 10, opacity: 0.5 } },
712
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ Star dsh-remote'),
713
- React.createElement('span', {}, '·'),
714
- React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '提建议 / 报问题'),
715
- ),
716
- ),
717
- )
718
- }
719
-
720
- // ── better-sidebar integration (optional) ────────────────────────────────
721
- const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
722
- const SIDEBAR_FILE_ID = 'dsh-remote:file'
723
-
724
- function fetchRemoteStatus() {
725
- return api('GET', '/dsh-remote/status').catch(() => null)
726
- }
727
-
728
- // Resolve the remote path this session should show: prefer the session's
729
- // own cwd (a remote mirror dir) mapped through /dsh-remote/resolve-mirror;
730
- // fall back to the machine workspace when the session has no cwd or the
731
- // host side has no mirror match.
732
- function resolveSessionRemote(sessionId) {
733
- if (!sessionId) return Promise.resolve('')
734
- // 1) Prefer the client-side session list cwd (mirror dir under
735
- // $DSH_HOME/remote-workspaces/...) passed as ?local=.
736
- let local = ''
737
- try {
738
- const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
739
- ? SESSIONS.list.getSnapshot()
740
- : null
741
- const entry = list && list.byId && list.byId[sessionId]
742
- if (entry && entry.cwd) local = String(entry.cwd)
743
- } catch { /* sessions store shape differs */ }
744
- if (local) {
745
- return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(local))
746
- .then((r) => (r && r.remotePath) || '')
747
- .catch(() => '')
748
- }
749
- // 2) Fall back to host-side session header cwd.
750
- return api('GET', '/dsh-remote/resolve-mirror?sessionId=' + encodeURIComponent(sessionId))
751
- .then((r) => (r && r.remotePath) || '')
752
- .catch(() => '')
753
- }
754
-
755
- function readRemoteFile(path, maxBytes) {
756
- return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
757
- throw new Error('远程读取失败: ' + ((e && e.message) || e))
758
- })
759
- }
760
-
761
- function listRemoteDir(path) {
762
- return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
763
- throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
764
- })
765
- }
766
-
767
- function joinRemote(base, name) {
768
- if (!base) return name
769
- return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
770
- }
771
-
772
- // ── Remote explorer tab ────────────────────────────────────────────────
773
- // Inline monochrome icons (fill: currentColor → follows the row text color,
774
- // i.e. black on the sidebar). Folder open/closed + file.
775
- const IconFolder = (open) => React.createElement('svg', {
776
- width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0 },
777
- },
778
- open
779
- ? 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' })
780
- : 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' }),
781
- )
782
- const IconFile = () => React.createElement('svg', {
783
- width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0, opacity: 0.85 },
784
- },
785
- 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' }),
786
- )
787
-
788
- // Tree-style remote file explorer. Uses the same `expanded` / `onToggleDir`
789
- // contract as the built-in local file tree (better-sidebar framework
790
- // manages + persists the expanded set), so interaction and look match the
791
- // local files tab. Data is listed live from the remote host over
792
- // /dsh-remote/ls, rooted at this session's remote workspace dir.
793
- function RemoteExplorerTab(props) {
794
- const [status, setStatus] = React.useState(null)
795
- const [root, setRoot] = React.useState('') // remote root path for this session
796
- const [data, setData] = React.useState({}) // dir -> { entries | error | loading }
797
- const dataRef = React.useRef(data)
798
- const [err, setErr] = React.useState('')
799
- const [busy, setBusy] = React.useState('')
800
- const [picker, setPicker] = React.useState(false)
801
- const [menu, setMenu] = React.useState(null) // { x, y, path, isDir, name }
802
- const [rootOpen, setRootOpen] = React.useState(true) // first level expanded by default
803
- const [hoverPath, setHoverPath] = React.useState('') // row hover highlight
804
- const expanded = Array.isArray(props.expanded) ? props.expanded : []
805
-
806
- const storeLevel = (path, level) => {
807
- dataRef.current = { ...dataRef.current, [path]: level }
808
- setData(dataRef.current)
809
- }
810
-
811
- const loadDir = React.useCallback((dir) => {
812
- const cur = dataRef.current
813
- if (cur[dir] !== undefined && !cur[dir].loading) return
814
- storeLevel(dir, { loading: true })
815
- listRemoteDir(dir)
816
- .then((res) => {
817
- const items = Array.isArray(res && res.items) ? res.items : []
818
- const sorted = items.slice().sort((a, b) => {
819
- if (a.type === 'dir' && b.type !== 'dir') return -1
820
- if (a.type !== 'dir' && b.type === 'dir') return 1
821
- return String(a.name).localeCompare(String(b.name))
822
- })
823
- storeLevel(dir, { entries: sorted })
824
- })
825
- .catch((e) => storeLevel(dir, { error: String((e && e.message) || e) }))
826
- }, [])
827
-
828
- // Resolve this session's remote root, then load it and every expanded dir.
829
- // Re-runs whenever the session scope changes (switching conversations, or
830
- // the async sessionCwd fetch landing), so the sidebar follows the session's
831
- // own workspace dir.
832
- const scopeCwd = props.scope && props.scope.cwd
833
- const sessionId = props.scope && props.scope.sessionId
834
- const refreshStatus = React.useCallback(() => {
835
- const applyRoot = (r) => {
836
- setRoot(r)
837
- setData({})
838
- dataRef.current = {}
839
- if (r) {
840
- storeLevel(r, { loading: true })
841
- listRemoteDir(r)
842
- .then((res) => {
843
- const items = Array.isArray(res && res.items) ? res.items : []
844
- const sorted = items.slice().sort((a, b) => {
845
- if (a.type === 'dir' && b.type !== 'dir') return -1
846
- if (a.type !== 'dir' && b.type === 'dir') return 1
847
- return String(a.name).localeCompare(String(b.name))
848
- })
849
- storeLevel(r, { entries: sorted })
850
- for (const dir of expanded) loadDir(dir)
851
- })
852
- .catch((e) => storeLevel(r, { error: String((e && e.message) || e) }))
853
- }
854
- }
855
- const resolveRoot = () => {
856
- if (scopeCwd) {
857
- return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(scopeCwd))
858
- .then((r) => (r && r.remotePath) || '')
859
- .catch(() => '')
860
- }
861
- if (sessionId) return resolveSessionRemote(sessionId)
862
- return Promise.resolve('')
863
- }
864
- resolveRoot().then((r) => {
865
- fetchRemoteStatus().then((s) => {
866
- setStatus(s)
867
- applyRoot(r || (s && s.workspace) || '')
868
- })
869
- })
870
- }, [scopeCwd, sessionId])
871
-
872
- React.useEffect(() => { refreshStatus() }, [refreshStatus])
873
- React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
874
- // Load dirs as they get expanded.
875
- React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir) }, [root, expanded, loadDir])
876
- React.useEffect(() => {
877
- if (!menu) return
878
- const close = () => setMenu(null)
879
- document.addEventListener('mousedown', close)
880
- return () => document.removeEventListener('mousedown', close)
881
- }, [menu])
882
-
883
- const openFile = (p) => {
884
- const name = String(p).split(/[\\/]/).pop() || p
885
- props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
886
- }
887
-
888
- const doFs = (op, payload, refreshParent) => {
889
- setBusy(op)
890
- return api('POST', '/dsh-remote/fs', { op, ...payload })
891
- .then(() => {
892
- setErr('')
893
- if (refreshParent) {
894
- dataRef.current = {}
895
- setData({})
896
- if (root) { storeLevel(root, { loading: true }); loadDir(root) }
897
- for (const dir of expanded) loadDir(dir)
898
- }
899
- })
900
- .catch((e) => setErr(String((e && e.message) || e)))
901
- .finally(() => setBusy(''))
902
- }
903
-
904
- const onCtx = (e, path, isDir, name) => {
905
- e.preventDefault(); e.stopPropagation()
906
- setMenu({ x: e.clientX, y: e.clientY, path, isDir, name })
907
- }
908
- const ctxItem = (label, danger, fn) => React.createElement('div', {
909
- onClick: (e) => { e.stopPropagation(); setMenu(null); fn() },
910
- style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
911
- }, label)
912
-
913
- // One tree row (dir toggles via the framework's onToggleDir; file opens).
914
- // Text uses the normal foreground (theme-following) — no accent green.
915
- const row = (entry, depth) => {
916
- const pad = depth * 22 + 6
917
- const hovered = hoverPath === entry.path
918
- const base = {
919
- display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: pad,
920
- borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap',
921
- overflow: 'hidden', textOverflow: 'ellipsis', transition: 'background 0.12s ease',
922
- background: hovered ? 'rgba(128,128,128,0.14)' : 'transparent',
923
- color: T.label,
924
- }
925
- if (entry.type === 'dir') {
926
- const isOpen = expanded.includes(entry.path)
927
- return React.createElement('div', { key: entry.path },
928
- React.createElement('div', {
929
- role: 'button', tabIndex: 0,
930
- onClick: () => { if (props.onToggleDir) props.onToggleDir(entry.path) },
931
- onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.onToggleDir) props.onToggleDir(entry.path) } },
932
- onContextMenu: (e) => onCtx(e, entry.path, true, entry.name),
933
- onMouseEnter: () => setHoverPath(entry.path),
934
- onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
935
- style: base,
936
- title: entry.path,
937
- },
938
- React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(isOpen)),
939
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, fontWeight: 500 } }, entry.name),
940
- ),
941
- isOpen ? renderLevel(entry.path, depth + 1) : null,
942
- )
943
- }
944
- return React.createElement('div', {
945
- role: 'button', tabIndex: 0, key: entry.path,
946
- onClick: () => openFile(entry.path),
947
- onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(entry.path) } },
948
- onContextMenu: (e) => onCtx(e, entry.path, false, entry.name),
949
- onMouseEnter: () => setHoverPath(entry.path),
950
- onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
951
- style: base,
952
- title: entry.path,
953
- },
954
- React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex' } }, IconFile()),
955
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, entry.name),
956
- entry.size != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, humanSize(entry.size)) : null,
957
- )
958
- }
959
-
960
- const renderLevel = (dir, depth) => {
961
- const level = data[dir]
962
- if (level === undefined || (level.loading && !level.entries)) {
963
- 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 } }, '加载中…')
964
- }
965
- if (level.error !== undefined) {
966
- 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)
967
- }
968
- const items = level.entries || []
969
- if (!items.length) return null
970
- return React.createElement('div', { key: dir + ':list' }, items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)))
971
- }
972
-
973
- const rootName = root ? String(root).split(/[\\/]/).filter(Boolean).pop() || root : ''
974
- const rootLevel = data[root]
975
-
976
- return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
977
- React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
978
- React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: refreshStatus, title: '刷新' }, '↻'),
979
- React.createElement('button', {
980
- style: { ...buttonS, padding: '3px 8px', fontSize: 12 },
981
- onClick: () => { const nm = window.prompt('新建目录名(在 ' + (root || '/') + ' 下)'); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(root, nm.trim()) }, true) },
982
- }, '新建目录'),
983
- React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
984
- ),
985
- status && status.workspace
986
- ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + root + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
987
- : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区'),
988
- picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
989
- open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
990
- })) : null,
991
- React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
992
- !root
993
- ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区')
994
- : React.createElement('div', { key: root },
995
- React.createElement('div', {
996
- role: 'button', tabIndex: 0,
997
- onClick: () => setRootOpen((v) => !v),
998
- onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRootOpen((v) => !v) } },
999
- onContextMenu: (e) => onCtx(e, root, true, rootName),
1000
- 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 },
1001
- title: root,
1002
- },
1003
- React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(rootOpen)),
1004
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, rootName || root),
1005
- ),
1006
- rootLevel && rootOpen ? renderLevel(root, 1) : null,
1007
- ),
1008
- ),
1009
- // context menu
1010
- 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() },
1011
- ctxItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }),
1012
- ctxItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })),
1013
- ctxItem('✏ 重命名', false, () => {
1014
- const nm = window.prompt('重命名为(新名字)', menu.name)
1015
- if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true)
1016
- }),
1017
- ctxItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => {
1018
- if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true)
1019
- }),
1020
- ) : null,
1021
- err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
1022
- )
1023
- }
1024
-
1025
- // ── Remote file tab (opened by the explorer; live read + explicit edit) ──
1026
- function RemoteFileTab(props) {
1027
- const { tab, scope } = props
1028
- const path = tab.path || ''
1029
- const [data, setData] = React.useState(null)
1030
- const [err, setErr] = React.useState('')
1031
- const [loading, setLoading] = React.useState(true)
1032
- const [edit, setEdit] = React.useState(false)
1033
- const [draft, setDraft] = React.useState('')
1034
- const [saving, setSaving] = React.useState(false)
1035
-
1036
- const load = () => {
1037
- if (!path) { setLoading(false); return }
1038
- let cancelled = false
1039
- setLoading(true); setErr(''); setData(null); setEdit(false)
1040
- readRemoteFile(path, 256 * 1024)
1041
- .then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
1042
- .catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
1043
- return () => { cancelled = true }
1044
- }
1045
- React.useEffect(load, [path])
1046
-
1047
- const startEdit = () => {
1048
- if (!data || data.binary) return
1049
- setDraft((data.content != null ? data.content : '').replace(/\n…\[truncated:.*$/, ''))
1050
- setEdit(true); setErr('')
1051
- }
1052
- const saveRemote = () => {
1053
- setSaving(true); setErr('')
1054
- apiRaw('POST', '/dsh-remote/write', { path, content: draft })
1055
- .then((r) => {
1056
- if (r.status === 409) {
1057
- setErr(String((r.data && r.data.error) || '远端文件已变化,请重新读取'))
1058
- return
1059
- }
1060
- if (r.status >= 400) throw new Error((r.data && (r.data.error || r.data.message)) || 'HTTP ' + r.status)
1061
- setEdit(false)
1062
- load()
1063
- })
1064
- .catch((e) => setErr(String((e && e.message) || e)))
1065
- .finally(() => setSaving(false))
1066
- }
1067
- const downloadMirror = () => {
1068
- setSaving(true); setErr('')
1069
- api('POST', '/dsh-remote/fs', { op: 'download', path })
1070
- .then((r) => setErr('已下载到本地镜像' + (r && r.local ? ': ' + r.local : '')))
1071
- .catch((e) => setErr(String((e && e.message) || e)))
1072
- .finally(() => setSaving(false))
1073
- }
1074
-
1075
- const baseName = path.split(/[\\/]/).pop() || path
1076
- return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
1077
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,0.25)', fontSize: 12 } },
1078
- React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
1079
- React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
1080
- React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, edit ? '编辑中' : '只读'),
1081
- edit
1082
- ? React.createElement(React.Fragment, null,
1083
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving }, saving ? '保存中…' : '保存到远程'),
1084
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, '取消'))
1085
- : React.createElement(React.Fragment, null,
1086
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, '编辑'),
1087
- React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, '下载')),
1088
- ),
1089
- React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
1090
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
1091
- : err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
1092
- : (data && data.binary
1093
- ? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
1094
- '二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 请用「下载」或 rw_download / 本地镜像查看。')
1095
- : (edit
1096
- ? 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' } })
1097
- : 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' } },
1098
- (data && data.content != null) ? data.content : '(空文件)',
1099
- (data && data.truncated)
1100
- ? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
1101
- : null,
1102
- ))),
1103
- ),
1104
- )
1105
- }
1106
-
1107
- // ── better-sidebar registration (guarded: plugin may be absent) ────────
1108
- function registerSidebarIntegration(ctx) {
1109
- const bs = (ctx && ctx.get && ctx.get('betterSidebar')) || null
1110
- if (!bs || typeof bs.registerTab !== 'function') return
1111
- const disposers = []
1112
- try {
1113
- disposers.push(bs.registerTab({
1114
- id: SIDEBAR_EXPLORER_ID,
1115
- title: () => '远程文件',
1116
- icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
1117
- order: 55,
1118
- single: true,
1119
- component: (props) => React.createElement(RemoteExplorerTab, props),
1120
- }))
1121
- disposers.push(bs.registerTab({
1122
- id: SIDEBAR_FILE_ID,
1123
- title: () => '远程文件',
1124
- hidden: true, // not in the + menu; opened from the explorer tree
1125
- dedupeKey: (tab) => tab.path,
1126
- component: (props) => React.createElement(RemoteFileTab, props),
1127
- }))
1128
- } catch (e) {
1129
- console.warn('[dsh-remote] better-sidebar integration skipped:', e)
1130
- disposers.forEach((d) => { try { d() } catch {} })
1131
- return
1132
- }
1133
- let autoOpenedFor = new Set()
1134
- let disposeAuto = null
1135
- const tryAutoOpen = () => {
1136
- if (typeof bs.subscribeState !== 'function') return
1137
- const snap = bs.getSnapshot && bs.getSnapshot()
1138
- if (!snap || !snap.sessionId) return
1139
- if (autoOpenedFor.has(snap.sessionId)) return
1140
- const sessionId = snap.sessionId
1141
- resolveSessionRemote(sessionId).then((remotePath) => {
1142
- if (!remotePath || autoOpenedFor.has(sessionId)) return
1143
- autoOpenedFor.add(sessionId)
1144
- try {
1145
- bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: remotePath })
1146
- } catch (e2) {
1147
- console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
1148
- }
1149
- })
1150
- }
1151
- tryAutoOpen()
1152
- if (typeof bs.subscribeState === 'function') {
1153
- disposeAuto = bs.subscribeState(tryAutoOpen)
1154
- disposers.push(disposeAuto)
1155
- }
1156
- return () => disposers.forEach((d) => { try { d() } catch {} })
1157
- }
1158
-
1159
- function apply(ctx) {
1160
- const slots = ctx.get('slots')
1161
- if (slots === undefined) return
1162
- WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
1163
- SESSIONS = (ctx && ((ctx.get && ctx.get('sessions')) || null)) || null
1164
- slots.inject('settings.section', () =>
1165
- slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
1166
- )
1167
- slots.inject(
1168
- 'conversation.hero.workspace.directoryFlow',
1169
- () => slots.inject('sidebar.workspaces.directoryFlow',
1170
- function* () {
1171
- yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
1172
- yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
1173
- },
1174
- ),
1175
- )
1176
- ctx.inject(['betterSidebar'], (inner) => {
1177
- const bsDispose = registerSidebarIntegration(inner)
1178
- if (bsDispose) {
1179
- inner.effect(() => bsDispose, 'dsh-remote.betterSidebar')
1180
- }
1181
- })
1182
- }
1183
-
1184
- exports.name = name
1185
- exports.apply = apply
1186
- return module.exports
1187
- },
1188
- })
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
+ })