dsh-remote 0.7.5 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // dsh-remote — client half.
2
- // Settings → 远程工作区: a multi-machine SSH registry (add/edit/select current;
3
- // password stored host-side and kept private). No workspace path here — the
4
- // path is picked at workspace-add time.
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.
5
6
  //
6
7
  // A unified workspace directory picker fills ui-workspace's two directory-flow
7
8
  // holes (sidebar + conversation hero):
@@ -11,6 +12,10 @@
11
12
  // /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
12
13
  // real LOCAL mirror → onPicked(localMirror) so host adopts it as a workspace.
13
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
+ //
14
19
  // Client entries must be classic scripts registered via window.__ModuleLoader__.load
15
20
  // ({ id, factory }); the factory receives a synchronous `require`.
16
21
  window.__ModuleLoader__.load({
@@ -32,6 +37,15 @@ window.__ModuleLoader__.load({
32
37
  return data
33
38
  }
34
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
+
35
49
  // Theme via DSH design tokens so this follows the harness light/dark theme.
36
50
  const v = (name, fb) => `var(${name}, ${fb})`
37
51
  const T = {
@@ -42,6 +56,7 @@ window.__ModuleLoader__.load({
42
56
  danger: v('--dsw-static-red-500', '#e06c75'),
43
57
  dangerText: v('--dsw-static-red-400', '#e06c75'),
44
58
  ok: v('--dsw-static-green-500', '#4caf7d'),
59
+ warn: v('--dsw-static-yellow-500', '#e6c07b'),
45
60
  radius: 8,
46
61
  muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
47
62
  label: v('--dsw-alias-label-primary', '#e4e4e7'),
@@ -52,37 +67,66 @@ window.__ModuleLoader__.load({
52
67
  const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
53
68
  const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
54
69
  const primaryBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: T.primary, color: T.onPrimary, cursor: 'pointer', fontWeight: 600 }
55
- const ghostBtnS = { padding: '6px 12px', borderRadius: T.radius, border: 'none', background: 'transparent', color: T.label, cursor: 'pointer' }
56
70
  const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
57
71
  const boxS = box
58
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
+
59
89
  let WORKSPACES = null // ctx.get('workspaces'), set in apply()
60
90
 
61
- // ── Settings → 远程工作区 (machine registry) ─────────────────────────────
91
+ // ── Settings → 远程工作区 (machine registry + forwards + audit) ──────────
62
92
  function RemoteWorkspacePage() {
63
93
  const [machines, setMachines] = React.useState([])
64
94
  const [currentId, setCurrentId] = React.useState('')
65
- const [form, setForm] = React.useState({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' })
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
+ })
66
100
  const [busy, setBusy] = React.useState(false)
67
101
  const [msg, setMsg] = React.useState('')
68
102
  const [err, setErr] = React.useState('')
69
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([])
70
109
 
71
110
  const testConn = () => {
72
111
  if (!form.host.trim() || tst === 'testing') return
73
112
  setTst('testing'); setErr(''); setMsg('')
74
113
  api('POST', '/dsh-remote/test-connect', {
75
114
  host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
76
- password: form.password, privateKeyPath: form.privateKeyPath,
115
+ password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
116
+ proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
77
117
  })
78
118
  .then((r) => {
79
- if (r && r.ok) { setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)` : '连接成功') }
119
+ if (r && r.ok) { setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)` : '连接成功'); refresh() }
80
120
  else { setTst('failing'); setErr((r && r.error) || '连接失败') }
81
121
  })
82
122
  .catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
83
123
  }
84
124
 
85
- const refresh = () => api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setCurrentId(r.currentId || '') })
125
+ const refresh = () => api('GET', '/dsh-remote/machines').then((r) => {
126
+ setMachines(r.machines || []); setCurrentId(r.currentId || '')
127
+ const cur = (r.machines || []).find((m) => m.id === r.currentId)
128
+ setWorkspaces((cur && cur.recentWorkspaces) || [])
129
+ })
86
130
  React.useEffect(() => { refresh() }, [])
87
131
 
88
132
  // ── update state ────────────────────────────────────────────────────────
@@ -98,7 +142,7 @@ window.__ModuleLoader__.load({
98
142
  if (!r || !r.ok) { setUpdMsg((r && r.error) || '检查更新失败'); return }
99
143
  setUpd(r)
100
144
  if (r.updateMode) setUpdMode(r.updateMode)
101
- if (!quiet) setUpdMsg(r.updateAvailable ? '发现新版本 ' + r.latest : '已是最新版本(v' + r.current + ')')
145
+ if (!quiet) setUpdMsg(r.updateAvailable ? '发现新版本 v' + r.latest : '已是最新版本(v' + r.current + ')')
102
146
  })
103
147
  .catch((e) => setUpdMsg(String((e && e.message) || e)))
104
148
  .finally(() => setUpdBusy(false))
@@ -109,45 +153,90 @@ window.__ModuleLoader__.load({
109
153
  setUpdBusy(true); setUpdMsg('')
110
154
  api('POST', '/dsh-remote/update-apply', { version: upd.latest })
111
155
  .then((r) => {
112
- if (r && r.ok) setUpdMsg('✅ 已更新到 v' + r.to + '(' + r.from + ' → ' + r.to + ')。重启 Harness 生效。')
156
+ if (r && r.ok) setUpdMsg('✅ 已更新到 v' + r.to + '(' + (r.from || '') + ' → ' + r.to + ')。重启 Harness 生效。')
113
157
  else setUpdMsg((r && r.error) || '更新失败')
114
158
  checkUpdate(true)
115
159
  })
116
160
  .catch((e) => setUpdMsg('更新失败: ' + String((e && e.message) || e)))
117
161
  .finally(() => setUpdBusy(false))
118
162
  }
119
- // 更新模式持久化:auto/manual/off 通过 /dsh-remote/update-check 返回当前值;
120
- // 修改模式这里仅本地提示(host 侧 config 由 profile 配置)。为简单可靠,
121
- // 模式切换写入 host 配置目录下的 override 文件,host 加载时读取。
122
163
  const saveUpdateMode = (mode) => {
123
164
  setUpdMode(mode); setUpdMsg('更新模式已设为「' + (mode === 'auto' ? '自动' : mode === 'off' ? '关闭' : '手动') + '」')
124
165
  api('POST', '/dsh-remote/update-mode', { mode }).catch((e) => setUpdMsg('模式保存失败: ' + String((e && e.message) || e)))
125
166
  }
126
167
  React.useEffect(() => { checkUpdate(true) }, [])
127
168
 
169
+ const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
170
+ React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
171
+
128
172
  const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
129
- const startEdit = (m) => setForm({ name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root', password: '', privateKeyPath: m.privateKeyPath || '', id: m.id })
173
+ const setFb = (k) => (ev) => setForm({ ...form, [k]: ev.target.checked })
174
+ const startEdit = (m) => setForm({
175
+ name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root',
176
+ password: '', privateKeyPath: m.privateKeyPath || '', passphrase: '', workspace: m.workspace || '',
177
+ hostKeyMode: m.hostKeyMode || '', useAgent: !!m.useAgent, keyboardInteractive: !!m.keyboardInteractive,
178
+ encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
179
+ proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
180
+ proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
181
+ id: m.id,
182
+ })
183
+ 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: '' })
130
184
  const save = (action) => {
131
185
  setBusy(true); setErr(''); setMsg('')
132
186
  if (action === 'delete') {
133
- api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg('已删除') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
187
+ 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))
134
188
  return
135
189
  }
136
190
  api('POST', '/dsh-remote/machines', {
137
191
  action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
138
- username: form.username, password: form.password, privateKeyPath: form.privateKeyPath,
139
- }).then((r) => { refresh(); setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
192
+ username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
193
+ workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
194
+ encryptPassword: form.encryptPassword,
195
+ proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
196
+ }).then((r) => { refresh(); clearForm(); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
140
197
  }
141
198
  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)) }
142
199
  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))) } }
