dsh-remote 0.7.4 → 0.8.1

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,62 +67,176 @@ 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
 
132
+ // ── update state ────────────────────────────────────────────────────────
133
+ const [upd, setUpd] = React.useState(null) // { current, latest, updateAvailable, updateMode, updatedMarker }
134
+ const [updBusy, setUpdBusy] = React.useState(false)
135
+ const [updMsg, setUpdMsg] = React.useState('')
136
+ const [updMode, setUpdMode] = React.useState('manual')
137
+ const checkUpdate = (quiet) => {
138
+ if (updBusy) return
139
+ setUpdBusy(true); setUpdMsg('')
140
+ api('GET', '/dsh-remote/update-check')
141
+ .then((r) => {
142
+ if (!r || !r.ok) { setUpdMsg((r && r.error) || '检查更新失败'); return }
143
+ setUpd(r)
144
+ if (r.updateMode) setUpdMode(r.updateMode)
145
+ if (!quiet) setUpdMsg(r.updateAvailable ? '发现新版本 v' + r.latest : '已是最新版本(v' + r.current + ')')
146
+ })
147
+ .catch((e) => setUpdMsg(String((e && e.message) || e)))
148
+ .finally(() => setUpdBusy(false))
149
+ }
150
+ const applyUpdateNow = () => {
151
+ if (!upd || !upd.latest) return
152
+ if (!window.confirm('将更新 dsh-remote 到 v' + upd.latest + ',替换本机插件文件。更新后需重启 Harness 生效。继续?')) return
153
+ setUpdBusy(true); setUpdMsg('')
154
+ api('POST', '/dsh-remote/update-apply', { version: upd.latest })
155
+ .then((r) => {
156
+ if (r && r.ok) setUpdMsg('✅ 已更新到 v' + r.to + '(' + (r.from || '') + ' → ' + r.to + ')。重启 Harness 生效。')
157
+ else setUpdMsg((r && r.error) || '更新失败')
158
+ checkUpdate(true)
159
+ })
160
+ .catch((e) => setUpdMsg('更新失败: ' + String((e && e.message) || e)))
161
+ .finally(() => setUpdBusy(false))
162
+ }
163
+ const saveUpdateMode = (mode) => {
164
+ setUpdMode(mode); setUpdMsg('更新模式已设为「' + (mode === 'auto' ? '自动' : mode === 'off' ? '关闭' : '手动') + '」')
165
+ api('POST', '/dsh-remote/update-mode', { mode }).catch((e) => setUpdMsg('模式保存失败: ' + String((e && e.message) || e)))
166
+ }
167
+ React.useEffect(() => { checkUpdate(true) }, [])
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
+
88
172
  const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
89
- const startEdit = (m) => setForm({ name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root', password: '', privateKeyPath: m.privateKeyPath || '', id: m.id })
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: '' })
90
184
  const save = (action) => {
91
185
  setBusy(true); setErr(''); setMsg('')
92
186
  if (action === 'delete') {
93
- api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg('已删除') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
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))
94
188
  return
95
189
  }
96
190
  api('POST', '/dsh-remote/machines', {
97
191
  action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
98
- username: form.username, password: form.password, privateKeyPath: form.privateKeyPath,
99
- }).then((r) => { refresh(); setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
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))
100
197
  }
101
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)) }
102
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))) } }
103
200
 
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
+
104
233
  const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center' } },
105
- React.createElement('label', { style: { width: 76, fontSize: 12, opacity: 0.8 } }, label), ctrl)
234
+ React.createElement('label', { style: { width: 84, fontSize: 12, opacity: 0.8, flexShrink: 0 } }, label), ctrl)
106
235
 
107
- 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: 820 } },
108
237
  React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
109
238
  React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
