dsh-remote 0.7.5 → 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/README.md +47 -16
- package/lib/client.js +352 -158
- package/lib/credential.js +107 -0
- package/lib/errors.js +78 -0
- package/lib/forwards.js +219 -0
- package/lib/hostkey.js +48 -0
- package/lib/ignore.js +123 -0
- package/lib/index.js +1395 -1002
- package/lib/paths.js +169 -0
- package/lib/search.js +127 -0
- package/lib/sshconfig.js +57 -0
- package/lib/sync.js +270 -0
- package/lib/tasks.js +95 -0
- package/lib/update.js +196 -0
- package/package.json +7 -3
package/lib/client.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// dsh-remote — client half.
|
|
2
|
-
// Settings → 远程工作区:
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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({
|
|
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) => {
|
|
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
|
|
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(() => {
|
|
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
|
-
|
|
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
|
|
|
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
|
+
|
|
144
233
|
const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
145
|
-
React.createElement('label', { style: { width:
|
|
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:
|
|
236
|
+
return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 820 } },
|
|
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,6 +245,9 @@ 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) }, '删除'),
|
|
@@ -163,28 +255,92 @@ window.__ModuleLoader__.load({
|
|
|
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: {
|
|
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
|
|
172
|
-
row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '
|
|
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'),
|
|
173
292
|
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
|
|
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: () => {
|
|
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 } },
|
|
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.
|
|
366
|
+
React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v0.8.0'),
|
|
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
|
|
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) {
|
|
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(
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
668
|
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
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([])
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
}
|
|
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' : '未知大小') + ') —
|
|
754
|
-
:
|
|
755
|
-
(
|
|
756
|
-
(
|
|
757
|
-
|
|
758
|
-
|
|
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
|
+
})
|