143
200
 
144
- const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center' } },
145
- React.createElement('label', { style: { width: 76, fontSize: 12, opacity: 0.8 } }, label), ctrl)
201
+ const importSsh = () => {
202
+ api('GET', '/dsh-remote/ssh-config').then((r) => setSshEntries(r && r.present ? (r.entries || []) : []))
203
+ .catch((e) => setErr('读取 ~/.ssh/config 失败: ' + String((e && e.message) || e)))
204
+ }
205
+ const applySshEntry = (e) => {
206
+ setForm({ ...form,
207
+ name: e.host, host: e.hostName || e.host, port: String(e.port || 22),
208
+ username: e.user || 'root', privateKeyPath: e.identityFile || '',
209
+ })
210
+ setSshEntries(null)
211
+ setMsg('已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)')
212
+ }
213
+
214
+ const setWorkspaceNow = (p) => {
215
+ api('POST', '/dsh-remote/workspace', { path: p })
216
+ .then(() => setMsg('已切换到工作区 ' + p))
217
+ .catch((e) => setErr(String((e && e.message) || e)))
218
+ }
219
+
220
+ // ── forwards panel ────────────────────────────────────────────────────
221
+ const fwdAction = (action, id) => {
222
+ api('POST', '/dsh-remote/forwards', { action, id })
223
+ .then((r) => { setForwards(r.forwards || []); setErr(''); setMsg('') })
224
+ .catch((e) => setErr(String((e && e.message) || e)))
225
+ }
226
+ const fwdDefine = () => {
227
+ if (!fwdForm.listenPort) return
228
+ 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 })
229
+ .then((r) => { setForwards(r.forwards || []); setFwdForm({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false }) })
230
+ .catch((e) => setErr(String((e && e.message) || e)))
231
+ }
232
+
233
+ const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 8 } },
234
+ React.createElement('label', { style: { width: 84, fontSize: 12, opacity: 0.8, flexShrink: 0 } }, label), ctrl)
146
235
 
147
- return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 760 } },
236
+ return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 860 } },
148
237
  React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
149
238
  React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
150
- '维护多台 SSH 机器。路径不在设置里配置 —— 新建/选择工作区时,「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
239
+ '维护多台 SSH 机器(密码/私钥/agent/跳板机/钥匙串)。路径在新建/选择工作区时选:「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
151
240
  React.createElement('div', { style: boxS },
152
241
  React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
153
242
  machines.length
@@ -156,35 +245,102 @@ window.__ModuleLoader__.load({
156
245
  m.name + ' ',
157
246
  React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
158
247
  m.passwordSet ? ' 🔒' : '',
248
+ (m.credentialBackend && m.credentialBackend !== 'plain') ? ' 🗝' : '',
249
+ m.proxy && m.proxy.host ? ' ⛳' : '',
250
+ m.latencyMs != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, ` · ${m.latencyMs}ms`) : null,
159
251
  m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
160
252
  React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
161
253
  React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
162
- React.createElement('button', { style: buttonS, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
254
+ React.createElement('button', { style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' }, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
163
255
  : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
164
256
  ),
165
257
  React.createElement('div', { style: boxS },
166
- React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
258
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
259
+ React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
260
+ React.createElement('button', { style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? '收起' : '从 ~/.ssh/config 导入')),
261
+ sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 160, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
262
+ 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 } },
263
+ e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')))
264
+ : React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, '~/.ssh/config 里没有可导入的 Host 条目'))
265
+ : null,
167
266
  row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
168
267
  row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
169
268
  row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
170
269
  row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
171
- row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显、仅保存)', style: inputS }), 'w'),
172
- row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用默认 key', style: inputS }), 'k'),
173
- React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
270
+ row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显)', style: inputS }), 'w'),
271
+ row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用密码或 agent', style: inputS }), 'k'),
272
+ row('Passphrase', React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: '私钥加密时使用', style: inputS }), 'pp'),
273
+ row('默认工作区', React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: '可选:该机器的默认远程目录', style: inputS }), 'ws'),
274
+ row('HostKey 模式', React.createElement('select', { value: form.hostKeyMode, onChange: setF('hostKeyMode'), style: { ...inputS, maxWidth: 220 } },
275
+ React.createElement('option', { value: '' }, '(默认 accept-new)'),
276
+ React.createElement('option', { value: 'accept-new' }, 'accept-new(信任首次,之后校验)'),
277
+ React.createElement('option', { value: 'verify' }, 'verify(严格:拒绝陌生主机)'),
278
+ React.createElement('option', { value: 'off' }, 'off(不校验,不推荐)')), 'hkm'),
279
+ React.createElement('div', { style: { display: 'flex', gap: 16, paddingLeft: 90, flexWrap: 'wrap', marginBottom: 8 } },
280
+ React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
281
+ React.createElement('input', { type: 'checkbox', checked: form.useAgent, onChange: setFb('useAgent') }), 'SSH agent(SSH_AUTH_SOCK)'),
282
+ React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
283
+ React.createElement('input', { type: 'checkbox', checked: form.keyboardInteractive, onChange: setFb('keyboardInteractive') }), 'keyboard-interactive(OTP/动态码)'),
284
+ React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
285
+ React.createElement('input', { type: 'checkbox', checked: form.encryptPassword, onChange: setFb('encryptPassword') }), '加密保存密码(系统钥匙串)')),
286
+ row('跳板机', React.createElement('input', { value: form.proxyHost, onChange: setF('proxyHost'), placeholder: '经此主机中转(可选)', style: inputS }), 'ph'),
287
+ row('端口/用户', React.createElement('div', { style: { display: 'flex', gap: 6, flex: 1, flexWrap: 'wrap' } },
288
+ React.createElement('input', { value: form.proxyPort, onChange: setF('proxyPort'), placeholder: '端口(22)', style: { ...inputS, minWidth: 90, maxWidth: 120 } }),
289
+ React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: '跳板用户', style: { ...inputS, minWidth: 140 } }),
290
+ React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: '跳板密码', style: { ...inputS, minWidth: 140 } })), 'pu'),
291
+ row('跳板私钥', React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: '跳板私钥路径(可选)', style: inputS }), 'pk'),
292
+ React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', flexWrap: 'wrap', marginTop: 4 } },
174
293
  msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