110
- '维护多台 SSH 机器。路径不在设置里配置 —— 新建/选择工作区时,「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
239
+ '维护多台 SSH 机器(密码/私钥/agent/跳板机/钥匙串)。路径在新建/选择工作区时选:「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
111
240
  React.createElement('div', { style: boxS },
112
241
  React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
113
242
  machines.length
@@ -116,6 +245,9 @@ window.__ModuleLoader__.load({
116
245
  m.name + ' ',
117
246
  React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
118
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,
119
251
  m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
120
252
  React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
121
253
  React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
@@ -123,29 +255,115 @@ window.__ModuleLoader__.load({
123
255
  : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
124
256
  ),
125
257
  React.createElement('div', { style: boxS },
126
- 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,
127
266
  row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
128
267
  row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
129
268
  row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
130
269
  row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
131
- row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显、仅保存)', style: inputS }), 'w'),
132
- row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用默认 key', style: inputS }), 'k'),
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' } },
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 } },
288
+ React.createElement('input', { value: form.proxyPort, onChange: setF('proxyPort'), placeholder: '22', style: { ...inputS, width: 60 } }),
289
+ React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: '跳板用户', style: inputS }),
290
+ React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: '跳板密码', style: inputS }),
291
+ React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: '跳板私钥', style: inputS })), 'pu'),
133
292
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
134
293
  msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
135
294
  form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
136
- React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr(''); setTst('idle') } }, form.id ? '取消编辑' : '清空'),
295
+ React.createElement('button', { style: buttonS, onClick: () => { clearForm(); setErr(''); setTst('idle') } }, form.id ? '取消编辑' : '清空'),
137
296
  React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
138
297
  tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
139
298
  React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
140
299
  ),
141
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
+ ),
142
336
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
337
+ React.createElement('div', { style: boxS },
338
+ React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '更新'),
339
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 } },
340
+ upd
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)
344
+ : React.createElement('span', { style: { opacity: 0.6 } }, '版本信息加载中…'),
345
+ React.createElement('button', { style: { ...buttonS, padding: '2px 10px' }, onClick: () => checkUpdate(false), disabled: updBusy }, updBusy ? '检查中…' : '检查更新'),
346
+ upd && upd.updateAvailable
347
+ ? React.createElement('button', { style: { ...buttonS, padding: '2px 10px', fontWeight: 700, color: T.ok }, onClick: applyUpdateNow, disabled: updBusy }, '立即更新')
348
+ : null,
349
+ ),
350
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
351
+ React.createElement('span', { style: { opacity: 0.8 } }, '更新模式:'),
352
+ ['manual', 'auto', 'off'].map((m) =>
353
+ React.createElement('button', {
354
+ key: m, style: { ...buttonS, padding: '2px 10px', fontWeight: updMode === m ? 700 : 400, color: updMode === m ? T.ok : 'inherit' },
355
+ onClick: () => saveUpdateMode(m), disabled: updBusy,
356
+ }, m === 'manual' ? '手动' : m === 'auto' ? '自动' : '关闭'),
357
+ ),
358
+ ),
359
+ updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') || updMsg.startsWith('已是最新') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
360
+ ),
143
361
  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 } },
144
362
  React.createElement('span', {}, '觉得好用?'),
145
363
  React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
146
364
  React.createElement('span', {}, '·'),
147
365
  React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
148
- React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v0.7.4'),
366
+ React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v0.8.0'),
149
367
  ),
150
368
  )
151
369
  }
