dsh-remote 0.8.4 → 0.8.5

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