175
294
  form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
176
- React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr(''); setTst('idle') } }, form.id ? '取消编辑' : '清空'),
295
+ React.createElement('button', { style: buttonS, onClick: () => { clearForm(); setErr(''); setTst('idle') } }, form.id ? '取消编辑' : '清空'),
177
296
  React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
178
297
  tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
179
298
  React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
180
299
  ),
181
300
  ),
301
+ // 快捷切换工作区(该机器的最近工作区)
302
+ workspaces.length ? React.createElement('div', { style: boxS },
303
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '当前机器最近工作区(一键切换)'),
304
+ workspaces.map((w, i) => React.createElement('div', { key: w + i, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
305
+ React.createElement('code', { style: { flex: 1, fontSize: 12, fontFamily: 'monospace' } }, w),
306
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, '切换')))) : null,
307
+ // 端口转发面板
308
+ React.createElement('div', { style: boxS },
309
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '端口转发(SSH 隧道)'),
310
+ 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' } },
311
+ React.createElement('span', { style: { color: f.active ? T.ok : T.muted } }, f.active ? '●' : '○'),
312
+ React.createElement('span', { style: { flex: 1 } }, `${f.direction} 127.0.0.1:${f.listenPort} → ${f.targetHost}:${f.targetPort}${f.autoStart ? ' [自启]' : ''}`),
313
+ f.active
314
+ ? React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('stop', f.id) }, '停止')
315
+ : React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('start', f.id) }, '启动'),
316
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => fwdAction('remove', f.id) }, '删除'))) : null,
317
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' } },
318
+ React.createElement('select', { value: fwdForm.direction, onChange: (e) => setFwdForm({ ...fwdForm, direction: e.target.value }), style: { ...inputS, maxWidth: 110 } },
319
+ React.createElement('option', { value: 'local' }, '本地→远端'),
320
+ React.createElement('option', { value: 'reverse' }, '远端→本地')),
321
+ React.createElement('input', { value: fwdForm.listenPort, onChange: (e) => setFwdForm({ ...fwdForm, listenPort: e.target.value }), placeholder: '监听端口', style: { ...inputS, maxWidth: 90 } }),
322
+ React.createElement('input', { value: fwdForm.targetHost, onChange: (e) => setFwdForm({ ...fwdForm, targetHost: e.target.value }), placeholder: '目标主机', style: { ...inputS, maxWidth: 120 } }),
323
+ React.createElement('input', { value: fwdForm.targetPort, onChange: (e) => setFwdForm({ ...fwdForm, targetPort: e.target.value }), placeholder: '目标端口', style: { ...inputS, maxWidth: 90 } }),
324
+ React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
325
+ React.createElement('input', { type: 'checkbox', checked: fwdForm.autoStart, onChange: (e) => setFwdForm({ ...fwdForm, autoStart: e.target.checked }) }), '自动重连'),
326
+ React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: fwdDefine, disabled: !fwdForm.listenPort }, '添加转发')),
327
+ ),
328
+ // 审计日志
329
+ React.createElement('div', { style: boxS },
330
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '最近执行的远程命令(审计日志)'),
331
+ auditLines.length
332
+ ? React.createElement('div', { style: { maxHeight: 180, overflowY: 'auto', fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 } },
333
+ auditLines.map((l, i) => React.createElement('div', { key: i, style: { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, l)))
334
+ : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)'),
335
+ ),
182
336
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
183
337
  React.createElement('div', { style: boxS },
184
338
  React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '更新'),
185
339
  React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 } },
186
340
  upd
187
- ? React.createElement('span', { style: { opacity: 0.85 } }, '当前 v' + upd.current + (upd.updateAvailable ? React.createElement('b', { style: { color: T.ok, marginLeft: 6 } }, '→ 新版本 v' + upd.latest) : null))
341
+ ? React.createElement('span', { style: { opacity: 0.85 } },
342
+ '当前 v' + upd.current,
343
+ upd.updateAvailable ? React.createElement('b', { style: { color: T.ok, marginLeft: 6 } }, '→ 新版本 v' + upd.latest) : null)
188
344
  : React.createElement('span', { style: { opacity: 0.6 } }, '版本信息加载中…'),
189
345
  React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => checkUpdate(false), disabled: updBusy }, updBusy ? '检查中…' : '检查更新'),