@@ -157,8 +375,6 @@ window.__ModuleLoader__.load({
157
375
  const t = ln.trim()
158
376
  if (!t || !t.length || /^total\b/i.test(t)) continue
159
377
  const parts = t.split(/\s+/).filter(Boolean)
160
- // ls -l row: mode links owner group size month day [hh:mm|year] name...
161
- // NAME starts at column 8 (everything before it is the fixed metadata).
162
378
  if (parts.length < 8) continue
163
379
  const mode = parts[0]
164
380
  const name = parts.slice(8).join(' ')
@@ -169,8 +385,7 @@ window.__ModuleLoader__.load({
169
385
  return out
170
386
  }
171
387
 
172
- // Clickable breadcrumb of the current remote path; clicking a segment jumps
173
- // to that ancestor level (finite-width, one line, ellipsized at the front).
388
+ // Clickable breadcrumb of the current remote path.
174
389
  function breadcrumb(active, cur, jumpTo) {
175
390
  const norm = String(cur || '').replace(/\/+$/, '')
176
391
  const segs = norm === '' || norm === '/' ? [] : norm.split('/')
@@ -200,21 +415,19 @@ window.__ModuleLoader__.load({
200
415
  const [items, setItems] = React.useState(null)
201
416
  const [err, setErr] = React.useState('')
202
417
  const [loading, setLoading] = React.useState(false)
203
- // 级联下钻状态:每一格是 { path, dirs } —— dirs 是该路径下的目录列表。
204
418
  const [levels, setLevels] = React.useState(null)
205
419
  const [popOpen, setPopOpen] = React.useState(false)
206
420
  const [suggest, setSuggest] = React.useState([])
207
421
  const [suggestOpen, setSuggestOpen] = React.useState(false)
422
+ const [recent, setRecent] = React.useState([])
208
423
  const suggestTimer = React.useRef(null)
209
424
  const suggestRef = React.useRef(null)
210
- // 点击自动补全下拉外部的空白处时收起补全列表(上游 0.5.10)
211
425
  React.useEffect(() => {
212
426
  if (!suggestOpen) return
213
427
  const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
214
428
  document.addEventListener('mousedown', onDown)
215
429
  return () => document.removeEventListener('mousedown', onDown)
216
430
  }, [suggestOpen])
217
- // 机器下拉(自绘 dropdown):点外部任意处关闭,避免候选层常驻遮挡下方按钮(上游 0.5.8)
218
431
  const [mOpen, setMOpen] = React.useState(false)
219
432
  const ddRef = React.useRef(null)
220
433
  React.useEffect(() => {
@@ -224,6 +437,14 @@ window.__ModuleLoader__.load({
224
437
  return () => document.removeEventListener('mousedown', onDown)
225
438
  }, [mOpen])
226
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
+
227
448
  const loadLevels = (id, p, toIndex) => {
228
449
  if (!id) return
229
450
  setLoading(true); setErr('')
@@ -231,15 +452,12 @@ window.__ModuleLoader__.load({
231
452
  .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
232
453
  .then((res) => {
233
454
  const real = res && res.path ? res.path : (p || '')
234
- // server returns [{ type:'dir'|'file', name }]; entering a dir is
235
- // decided by type dir (server already follows symlinks to dirs).
236
455
  const list = Array.isArray(res && res.items)
237
- ? 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 }))
238
457
  : parseLs(res && res.text)
239
- 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) }
240
459
  setLevels((prev) => {
241
460
  const base = prev && prev.length ? prev.slice() : []
242
- // toIndex >= 0 → put this node at that position, truncating below it.
243
461
  let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
244
462
  if (idx >= base.length) return base.concat([node])
245
463
  base[idx] = node
@@ -250,7 +468,14 @@ window.__ModuleLoader__.load({
250
468
  .finally(() => setLoading(false))
251
469
  }
252
470
 
253
- 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])
254
479
 
255
480
  const chooseLocal = () => {
256
481
  setLoading(true); setErr('')
@@ -268,14 +493,10 @@ window.__ModuleLoader__.load({
268
493
  setTab(t); setErr('')
269
494
  if (t === 'remote') {
270
495
  if (machineId) loadLevels(machineId, '', 0)
271
- // Prime the field with the machine's root so the user immediately gets
272
- // a '/'-level completion list without typing anything.
273
496
  if (!path.trim()) { setPath('/'); loadSuggestions('/') }
274
497
  }
275
498
  }
276
499
 
277
- // enterDir(name): drive into the named subdir of the current deepest level,
278
- // appending that directory as the new deepest level (path is pieced together).
279
500
  const enterDir = (name) => {
280
501
  if (busy || loading) return
281
502
  const last = levels && levels.length ? levels[levels.length - 1] : null
@@ -285,14 +506,11 @@ window.__ModuleLoader__.load({
285
506
  loadLevels(machineId, next, (levels ? levels.length : 0))
286
507
  }
287
508
 
288
- // Autocomplete: given a partial remote path, list children of its parent dir
289
- // that start with the last segment (powered by the structured ls endpoint).
290
509
  const loadSuggestions = (raw, mid) => {
291
510
  const id = mid || machineId
292
511
  if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
293
512
  const t = String(raw || '').trim()
294
513
  if (!t) { setSuggest([]); setSuggestOpen(false); return }
295
- // Windows drive path (D:\...) uses backslashes; POSIX uses '/'.
296
514
  const isWin = t.includes('\\')
297
515
  const sep = isWin ? '\\' : '/'
298
516
  const slash = t.lastIndexOf(sep)
@@ -318,8 +536,6 @@ window.__ModuleLoader__.load({
318
536
  suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
319
537
  }
320
538
 
321
- // After a directory is chosen, immediately reveal the next level: list the
322
- // chosen directory's children as fresh completions (no keystroke needed).
323
539
  const continueSuggest = (dir) => {
324
540
  if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
325
541
  setSuggestOpen(false)
@@ -337,13 +553,11 @@ window.__ModuleLoader__.load({
337
553
  }).catch(() => { setSuggest([]); setSuggestOpen(false) })
338
554
  }
339
555
 
340
- // Choose a suggestion: fill the field and immediately open its next level.
341
556
  const selectSuggestion = (s) => {
342
557
  setPath(s); setErr(''); setSuggestOpen(false)
343
558
  continueSuggest(s)
344
559
  }
345
560
 
346
- // Commit an explicit remote path as the workspace (race from the input).
347
561
  const commitPath = (p) => {
348
562
  const target = String(p || '').trim()
349
563
  if (!target || !machineId || busy) return
@@ -351,27 +565,42 @@ window.__ModuleLoader__.load({
351
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)))
352
566
  }
353
567
 
354
- // Confirm the highlighted directory from the browser popup by filling the
355
- // path input (not committing), so the user can review/edit before commit.
356
568
  const acceptBrowserPick = (p) => {
357
569
  setPath(String(p || ''))
358
570
  setSuggestOpen(false)
359
571
  setPopOpen(false)
360
572
  }
361
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
+
362
593
  function renderDirPopup() {
363
594
  if (!levels || !levels.length) {
364
595
  return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
365
596
  }
366
597
  const last = levels[levels.length - 1]
367
598
  const entries = last.all || []
368
- // Inline panel anchored inside the dialog (NOT a full-viewport overlay):
369
- // a height-capped box whose directory list scrolls internally, so the
370
- // dialog's own confirm row ("设为远程工作区") stays visible below it.
371
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' } },
372
600
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
373
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 }, '回上一级 ▴'),
374
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 }, '新建目录'),
375
604
  React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
376
605
  ),
377
606
  React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
@@ -382,7 +611,8 @@ window.__ModuleLoader__.load({
382
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 },
383
612
  },
384
613
  React.createElement('span', null, it.dir ? '📁' : '📄'),
385
- 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) : ''))),
386
616
  )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
387
617
  ),
388
618
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
@@ -394,11 +624,8 @@ window.__ModuleLoader__.load({
394
624
 
395
625
  if (!open) return null
396
626
  const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
397
- // The picker is a full-viewport centered modal (backdrop + panel), so it
398
- // renders identically in the narrow sidebar and in the conversation and
399
- // is never squeezed into a cramped in-place column.
400
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() } },
401
- 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() },
402
629
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
403
630
  React.createElement('div', null, '选择工作目录'),
404
631
  React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
@@ -427,29 +654,28 @@ window.__ModuleLoader__.load({
427
654
  mOpen
428
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 } },
429
656
  machines.length
430
- ? 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 + ')'))
431
658
  : React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
432
659
  )
