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/README.md +47 -16
- package/lib/client.js +408 -152
- 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 +1458 -841
- 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,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({
|
|
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
|
|
|
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
|
|
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(() => {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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: {
|
|
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
|
|
132
|
-
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'),
|
|
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: () => {
|
|
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.
|
|
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
|
|
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) {
|
|
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(
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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([])
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
},
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
}
|
|
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' : '未知大小') + ') —
|
|
692
|
-
:
|
|
693
|
-
(
|
|
694
|
-
(
|
|
695
|
-
|
|
696
|
-
|
|
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
|
+
})
|