190
346
  upd && upd.updateAvailable
@@ -207,7 +363,7 @@ window.__ModuleLoader__.load({
207
363
  React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
208
364
  React.createElement('span', {}, '·'),
209
365
  React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
210
- React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v0.7.5'),
366
+ React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v0.8.2'),
211
367
  ),
212
368
  )
213
369
  }
@@ -219,8 +375,6 @@ window.__ModuleLoader__.load({
219
375
  const t = ln.trim()
220
376
  if (!t || !t.length || /^total\b/i.test(t)) continue
221
377
  const parts = t.split(/\s+/).filter(Boolean)
222
- // ls -l row: mode links owner group size month day [hh:mm|year] name...
223
- // NAME starts at column 8 (everything before it is the fixed metadata).
224
378
  if (parts.length < 8) continue
225
379
  const mode = parts[0]
226
380
  const name = parts.slice(8).join(' ')
@@ -231,8 +385,7 @@ window.__ModuleLoader__.load({
231
385
  return out
232
386
  }
233
387
 
234
- // Clickable breadcrumb of the current remote path; clicking a segment jumps
235
- // to that ancestor level (finite-width, one line, ellipsized at the front).
388
+ // Clickable breadcrumb of the current remote path.
236
389
  function breadcrumb(active, cur, jumpTo) {
237
390
  const norm = String(cur || '').replace(/\/+$/, '')
238
391
  const segs = norm === '' || norm === '/' ? [] : norm.split('/')
@@ -262,21 +415,19 @@ window.__ModuleLoader__.load({
262
415
  const [items, setItems] = React.useState(null)
263
416
  const [err, setErr] = React.useState('')
264
417
  const [loading, setLoading] = React.useState(false)
265
- // 级联下钻状态:每一格是 { path, dirs } —— dirs 是该路径下的目录列表。
266
418
  const [levels, setLevels] = React.useState(null)
267
419
  const [popOpen, setPopOpen] = React.useState(false)
268
420
  const [suggest, setSuggest] = React.useState([])
269
421
  const [suggestOpen, setSuggestOpen] = React.useState(false)
422
+ const [recent, setRecent] = React.useState([])
270
423
  const suggestTimer = React.useRef(null)
271
424
  const suggestRef = React.useRef(null)
272
- // 点击自动补全下拉外部的空白处时收起补全列表(上游 0.5.10)
273
425
  React.useEffect(() => {
274
426
  if (!suggestOpen) return
275
427
  const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
276
428
  document.addEventListener('mousedown', onDown)
277
429
  return () => document.removeEventListener('mousedown', onDown)
278
430
  }, [suggestOpen])
279
- // 机器下拉(自绘 dropdown):点外部任意处关闭,避免候选层常驻遮挡下方按钮(上游 0.5.8)
280
431
  const [mOpen, setMOpen] = React.useState(false)
281
432
  const ddRef = React.useRef(null)
282
433
  React.useEffect(() => {
@@ -286,6 +437,14 @@ window.__ModuleLoader__.load({
286
437
  return () => document.removeEventListener('mousedown', onDown)
287
438
  }, [mOpen])
288
439
 
440
+ const sortItems = (list) => {
441
+ const dirs = list.filter((it) => it.dir)
442
+ const files = list.filter((it) => !it.dir)
443
+ dirs.sort((a, b) => a.name.localeCompare(b.name))
444
+ files.sort((a, b) => a.name.localeCompare(b.name))
445
+ return dirs.concat(files)
446
+ }
447
+
289
448
  const loadLevels = (id, p, toIndex) => {
290
449
  if (!id) return
291
450
  setLoading(true); setErr('')
@@ -293,15 +452,12 @@ window.__ModuleLoader__.load({
293
452
  .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
294
453
  .then((res) => {
295
454
  const real = res && res.path ? res.path : (p || '')
296
- // server returns [{ type:'dir'|'file', name }]; entering a dir is
297
- // decided by type dir (server already follows symlinks to dirs).
298
455
  const list = Array.isArray(res && res.items)
299
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
456
+ ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime }))
300
457
  : parseLs(res && res.text)
301
- const node = { path: real, dirs: list.filter((it) => it.dir), all: list }
458
+ const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
302
459
  setLevels((prev) => {
303
460
  const base = prev && prev.length ? prev.slice() : []
304
- // toIndex >= 0 → put this node at that position, truncating below it.
305
461
  let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
306
462
  if (idx >= base.length) return base.concat([node])
307
463
  base[idx] = node
@@ -312,7 +468,14 @@ window.__ModuleLoader__.load({
312
468
  .finally(() => setLoading(false))
313
469
  }
314
470
 
315
- React.useEffect(() => { if (open) { api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '') }) } }, [open])
471
+ React.useEffect(() => { if (open) {
472
+ api('GET', '/dsh-remote/machines').then((r) => {
473
+ setMachines(r.machines || [])
474
+ const cur = (r.machines || []).find((m) => m.id === r.currentId)
475
+ setRecent((cur && cur.recentWorkspaces) || [])
476
+ setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '')
477
+ })
478
+ } }, [open])
316
479
 
317
480
  const chooseLocal = () => {
318
481
  setLoading(true); setErr('')
@@ -330,14 +493,10 @@ window.__ModuleLoader__.load({
330
493
  setTab(t); setErr('')
331
494
  if (t === 'remote') {
332
495
  if (machineId) loadLevels(machineId, '', 0)
333
- // Prime the field with the machine's root so the user immediately gets
334
- // a '/'-level completion list without typing anything.
335
496
  if (!path.trim()) { setPath('/'); loadSuggestions('/') }
336
497
  }
337
498
  }
338
499
 
339
- // enterDir(name): drive into the named subdir of the current deepest level,
340
- // appending that directory as the new deepest level (path is pieced together).
341
500
  const enterDir = (name) => {
342
501
  if (busy || loading) return
343
502
  const last = levels && levels.length ? levels[levels.length - 1] : null
@@ -347,14 +506,11 @@ window.__ModuleLoader__.load({
347
506
  loadLevels(machineId, next, (levels ? levels.length : 0))
348
507
  }
349
508
 
350
- // Autocomplete: given a partial remote path, list children of its parent dir
351
- // that start with the last segment (powered by the structured ls endpoint).
352
509
  const loadSuggestions = (raw, mid) => {
353
510
  const id = mid || machineId
354
511
  if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
355
512
  const t = String(raw || '').trim()
356
513
  if (!t) { setSuggest([]); setSuggestOpen(false); return }
357
- // Windows drive path (D:\...) uses backslashes; POSIX uses '/'.
358
514
  const isWin = t.includes('\\')
359
515
  const sep = isWin ? '\\' : '/'
360
516
  const slash = t.lastIndexOf(sep)
@@ -380,8 +536,6 @@ window.__ModuleLoader__.load({
380
536
  suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
381
537
  }
382
538
 
383
- // After a directory is chosen, immediately reveal the next level: list the
384
- // chosen directory's children as fresh completions (no keystroke needed).
385
539
  const continueSuggest = (dir) => {
386
540
  if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
387
541
  setSuggestOpen(false)
@@ -399,13 +553,11 @@ window.__ModuleLoader__.load({
399
553
  }).catch(() => { setSuggest([]); setSuggestOpen(false) })
400
554
  }
401
555
 
402
- // Choose a suggestion: fill the field and immediately open its next level.
403
556
  const selectSuggestion = (s) => {
404
557
  setPath(s); setErr(''); setSuggestOpen(false)
405
558
  continueSuggest(s)
406
559
  }
407
560
 
408
- // Commit an explicit remote path as the workspace (race from the input).
409
561
  const commitPath = (p) => {
410
562
  const target = String(p || '').trim()
411
563
  if (!target || !machineId || busy) return
@@ -413,27 +565,42 @@ window.__ModuleLoader__.load({
413
565
  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)))
414
566
  }
415
567
 
416
- // Confirm the highlighted directory from the browser popup by filling the
417
- // path input (not committing), so the user can review/edit before commit.
418
568
  const acceptBrowserPick = (p) => {
419
569
  setPath(String(p || ''))
420
570
  setSuggestOpen(false)
421
571
  setPopOpen(false)
422
572
  }
423
573
 
574
+ const pickHome = () => {
575
+ api('POST', '/dsh-remote/home').then((r) => {
576
+ if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
577
+ else setErr((r && (r.hint || r.error)) || '无法解析主目录')
578
+ }).catch((e) => setErr(String((e && e.message) || e)))
579
+ }
580
+
581
+ const mkdirHere = () => {
582
+ const last = levels && levels.length ? levels[levels.length - 1] : null
583
+ const base = (last && last.path) || '/'
584
+ const nm = window.prompt('新建目录名(在 ' + base + ' 下)')
585
+ if (!nm || !nm.trim()) return
586
+ const sep = base.includes('\\') ? '\\' : '/'
587
+ const full = base === '/' ? '/' + nm.trim() : base + sep + nm.trim()
588
+ api('POST', '/dsh-remote/fs', { op: 'mkdir', path: full })
589
+ .then(() => { setErr(''); loadLevels(machineId, base, (levels ? levels.length - 1 : 0)) })
590
+ .catch((e) => setErr(String((e && e.message) || e)))
591
+ }
592
+
424
593
  function renderDirPopup() {
425
594
  if (!levels || !levels.length) {
426
595
  return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
427
596
  }
428
597
  const last = levels[levels.length - 1]
429
598
  const entries = last.all || []
430
- // Inline panel anchored inside the dialog (NOT a full-viewport overlay):
431
- // a height-capped box whose directory list scrolls internally, so the
432
- // dialog's own confirm row ("设为远程工作区") stays visible below it.
433
599
  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' } },
434
600
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
435
601
  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 }, '回上一级 ▴'),
436
602
  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 }))),
603
+ React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: mkdirHere }, '新建目录'),
437
604
  React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
438
605
  ),
439
606
  React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
@@ -444,7 +611,8 @@ window.__ModuleLoader__.load({
444
611
  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 },
445
612
  },
446
613
  React.createElement('span', null, it.dir ? '📁' : '📄'),
447
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
614
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
615
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
448
616
  )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
449
617
  ),
450
618
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
@@ -456,11 +624,8 @@ window.__ModuleLoader__.load({
456
624
 
457
625
  if (!open) return null
458
626
  const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
459
- // The picker is a full-viewport centered modal (backdrop + panel), so it
460
- // renders identically in the narrow sidebar and in the conversation and
461
- // is never squeezed into a cramped in-place column.
462
627
  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() } },
463
- 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(620px, 90vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
628
+ 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() },
464
629
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
465
630
  React.createElement('div', null, '选择工作目录'),
466
631
  React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
@@ -489,29 +654,28 @@ window.__ModuleLoader__.load({
489
654
  mOpen
490
655
  ? 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 } },
491
656
  machines.length
492
- ? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); 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 + ')'))
657
+ ? 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 + ')'))
493
658
  : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