433
660
  : null,
434
661
  ),
435
662
  ),
436
- // 路径输入框(带自动补全)+ 打开浏览弹层按钮
437
- // suggestRef 外层仍用于「点击外部关闭」判定;输入行与补全列表
438
- // 是两级 div —— 补全列表以流式(非绝对定位)展开,确认按钮被
439
- // 推到下方而不会被遮挡(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,
440
667
  React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
441
668
  React.createElement('div', { style: { display: 'flex', gap: 6 } },
442
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 } }),
443
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 }, '~ 主目录'),
444
672
  ),
445
- // 自动补全下拉(流式展开,参与文档流,不覆盖确认按钮)
446
673
  (suggestOpen && suggest.length)
447
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)' } },
448
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)),
449
676
  )
450
677
  : null,
451
678
  ),
452
- // 目录浏览面板(内联,锚定在对话框内;确认按钮保持可见)
453
679
  popOpen ? renderDirPopup() : null,
454
680
  err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
455
681
  React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
@@ -468,50 +694,25 @@ window.__ModuleLoader__.load({
468
694
  }
469
695
 
470
696
  // ── better-sidebar integration (optional) ────────────────────────────────
471
- // The sidebar of dsh-better-sidebar lists LOCAL files (its fs tree reads
472
- // the host filesystem). When dsh-remote is the active remote workspace the
473
- // user wants to browse the REMOTE host's files live — not the local SFTP
474
- // mirror snapshot (which rw_sync caps by depth/file-count/size).
475
- //
476
- // Through the public `ctx.betterSidebar` service (v0.4.0+; gracefully
477
- // skipped when dsh-better-sidebar is not installed) we register TWO tabs:
478
- // • `dsh-remote:explorer` — a live remote file tree (dirs expand over
479
- // /dsh-remote/ls; every click lists the REMOTE host, not the mirror),
480
- // • `dsh-remote:file` — a read-only remote file view (hidden from the
481
- // + menu; opened by the explorer) that reads the file live over
482
- // /dsh-remote/read and renders its text (or a binary notice).
483
- //
484
- // Both are READ-ONLY on purpose: the sidebar editor's save path writes to
485
- // the LOCAL fs, so an editable remote file would silently save into the
486
- // mirror. Editing stays in the rw_* tools / the mirror workflow. We do NOT
487
- // register a file viewer (better-sidebar's matching is extension/priority
488
- // based and cannot tell a remote path from a local one — a catch-all
489
- // viewer would hijack every local file open).
490
-
491
- // A stable id namespace so the persisted sidebar tabs survive reloads.
492
697
  const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
493
698
  const SIDEBAR_FILE_ID = 'dsh-remote:file'
494
699
 
495
- /** Remote workspace state the sidebar reads (host /dsh-remote/status). */
496
700
  function fetchRemoteStatus() {
497
701
  return api('GET', '/dsh-remote/status').catch(() => null)
498
702
  }
499
703
 
500
- /** Read a remote file (live) → {binary, content?, head?, size?, truncated?}. */
501
704
  function readRemoteFile(path, maxBytes) {
502
705
  return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
503
706
  throw new Error('远程读取失败: ' + ((e && e.message) || e))
504
707
  })