494
659
  )
495
660
  : null,
496
661
  ),
497
662
  ),
498
- // 路径输入框(带自动补全)+ 打开浏览弹层按钮
499
- // suggestRef 外层仍用于「点击外部关闭」判定;输入行与补全列表
500
- // 是两级 div —— 补全列表以流式(非绝对定位)展开,确认按钮被
501
- // 推到下方而不会被遮挡(issue #4「有子目录时选择框遮挡确定菜单」)。
663
+ // 最近工作区快捷入口
664
+ recent.length ? React.createElement('div', { style: { display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' } },
665
+ React.createElement('span', { style: { fontSize: 11, opacity: 0.6, whiteSpace: 'nowrap' } }, '最近:'),
666
+ 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,
502
667
  React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
503
- React.createElement('div', { style: { display: 'flex', gap: 6 } },
668
+ React.createElement('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
504
669
  React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
505
670
  React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
671
+ React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, '~ 主目录'),
506
672
  ),
507
- // 自动补全下拉(流式展开,参与文档流,不覆盖确认按钮)
508
673
  (suggestOpen && suggest.length)
509
674
  ? 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)' } },
510
675
  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)),
511
676
  )
512
677
  : null,
513
678
  ),
514
- // 目录浏览面板(内联,锚定在对话框内;确认按钮保持可见)
515
679
  popOpen ? renderDirPopup() : null,
516
680
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
517
681
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
@@ -530,50 +694,25 @@ window.__ModuleLoader__.load({
530
694
  }
531
695
 
532
696
  // ── better-sidebar integration (optional) ────────────────────────────────
533
- // The sidebar of dsh-better-sidebar lists LOCAL files (its fs tree reads
534
- // the host filesystem). When dsh-remote is the active remote workspace the
535
- // user wants to browse the REMOTE host's files live — not the local SFTP
536
- // mirror snapshot (which rw_sync caps by depth/file-count/size).
537
- //
538
- // Through the public `ctx.betterSidebar` service (v0.4.0+; gracefully
539
- // skipped when dsh-better-sidebar is not installed) we register TWO tabs:
540
- // • `dsh-remote:explorer` — a live remote file tree (dirs expand over
541
- // /dsh-remote/ls; every click lists the REMOTE host, not the mirror),
542
- // • `dsh-remote:file` — a read-only remote file view (hidden from the
543
- // + menu; opened by the explorer) that reads the file live over
544
- // /dsh-remote/read and renders its text (or a binary notice).
545
- //
546
- // Both are READ-ONLY on purpose: the sidebar editor's save path writes to
547
- // the LOCAL fs, so an editable remote file would silently save into the
548
- // mirror. Editing stays in the rw_* tools / the mirror workflow. We do NOT
549
- // register a file viewer (better-sidebar's matching is extension/priority
550
- // based and cannot tell a remote path from a local one — a catch-all
551
- // viewer would hijack every local file open).
552
-
553
- // A stable id namespace so the persisted sidebar tabs survive reloads.
554
697
  const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
555
698
  const SIDEBAR_FILE_ID = 'dsh-remote:file'
556
699
 
557
- /** Remote workspace state the sidebar reads (host /dsh-remote/status). */
558
700
  function fetchRemoteStatus() {
559
701
  return api('GET', '/dsh-remote/status').catch(() => null)
560
702
  }
561
703
 
562
- /** Read a remote file (live) → {binary, content?, head?, size?, truncated?}. */
563
704
  function readRemoteFile(path, maxBytes) {
564
705
  return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
565
706
  throw new Error('远程读取失败: ' + ((e && e.message) || e))
566
707
  })
567
708
  }
568
709
 
569
- /** List a remote dir (live) → items [{name, type}] (server-driven types). */
570
710
  function listRemoteDir(path) {
571
711
  return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
572
712
  throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
573
713
  })
574
714
  }
575
715
 