505
708
  }
506
709
 
507
- /** List a remote dir (live) → items [{name, type}] (server-driven types). */
508
710
  function listRemoteDir(path) {
509
711
  return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
510
712
  throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
511
713
  })
512
714
  }
513
715
 
514
- /** Join a remote dir + entry name, honoring the dir's separator style. */
515
716
  function joinRemote(base, name) {
516
717
  if (!base) return name
517
718
  return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
@@ -520,19 +721,18 @@ window.__ModuleLoader__.load({
520
721
  // ── Remote explorer tab ────────────────────────────────────────────────
521
722
  function RemoteExplorerTab(props) {
522
723
  const [status, setStatus] = React.useState(null)
523
- const [levels, setLevels] = React.useState([]) // [{path, items}]
724
+ const [levels, setLevels] = React.useState([])
524
725
  const [err, setErr] = React.useState('')
525
726
  const [loading, setLoading] = React.useState(false)
526
727
  const [picker, setPicker] = React.useState(false)
728
+ const [menu, setMenu] = React.useState(null) // { x, y, item }
729
+ const [busy, setBusy] = React.useState('')
527
730
 
528
731
  const refreshStatus = () => {
529
732
  fetchRemoteStatus().then((s) => {
530
733
  setStatus(s)
531
734
  if (s && s.workspace) {
532
735
  setLevels((prev) => {
533
- // First open with a workspace: seed the root level AND load it,
534
- // so the tree shows the remote listing immediately instead of
535
- // an empty "(空目录)" until the user hits ↻.
536
736
  if (!prev.length) {
537
737
  loadDir(s.workspace, 0)
538
738
  return [{ path: s.workspace, items: null }]
@@ -543,18 +743,27 @@ window.__ModuleLoader__.load({
543
743
  })
544
744
  }
545
745
  React.useEffect(() => { refreshStatus() }, [])
546
- // When the tab becomes visible, re-verify the workspace still exists
547
- // (the remote connection may have been switched in settings).
548
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])
549
753
 
550
754
  const loadDir = (p, idx) => {
551
755
  if (loading) return
552
756
  setLoading(true); setErr('')
553
757
  listRemoteDir(p).then((res) => {
554
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
+ })
555
764
  setLevels((prev) => {
556
765
  const base = prev.length ? prev.slice() : [{ path: p, items: null }]
557
- 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 }))
558
767
  if (idx < 0 || idx >= base.length) return base.concat([{ path: p, items: list }])
559
768
  base[idx] = { path: p, items: list }
560
769
  return base.slice(0, idx + 1)
@@ -563,7 +772,6 @@ window.__ModuleLoader__.load({
563
772
  .finally(() => setLoading(false))
564
773
  }
565
774
 
566
- // Open a directory: append it as the new deepest level.
567
775
  const enterDir = (name) => {
568
776
  const last = levels.length ? levels[levels.length - 1] : null
569
777
  if (!last || !last.items) return
@@ -572,7 +780,6 @@ window.__ModuleLoader__.load({
572
780
  const next = base === '/' ? '/' + name : base + sep + name
573
781
  loadDir(next, levels.length)
574
782
  }
575
- // Jump to a breadcrumb ancestor.
576
783
  const jumpTo = (p) => {
577
784
  // Breadcrumb segments may lack the leading separator (built by
578
785
  // splitting the absolute path), so normalize to the absolute form
@@ -582,36 +789,60 @@ window.__ModuleLoader__.load({
582
789
  if (idx < 0) return
583
790
  setLevels(levels.slice(0, idx + 1))
584
791
  }
585
- // Open a file: open the dedicated REMOTE file tab (reads the file live
586
- // over /dsh-remote/read) — NOT the builtin editor, whose fs.read would
587
- // hit the local filesystem and fail on a remote path.
588
792
  const openFile = (p) => {
589
793
  const name = String(p).split(/[\\/]/).pop() || p
590
794
  props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
591
795
  }
592
796
 
593
- 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
+ }
594
813
  const curPath = levels.length ? levels[levels.length - 1].path : ''
595
- const segs = String(curPath || '').split(/[\\/]+/).filter(Boolean)
596
- 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([]) } }, '⌂')]
597
- let acc = ''
598
- segs.forEach((s, i) => {
599
- acc = acc ? acc + '/' + s : s
600
- const isLast = i === segs.length - 1
601
- 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))
602
- })
814
+ const menuFull = menu ? joinRemote(curPath, menu.item.name) : ''
603
815
 
604
816
  const row = (it, i) => React.createElement('div', {
605
817
  key: it.name + '|' + i,
606
818
  title: (it.type === 'dir' ? '进入目录 ' : '打开文件 ') + it.name,
607
819
  onClick: () => (it.type === 'dir' ? enterDir(it.name) : openFile(joinRemote(curPath, it.name))),
820
+ onContextMenu: (e) => onCtx(e, it),
608
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)' },
609
- }, 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)
610
826
 
611
827
  const rootHint = status && status.workspace
612
828
  ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + status.workspace + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
613
829
  : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区')
614
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
+
615
846
  // Explicit "up one level" button: the breadcrumb line ellipsizes and
616
847
  // its segments may be hard to hit in a narrow sidebar, so give the
617
848
  // user a direct way back to the parent level.
@@ -630,13 +861,13 @@ window.__ModuleLoader__.load({
630
861
  title: '返回上一级目录',
631
862
  }, '↑ 上一级'),
632
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()) }) } }, '新建目录'),
633
865
  React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
634
866
  ),
635
867
  rootHint,
636
868
  picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
637
869
  open: true, busy: false, onPicked: (localMirror) => {
638
870
  setPicker(false)
639
- // The picker commits a mirror + workspace; refresh the tree.
640
871
  refreshStatus()
641
872
  fetchRemoteStatus().then((s) => { if (s && s.workspace) { setLevels([{ path: s.workspace, items: null }]); loadDir(s.workspace, 0) } })
642
873
  }, onCancel: () => setPicker(false),
@@ -647,54 +878,100 @@ window.__ModuleLoader__.load({
647
878
  : (curPath ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '(空目录)')
648
879
  : React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区'))),
649
880
  ),
650
- 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,
651
894
  )
652
895
  }
653
896
 
654
- // ── Remote file tab (opened by the explorer; reads the file live) ──────
655
- // A dedicated tab type instead of a file viewer: better-sidebar's viewer
656
- // matching is extension/priority-based and cannot tell a REMOTE path from
657
- // a LOCAL one (a catch-all viewer would hijack every local file open and
658
- // then fail its local fs.read). Our own tab carries the remote path and
659
- // reads it straight over /dsh-remote/read — read-only by design (the
660
- // sidebar editor's save writes to the LOCAL fs, so an editable remote
661
- // file would silently save into the mirror).
897
+ // ── Remote file tab (opened by the explorer; live read + explicit edit) ──
662
898
  function RemoteFileTab(props) {
663
899
  const { tab, scope } = props
664
900
  const path = tab.path || ''
665
901
  const [data, setData] = React.useState(null)
666
902
  const [err, setErr] = React.useState('')
667
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)
668
907
 