576
- /** Join a remote dir + entry name, honoring the dir's separator style. */
577
716
  function joinRemote(base, name) {
578
717
  if (!base) return name
579
718
  return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
@@ -582,19 +721,18 @@ window.__ModuleLoader__.load({
582
721
  // ── Remote explorer tab ────────────────────────────────────────────────
583
722
  function RemoteExplorerTab(props) {
584
723
  const [status, setStatus] = React.useState(null)
585
- const [levels, setLevels] = React.useState([]) // [{path, items}]
724
+ const [levels, setLevels] = React.useState([])
586
725
  const [err, setErr] = React.useState('')
587
726
  const [loading, setLoading] = React.useState(false)
588
727
  const [picker, setPicker] = React.useState(false)
728
+ const [menu, setMenu] = React.useState(null) // { x, y, item }
729
+ const [busy, setBusy] = React.useState('')
589
730
 
590
731
  const refreshStatus = () => {
591
732
  fetchRemoteStatus().then((s) => {
592
733
  setStatus(s)
593
734
  if (s && s.workspace) {
594
735
  setLevels((prev) => {
595
- // First open with a workspace: seed the root level AND load it,
596
- // so the tree shows the remote listing immediately instead of
597
- // an empty "(空目录)" until the user hits ↻.
598
736
  if (!prev.length) {
599
737
  loadDir(s.workspace, 0)
600
738
  return [{ path: s.workspace, items: null }]
@@ -605,18 +743,27 @@ window.__ModuleLoader__.load({
605
743
  })
606
744
  }
607
745
  React.useEffect(() => { refreshStatus() }, [])
608
- // When the tab becomes visible, re-verify the workspace still exists
609
- // (the remote connection may have been switched in settings).
610
746
  React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
747
+ React.useEffect(() => {
748
+ if (!menu) return
749
+ const close = () => setMenu(null)
750
+ document.addEventListener('mousedown', close)
751
+ return () => document.removeEventListener('mousedown', close)
752
+ }, [menu])
611
753
 
612
754
  const loadDir = (p, idx) => {
613
755
  if (loading) return
614
756
  setLoading(true); setErr('')
615
757
  listRemoteDir(p).then((res) => {
616
758
  const items = Array.isArray(res && res.items) ? res.items : []
759
+ const sorted = items.slice().sort((a, b) => {
760
+ if (a.type === 'dir' && b.type !== 'dir') return -1
761
+ if (a.type !== 'dir' && b.type === 'dir') return 1
762
+ return String(a.name).localeCompare(String(b.name))
763
+ })
617
764
  setLevels((prev) => {
618
765
  const base = prev.length ? prev.slice() : [{ path: p, items: null }]
619
- const list = items.map((it) => ({ name: it.name, type: it.type === 'dir' ? 'dir' : 'file' }))
766
+ const list = sorted.map((it) => ({ name: it.name, type: it.type === 'dir' ? 'dir' : 'file', size: it.size, mtime: it.mtime }))
620
767
  if (idx < 0 || idx >= base.length) return base.concat([{ path: p, items: list }])
621
768
  base[idx] = { path: p, items: list }
622
769
  return base.slice(0, idx + 1)
@@ -625,7 +772,6 @@ window.__ModuleLoader__.load({
625
772
  .finally(() => setLoading(false))
626
773
  }
627
774
 
628
- // Open a directory: append it as the new deepest level.
629
775
  const enterDir = (name) => {
630
776
  const last = levels.length ? levels[levels.length - 1] : null
631
777
  if (!last || !last.items) return
@@ -634,7 +780,6 @@ window.__ModuleLoader__.load({
634
780
  const next = base === '/' ? '/' + name : base + sep + name
635
781
  loadDir(next, levels.length)
636
782
  }
637
- // Jump to a breadcrumb ancestor.
638
783
  const jumpTo = (p) => {
639
784
  // Breadcrumb segments may lack the leading separator (built by
640
785
  // splitting the absolute path), so normalize to the absolute form
@@ -644,36 +789,60 @@ window.__ModuleLoader__.load({
644
789
  if (idx < 0) return
645
790
  setLevels(levels.slice(0, idx + 1))
646
791
  }
647
- // Open a file: open the dedicated REMOTE file tab (reads the file live
648
- // over /dsh-remote/read) — NOT the builtin editor, whose fs.read would
649
- // hit the local filesystem and fail on a remote path.
650
792
  const openFile = (p) => {
651
793
  const name = String(p).split(/[\\/]/).pop() || p
652
794
  props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
653
795
  }
654
796
 
655
- const allItems = levels.length ? (levels[levels.length - 1].items || []) : []
797
+ const doFs = (op, payload) => {
798
+ setBusy(op)
799
+ return api('POST', '/dsh-remote/fs', { op, ...payload })
800
+ .then(() => {
801
+ const last = levels.length ? levels[levels.length - 1] : null
802
+ if (last) loadDir(last.path, levels.length - 1)
803
+ setErr('')
804
+ })
805
+ .catch((e) => setErr(String((e && e.message) || e)))
806
+ .finally(() => setBusy(''))
807
+ }
808
+
809
+ const onCtx = (e, item) => {
810
+ e.preventDefault(); e.stopPropagation()
811
+ setMenu({ x: e.clientX, y: e.clientY, item })
812
+ }
656
813
  const curPath = levels.length ? levels[levels.length - 1].path : ''
657
- const segs = String(curPath || '').split(/[\\/]+/).filter(Boolean)
658
- 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([]) } }, '⌂')]
659
- let acc = ''
660
- segs.forEach((s, i) => {
661
- acc = acc ? acc + '/' + s : s
662
- const isLast = i === segs.length - 1
663
- 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))
664
- })
814
+ const menuFull = menu ? joinRemote(curPath, menu.item.name) : ''
665
815
 
666
816
  const row = (it, i) => React.createElement('div', {
667
817
  key: it.name + '|' + i,
668
818
  title: (it.type === 'dir' ? '进入目录 ' : '打开文件 ') + it.name,
669
819
  onClick: () => (it.type === 'dir' ? enterDir(it.name) : openFile(joinRemote(curPath, it.name))),
820
+ onContextMenu: (e) => onCtx(e, it),
670
821
  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)' },
671
- }, React.createElement('span', { style: { flexShrink: 0 } }, it.type === 'dir' ? '📁' : '📄'), React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name))
822
+ },
823
+ React.createElement('span', { style: { flexShrink: 0 } }, it.type === 'dir' ? '📁' : '📄'),
824
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
825
+ it.type !== 'dir' ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, humanSize(it.size)) : null)
672
826
 
673
827
  const rootHint = status && status.workspace
674
828
  ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + status.workspace + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
675
829
  : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区')
676
830
 
831
+ const allItems = levels.length ? (levels[levels.length - 1].items || []) : []
832
+ const segs = String(curPath || '').split(/[\\/]+/).filter(Boolean)
833
+ 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([]) } }, '⌂')]
834
+ let acc = ''
835
+ segs.forEach((s, i) => {
836
+ acc = acc ? acc + '/' + s : s
837
+ const isLast = i === segs.length - 1
838
+ 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))
839
+ })
840
+
841
+ const ctxItem = (label, danger, fn) => React.createElement('div', {
842
+ onClick: (e) => { e.stopPropagation(); setMenu(null); fn() },
843
+ style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
844
+ }, label)
845
+
677
846
  // Explicit "up one level" button: the breadcrumb line ellipsizes and
678
847
  // its segments may be hard to hit in a narrow sidebar, so give the
679
848
  // user a direct way back to the parent level.
@@ -692,13 +861,13 @@ window.__ModuleLoader__.load({
692
861
  title: '返回上一级目录',
693
862
  }, '↑ 上一级'),
694
863
  React.createElement('div', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontFamily: 'monospace' } }, crumbs),
864
+ 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()) }) } }, '新建目录'),
695
865
  React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
696
866
  ),
697
867
  rootHint,
698
868
  picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
699
869
  open: true, busy: false, onPicked: (localMirror) => {
700
870
  setPicker(false)
701
- // The picker commits a mirror + workspace; refresh the tree.
702
871
  refreshStatus()
703
872
  fetchRemoteStatus().then((s) => { if (s && s.workspace) { setLevels([{ path: s.workspace, items: null }]); loadDir(s.workspace, 0) } })
704
873
  }, onCancel: () => setPicker(false),
@@ -709,54 +878,100 @@ window.__ModuleLoader__.load({
709
878
  : (curPath ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '(空目录)')
710
879
  : React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区'))),
711
880
  ),
712
- err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, err) : null,
881
+ // context menu
882
+ 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() },
883
+ ctxItem(menu.item.type === 'dir' ? '📁 进入目录' : '📄 打开文件', false, () => (menu.item.type === 'dir' ? enterDir(menu.item.name) : openFile(menuFull))),
884
+ ctxItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menuFull })),
885
+ ctxItem('✏ 重命名', false, () => {
886
+ const nm = window.prompt('重命名为(新名字)', menu.item.name)
887
+ if (nm && nm.trim() && nm.trim() !== menu.item.name) doFs('rename', { path: menuFull, dest: joinRemote(curPath, nm.trim()) })
888
+ }),
889
+ ctxItem('🗑 删除' + (menu.item.type === 'dir' ? '(含子目录)' : ''), true, () => {
890
+ if (window.confirm('确认删除 ' + menuFull + '?')) doFs('remove', { path: menuFull })
891
+ }),
892
+ ) : null,
893
+ err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
713
894
  )
714
895
  }
715
896
 