669
- React.useEffect(() => {
908
+ const load = () => {
670
909
  if (!path) { setLoading(false); return }
671
910
  let cancelled = false
672
- setLoading(true); setErr(''); setData(null)
911
+ setLoading(true); setErr(''); setData(null); setEdit(false)
673
912
  readRemoteFile(path, 256 * 1024)
674
913
  .then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
675
914
  .catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
676
915
  return () => { cancelled = true }
677
- }, [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
+ }
678
946
 
679
947
  const baseName = path.split(/[\\/]/).pop() || path
680
948
  return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
681
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 } },
682
950
  React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
683
951
  React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
684
- 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 }, '下载')),
685
960
  ),
686
961
  React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
687
962
  loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
688
963
  : err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
689
964
  : (data && data.binary
690
965
  ? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
691
- '二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 远程查看器为只读,请用 rw_download / 本地镜像查看。')
692
- : 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' } },
693
- (data && data.content != null) ? data.content : '(空文件)',
694
- (data && data.truncated)
695
- ? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
696
- : null,
697
- )),
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
+ ))),
698
975
  ),
699
976
  )
700
977
  }
@@ -725,16 +1002,6 @@ window.__ModuleLoader__.load({
725
1002
  disposers.forEach((d) => { try { d() } catch {} })
726
1003
  return
727
1004
  }
728
- // When a remote workspace is already configured, auto-open the remote
729
- // explorer tab so the sidebar shows REMOTE files (not the local mirror
730
- // tree the built-in Files tab lists). openTab with a path seed expands
731
- // the panel and lands the tab in the active pane (the right panel);
732
- // on the single:true explorer descriptor a repeat open focuses it.
733
- //
734
- // Timing: at plugin apply there is usually no active session yet, so
735
- // openTab would be a no-op (store.reduce without a session). Subscribe
736
- // to the sidebar snapshot and open the FIRST time a session appears;
737
- // the subscription is also the cleanup channel for the auto-open fiber.
738
1005
  let autoOpened = false
739
1006
  let disposeAuto = null
740
1007
  const tryAutoOpen = () => {
@@ -775,17 +1042,6 @@ window.__ModuleLoader__.load({
775
1042
  },
776
1043
  ),
777
1044
  )
778
- // better-sidebar integration: register the remote explorer + file tabs
779
- // when dsh-better-sidebar is installed. Guarded — the plugin is optional
780
- // and everything above works without it.
781
- //
782
- // Timing: bundle order may apply dsh-remote BEFORE dsh-better-sidebar,
783
- // so `ctx.get('betterSidebar')` at apply time can be undefined. Use
784
- // ctx.inject (cordis: "run once the requested services are available",
785
- // re-run when a required service changes) so the integration registers
786
- // the moment better-sidebar provides its service; when the plugin is
787
- // never installed the fiber simply never runs — the optional integration
788
- // stays inert. ctx.effect inside keeps the disposer fiber-bound.
789
1045
  ctx.inject(['betterSidebar'], (inner) => {
790
1046
  const bsDispose = registerSidebarIntegration(inner)
791
1047
  if (bsDispose) {
@@ -798,4 +1054,4 @@ window.__ModuleLoader__.load({
798
1054
  exports.apply = apply
799
1055
  return module.exports
800
1056
  },
801
- })
1057
+ })