716
- // ── Remote file tab (opened by the explorer; reads the file live) ──────
717
- // A dedicated tab type instead of a file viewer: better-sidebar's viewer
718
- // matching is extension/priority-based and cannot tell a REMOTE path from
719
- // a LOCAL one (a catch-all viewer would hijack every local file open and
720
- // then fail its local fs.read). Our own tab carries the remote path and
721
- // reads it straight over /dsh-remote/read — read-only by design (the
722
- // sidebar editor's save writes to the LOCAL fs, so an editable remote
723
- // file would silently save into the mirror).
897
+ // ── Remote file tab (opened by the explorer; live read + explicit edit) ──
724
898
  function RemoteFileTab(props) {
725
899
  const { tab, scope } = props
726
900
  const path = tab.path || ''
727
901
  const [data, setData] = React.useState(null)
728
902
  const [err, setErr] = React.useState('')
729
903
  const [loading, setLoading] = React.useState(true)
904
+ const [edit, setEdit] = React.useState(false)
905
+ const [draft, setDraft] = React.useState('')
906
+ const [saving, setSaving] = React.useState(false)
730
907
 
731
- React.useEffect(() => {
908
+ const load = () => {
732
909
  if (!path) { setLoading(false); return }
733
910
  let cancelled = false
734
- setLoading(true); setErr(''); setData(null)
911
+ setLoading(true); setErr(''); setData(null); setEdit(false)
735
912
  readRemoteFile(path, 256 * 1024)
736
913
  .then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
737
914
  .catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
738
915
  return () => { cancelled = true }
739
- }, [path])
916
+ }
917
+ React.useEffect(load, [path])
918
+
919
+ const startEdit = () => {
920
+ if (!data || data.binary) return
921
+ setDraft((data.content != null ? data.content : '').replace(/\n…\[truncated:.*$/, ''))
922
+ setEdit(true); setErr('')
923
+ }
924
+ const saveRemote = () => {
925
+ setSaving(true); setErr('')
926
+ apiRaw('POST', '/dsh-remote/write', { path, content: draft })
927
+ .then((r) => {
928
+ if (r.status === 409) {
929
+ setErr(String((r.data && r.data.error) || '远端文件已变化,请重新读取'))
930
+ return
931
+ }
932
+ if (r.status >= 400) throw new Error((r.data && (r.data.error || r.data.message)) || 'HTTP ' + r.status)
933
+ setEdit(false)
934
+ load()
935
+ })
936
+ .catch((e) => setErr(String((e && e.message) || e)))
937
+ .finally(() => setSaving(false))
938
+ }
939
+ const downloadMirror = () => {
940
+ setSaving(true); setErr('')
941
+ api('POST', '/dsh-remote/fs', { op: 'download', path })
942
+ .then((r) => setErr('已下载到本地镜像' + (r && r.local ? ': ' + r.local : '')))
943
+ .catch((e) => setErr(String((e && e.message) || e)))
944
+ .finally(() => setSaving(false))
945
+ }
740
946
 
741
947
  const baseName = path.split(/[\\/]/).pop() || path
742
948
  return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
743
949
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,0.25)', fontSize: 12 } },
744
950
  React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
745
951
  React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
746
- React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, '只读'),
952
+ React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, edit ? '编辑中' : '只读'),
953
+ edit
954
+ ? React.createElement(React.Fragment, null,
955
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving }, saving ? '保存中…' : '保存到远程'),
956
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, '取消'))
957
+ : React.createElement(React.Fragment, null,
958
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, '编辑'),
959
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, '下载')),
747
960
  ),
748
961
  React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
749
962
  loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
750
963
  : err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
751
964
  : (data && data.binary
752
965
  ? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
753
- '二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 远程查看器为只读,请用 rw_download / 本地镜像查看。')
754
- : 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' } },
755
- (data && data.content != null) ? data.content : '(空文件)',
756
- (data && data.truncated)
757
- ? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
758
- : null,
759
- )),
966
+ '二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 请用「下载」或 rw_download / 本地镜像查看。')
967
+ : (edit
968
+ ? 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' } })
969
+ : 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' } },
970
+ (data && data.content != null) ? data.content : '(空文件)',
971
+ (data && data.truncated)
972
+ ? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
973
+ : null,
974
+ ))),
760
975
  ),
761
976
  )
762
977
  }
@@ -787,16 +1002,6 @@ window.__ModuleLoader__.load({
787
1002
  disposers.forEach((d) => { try { d() } catch {} })
788
1003
  return
789
1004
  }
790
- // When a remote workspace is already configured, auto-open the remote
791
- // explorer tab so the sidebar shows REMOTE files (not the local mirror
792
- // tree the built-in Files tab lists). openTab with a path seed expands
793
- // the panel and lands the tab in the active pane (the right panel);
794
- // on the single:true explorer descriptor a repeat open focuses it.
795
- //
796
- // Timing: at plugin apply there is usually no active session yet, so
797
- // openTab would be a no-op (store.reduce without a session). Subscribe
798
- // to the sidebar snapshot and open the FIRST time a session appears;
799
- // the subscription is also the cleanup channel for the auto-open fiber.
800
1005
  let autoOpened = false
801
1006
  let disposeAuto = null
802
1007
  const tryAutoOpen = () => {
@@ -837,17 +1042,6 @@ window.__ModuleLoader__.load({
837
1042
  },
838
1043
  ),
839
1044
  )
840
- // better-sidebar integration: register the remote explorer + file tabs
841
- // when dsh-better-sidebar is installed. Guarded — the plugin is optional
842
- // and everything above works without it.
843
- //
844
- // Timing: bundle order may apply dsh-remote BEFORE dsh-better-sidebar,
845
- // so `ctx.get('betterSidebar')` at apply time can be undefined. Use
846
- // ctx.inject (cordis: "run once the requested services are available",
847
- // re-run when a required service changes) so the integration registers
848
- // the moment better-sidebar provides its service; when the plugin is
849
- // never installed the fiber simply never runs — the optional integration
850
- // stays inert. ctx.effect inside keeps the disposer fiber-bound.
851
1045
  ctx.inject(['betterSidebar'], (inner) => {
852
1046
  const bsDispose = registerSidebarIntegration(inner)
853
1047
  if (bsDispose) {
@@ -860,4 +1054,4 @@ window.__ModuleLoader__.load({
860
1054
  exports.apply = apply
861
1055
  return module.exports
862
1056
  },
863
- })
1057
+ })