dsh-remote 0.7.1 → 0.7.3
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/LICENSE +21 -21
- package/README.md +189 -174
- package/README.zh.md +178 -165
- package/cordis.patch.yml +24 -6
- package/lib/client.js +788 -770
- package/lib/index.js +1810 -1736
- package/package.json +62 -61
package/lib/client.js
CHANGED
|
@@ -1,771 +1,789 @@
|
|
|
1
|
-
// dsh-remote — client half.
|
|
2
|
-
// Settings → 远程工作区: a multi-machine SSH registry (add/edit/select current;
|
|
3
|
-
// password stored host-side and kept private). No workspace path here — the
|
|
4
|
-
// path is picked at workspace-add time.
|
|
5
|
-
//
|
|
6
|
-
// A unified workspace directory picker fills ui-workspace's two directory-flow
|
|
7
|
-
// holes (sidebar + conversation hero):
|
|
8
|
-
// • 本机 tab → opens the NATIVE OS folder chooser (ctx.workspaces.pickDirectory)
|
|
9
|
-
// and returns the picked local path (works with local workspaces).
|
|
10
|
-
// • 远程 tab → pick a machine (dropdown), list its directories over
|
|
11
|
-
// /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
|
|
12
|
-
// real LOCAL mirror → onPicked(localMirror) so host adopts it as a workspace.
|
|
13
|
-
//
|
|
14
|
-
// Client entries must be classic scripts registered via window.__ModuleLoader__.load
|
|
15
|
-
// ({ id, factory }); the factory receives a synchronous `require`.
|
|
16
|
-
window.__ModuleLoader__.load({
|
|
17
|
-
id: 'dsh-remote',
|
|
18
|
-
factory: (require) => {
|
|
19
|
-
var module = { exports: {} }
|
|
20
|
-
var exports = module.exports
|
|
21
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
22
|
-
|
|
23
|
-
const React = require('react')
|
|
24
|
-
const name = 'dsh-remote'
|
|
25
|
-
|
|
26
|
-
async function api(method, path, body) {
|
|
27
|
-
const opts = { method, headers: {} }
|
|
28
|
-
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
|
|
29
|
-
const res = await fetch(path, opts)
|
|
30
|
-
const data = await res.json().catch(() => ({}))
|
|
31
|
-
if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
|
|
32
|
-
return data
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Theme via DSH design tokens so this follows the harness light/dark theme.
|
|
36
|
-
const v = (name, fb) => `var(${name}, ${fb})`
|
|
37
|
-
const T = {
|
|
38
|
-
bg: v('--dsw-alias-bg-layer-1', 'rgba(128,128,128,0.07)'),
|
|
39
|
-
bg2: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.10)'),
|
|
40
|
-
border: v('--dsw-alias-border-l2', 'rgba(128,128,128,0.35)'),
|
|
41
|
-
borderStrong: v('--dsw-alias-border-l3', 'rgba(128,128,128,0.5)'),
|
|
42
|
-
danger: v('--dsw-static-red-500', '#e06c75'),
|
|
43
|
-
dangerText: v('--dsw-static-red-400', '#e06c75'),
|
|
44
|
-
ok: v('--dsw-static-green-500', '#4caf7d'),
|
|
45
|
-
radius: 8,
|
|
46
|
-
muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
|
|
47
|
-
label: v('--dsw-alias-label-primary', '#e4e4e7'),
|
|
48
|
-
primary: v('--dsw-alias-button-primary-fill', '#2563eb'),
|
|
49
|
-
onPrimary: v('--dsw-alias-button-contrast-fill', '#fff'),
|
|
50
|
-
hoverBg: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.14)'),
|
|
51
|
-
}
|
|
52
|
-
const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
|
|
53
|
-
const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
|
|
54
|
-
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
|
-
const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
|
|
57
|
-
const boxS = box
|
|
58
|
-
|
|
59
|
-
let WORKSPACES = null // ctx.get('workspaces'), set in apply()
|
|
60
|
-
|
|
61
|
-
// ── Settings → 远程工作区 (machine registry) ─────────────────────────────
|
|
62
|
-
function RemoteWorkspacePage() {
|
|
63
|
-
const [machines, setMachines] = React.useState([])
|
|
64
|
-
const [currentId, setCurrentId] = React.useState('')
|
|
65
|
-
const [form, setForm] = React.useState({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' })
|
|
66
|
-
const [busy, setBusy] = React.useState(false)
|
|
67
|
-
const [msg, setMsg] = React.useState('')
|
|
68
|
-
const [err, setErr] = React.useState('')
|
|
69
|
-
const [tst, setTst] = React.useState('') // idle | testing | ok | failing
|
|
70
|
-
|
|
71
|
-
const testConn = () => {
|
|
72
|
-
if (!form.host.trim() || tst === 'testing') return
|
|
73
|
-
setTst('testing'); setErr(''); setMsg('')
|
|
74
|
-
api('POST', '/dsh-remote/test-connect', {
|
|
75
|
-
host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
|
|
76
|
-
password: form.password, privateKeyPath: form.privateKeyPath,
|
|
77
|
-
})
|
|
78
|
-
.then((r) => {
|
|
79
|
-
if (r && r.ok) { setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)` : '连接成功') }
|
|
80
|
-
else { setTst('failing'); setErr((r && r.error) || '连接失败') }
|
|
81
|
-
})
|
|
82
|
-
.catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const refresh = () => api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setCurrentId(r.currentId || '') })
|
|
86
|
-
React.useEffect(() => { refresh() }, [])
|
|
87
|
-
|
|
88
|
-
const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
|
|
89
|
-
const startEdit = (m) => setForm({ name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root', password: '', privateKeyPath: m.privateKeyPath || '', id: m.id })
|
|
90
|
-
const save = (action) => {
|
|
91
|
-
setBusy(true); setErr(''); setMsg('')
|
|
92
|
-
if (action === 'delete') {
|
|
93
|
-
api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg('已删除') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
api('POST', '/dsh-remote/machines', {
|
|
97
|
-
action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
|
|
98
|
-
username: form.username, password: form.password, privateKeyPath: form.privateKeyPath,
|
|
99
|
-
}).then((r) => { refresh(); setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
100
|
-
}
|
|
101
|
-
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
|
-
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
|
-
|
|
104
|
-
const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
105
|
-
React.createElement('label', { style: { width: 76, fontSize: 12, opacity: 0.8 } }, label), ctrl)
|
|
106
|
-
|
|
107
|
-
return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 760 } },
|
|
108
|
-
React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
|
|
109
|
-
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
|
|
110
|
-
'维护多台 SSH 机器。路径不在设置里配置 —— 新建/选择工作区时,「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
|
|
111
|
-
React.createElement('div', { style: boxS },
|
|
112
|
-
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
|
|
113
|
-
machines.length
|
|
114
|
-
? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
|
|
115
|
-
React.createElement('div', { style: { flex: 1, fontSize: 13 } },
|
|
116
|
-
m.name + ' ',
|
|
117
|
-
React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
|
|
118
|
-
m.passwordSet ? ' 🔒' : '',
|
|
119
|
-
m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
|
|
120
|
-
React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
|
|
121
|
-
React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
|
|
122
|
-
React.createElement('button', { style: buttonS, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
|
|
123
|
-
: React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
|
|
124
|
-
),
|
|
125
|
-
React.createElement('div', { style: boxS },
|
|
126
|
-
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
|
|
127
|
-
row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
|
|
128
|
-
row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
|
|
129
|
-
row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
|
|
130
|
-
row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
|
|
131
|
-
row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显、仅保存)', style: inputS }), 'w'),
|
|
132
|
-
row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用默认 key', style: inputS }), 'k'),
|
|
133
|
-
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
|
|
134
|
-
msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
|
|
135
|
-
form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
|
|
136
|
-
React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr(''); setTst('idle') } }, form.id ? '取消编辑' : '清空'),
|
|
137
|
-
React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
|
|
138
|
-
tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
|
|
139
|
-
React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
|
|
140
|
-
),
|
|
141
|
-
),
|
|
142
|
-
err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
|
|
143
|
-
)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ── Unified picker (fill the directory-flow holes) ───────────────────
|
|
147
|
-
function parseLs(text) {
|
|
148
|
-
const out = []
|
|
149
|
-
for (const ln of String(text || '').split('\n')) {
|
|
150
|
-
const t = ln.trim()
|
|
151
|
-
if (!t || !t.length || /^total\b/i.test(t)) continue
|
|
152
|
-
const parts = t.split(/\s+/).filter(Boolean)
|
|
153
|
-
// ls -l row: mode links owner group size month day [hh:mm|year] name...
|
|
154
|
-
// NAME starts at column 8 (everything before it is the fixed metadata).
|
|
155
|
-
if (parts.length < 8) continue
|
|
156
|
-
const mode = parts[0]
|
|
157
|
-
const name = parts.slice(8).join(' ')
|
|
158
|
-
if (!name || name === '.' || name === '..') continue
|
|
159
|
-
const isDir = mode.charAt(0) === 'd'
|
|
160
|
-
out.push({ name, dir: isDir })
|
|
161
|
-
}
|
|
162
|
-
return out
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Clickable breadcrumb of the current remote path; clicking a segment jumps
|
|
166
|
-
// to that ancestor level (finite-width, one line, ellipsized at the front).
|
|
167
|
-
function breadcrumb(active, cur, jumpTo) {
|
|
168
|
-
const norm = String(cur || '').replace(/\/+$/, '')
|
|
169
|
-
const segs = norm === '' || norm === '/' ? [] : norm.split('/')
|
|
170
|
-
const crumbs = []
|
|
171
|
-
crumbs.push(React.createElement('span', { key: 'root', style: { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted }, onClick: active ? () => jumpTo('/') : undefined }, '/'))
|
|
172
|
-
let acc = ''
|
|
173
|
-
for (const s of segs) {
|
|
174
|
-
if (!s) continue
|
|
175
|
-
acc += '/' + s
|
|
176
|
-
crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
|
|
177
|
-
crumbs.push(React.createElement('span', {
|
|
178
|
-
key: acc,
|
|
179
|
-
style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === norm ? 700 : 400, whiteSpace: 'nowrap' },
|
|
180
|
-
onClick: active ? () => jumpTo(acc) : undefined,
|
|
181
|
-
}, s))
|
|
182
|
-
}
|
|
183
|
-
if (!crumbs.length) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, '/'))
|
|
184
|
-
return React.createElement('span', { key: 'crumb' }, crumbs)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function DirPicker(props) {
|
|
188
|
-
const { open, busy, onPicked, onCancel } = props
|
|
189
|
-
const [tab, setTab] = React.useState('local')
|
|
190
|
-
const [machines, setMachines] = React.useState([])
|
|
191
|
-
const [machineId, setMachineId] = React.useState('')
|
|
192
|
-
const [path, setPath] = React.useState('')
|
|
193
|
-
const [items, setItems] = React.useState(null)
|
|
194
|
-
const [err, setErr] = React.useState('')
|
|
195
|
-
const [loading, setLoading] = React.useState(false)
|
|
196
|
-
// 级联下钻状态:每一格是 { path, dirs } —— dirs 是该路径下的目录列表。
|
|
197
|
-
const [levels, setLevels] = React.useState(null)
|
|
198
|
-
const [popOpen, setPopOpen] = React.useState(false)
|
|
199
|
-
const [suggest, setSuggest] = React.useState([])
|
|
200
|
-
const [suggestOpen, setSuggestOpen] = React.useState(false)
|
|
201
|
-
const suggestTimer = React.useRef(null)
|
|
202
|
-
const suggestRef = React.useRef(null)
|
|
203
|
-
// 点击自动补全下拉外部的空白处时收起补全列表(上游 0.5.10)
|
|
204
|
-
React.useEffect(() => {
|
|
205
|
-
if (!suggestOpen) return
|
|
206
|
-
const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
|
|
207
|
-
document.addEventListener('mousedown', onDown)
|
|
208
|
-
return () => document.removeEventListener('mousedown', onDown)
|
|
209
|
-
}, [suggestOpen])
|
|
210
|
-
// 机器下拉(自绘 dropdown):点外部任意处关闭,避免候选层常驻遮挡下方按钮(上游 0.5.8)
|
|
211
|
-
const [mOpen, setMOpen] = React.useState(false)
|
|
212
|
-
const ddRef = React.useRef(null)
|
|
213
|
-
React.useEffect(() => {
|
|
214
|
-
if (!mOpen) return
|
|
215
|
-
const onDown = (ev) => { if (ddRef.current && !ddRef.current.contains(ev.target)) setMOpen(false) }
|
|
216
|
-
document.addEventListener('mousedown', onDown)
|
|
217
|
-
return () => document.removeEventListener('mousedown', onDown)
|
|
218
|
-
}, [mOpen])
|
|
219
|
-
|
|
220
|
-
const loadLevels = (id, p, toIndex) => {
|
|
221
|
-
if (!id) return
|
|
222
|
-
setLoading(true); setErr('')
|
|
223
|
-
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
224
|
-
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
|
|
225
|
-
.then((res) => {
|
|
226
|
-
const real = res && res.path ? res.path : (p || '')
|
|
227
|
-
// server returns [{ type:'dir'|'file', name }]; entering a dir is
|
|
228
|
-
// decided by type dir (server already follows symlinks to dirs).
|
|
229
|
-
const list = Array.isArray(res && res.items)
|
|
230
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
231
|
-
: parseLs(res && res.text)
|
|
232
|
-
const node = { path: real, dirs: list.filter((it) => it.dir), all: list }
|
|
233
|
-
setLevels((prev) => {
|
|
234
|
-
const base = prev && prev.length ? prev.slice() : []
|
|
235
|
-
// toIndex >= 0 → put this node at that position, truncating below it.
|
|
236
|
-
let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
|
|
237
|
-
if (idx >= base.length) return base.concat([node])
|
|
238
|
-
base[idx] = node
|
|
239
|
-
return base.slice(0, idx + 1)
|
|
240
|
-
})
|
|
241
|
-
})
|
|
242
|
-
.catch((e) => setErr(String((e && e.message) || e)))
|
|
243
|
-
.finally(() => setLoading(false))
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
React.useEffect(() => { if (open) { api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '') }) } }, [open])
|
|
247
|
-
|
|
248
|
-
const chooseLocal = () => {
|
|
249
|
-
setLoading(true); setErr('')
|
|
250
|
-
api('POST', '/dsh-remote/local-pick')
|
|
251
|
-
.then((r) => {
|
|
252
|
-
if (r && r.path) onPicked(String(r.path))
|
|
253
|
-
else if (r && r.cancelled) setErr('已取消选择')
|
|
254
|
-
else setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径')
|
|
255
|
-
})
|
|
256
|
-
.catch((e) => setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径'))
|
|
257
|
-
.finally(() => setLoading(false))
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const switchTab = (t) => {
|
|
261
|
-
setTab(t); setErr('')
|
|
262
|
-
if (t === 'remote') {
|
|
263
|
-
if (machineId) loadLevels(machineId, '', 0)
|
|
264
|
-
// Prime the field with the machine's root so the user immediately gets
|
|
265
|
-
// a '/'-level completion list without typing anything.
|
|
266
|
-
if (!path.trim()) { setPath('/'); loadSuggestions('/') }
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// enterDir(name): drive into the named subdir of the current deepest level,
|
|
271
|
-
// appending that directory as the new deepest level (path is pieced together).
|
|
272
|
-
const enterDir = (name) => {
|
|
273
|
-
if (busy || loading) return
|
|
274
|
-
const last = levels && levels.length ? levels[levels.length - 1] : null
|
|
275
|
-
const base = last && last.path ? last.path : ''
|
|
276
|
-
const sep = base.includes('\\') ? '\\' : '/'
|
|
277
|
-
const next = base === '/' ? '/' + name : (base ? base + sep + name : name)
|
|
278
|
-
loadLevels(machineId, next, (levels ? levels.length : 0))
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Autocomplete: given a partial remote path, list children of its parent dir
|
|
282
|
-
// that start with the last segment (powered by the structured ls endpoint).
|
|
283
|
-
const loadSuggestions = (raw, mid) => {
|
|
284
|
-
const id = mid || machineId
|
|
285
|
-
if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
|
|
286
|
-
const t = String(raw || '').trim()
|
|
287
|
-
if (!t) { setSuggest([]); setSuggestOpen(false); return }
|
|
288
|
-
// Windows drive path (D:\...) uses backslashes; POSIX uses '/'.
|
|
289
|
-
const isWin = t.includes('\\')
|
|
290
|
-
const sep = isWin ? '\\' : '/'
|
|
291
|
-
const slash = t.lastIndexOf(sep)
|
|
292
|
-
const parent = slash <= 0 ? (isWin ? t.match(/^[a-zA-Z]:\\?/) ? t : '' : '/') : t.slice(0, slash)
|
|
293
|
-
const lastSeg = slash < 0 ? t : t.slice(slash + 1)
|
|
294
|
-
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
295
|
-
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
|
|
296
|
-
.then((res) => {
|
|
297
|
-
const list = Array.isArray(res && res.items)
|
|
298
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
299
|
-
: parseLs(res && res.text)
|
|
300
|
-
const base = parent === '/' ? '/' : parent
|
|
301
|
-
const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
|
|
302
|
-
.slice(0, 40).map((it) => (base === '/' || !base ? sep + it.name : base + sep + it.name))
|
|
303
|
-
setSuggest(matches)
|
|
304
|
-
setSuggestOpen(!!matches.length)
|
|
305
|
-
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const onPathChange = (raw) => {
|
|
309
|
-
setPath(raw); setErr('')
|
|
310
|
-
if (suggestTimer.current) clearTimeout(suggestTimer.current)
|
|
311
|
-
suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// After a directory is chosen, immediately reveal the next level: list the
|
|
315
|
-
// chosen directory's children as fresh completions (no keystroke needed).
|
|
316
|
-
const continueSuggest = (dir) => {
|
|
317
|
-
if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
|
|
318
|
-
setSuggestOpen(false)
|
|
319
|
-
const sep = String(dir).includes('\\') ? '\\' : '/'
|
|
320
|
-
api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
|
|
321
|
-
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
|
|
322
|
-
.then((res) => {
|
|
323
|
-
const list = Array.isArray(res && res.items)
|
|
324
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
325
|
-
: parseLs(res && res.text)
|
|
326
|
-
const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
|
|
327
|
-
const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + sep + it.name))
|
|
328
|
-
setSuggest(kids)
|
|
329
|
-
setSuggestOpen(!!kids.length)
|
|
330
|
-
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Choose a suggestion: fill the field and immediately open its next level.
|
|
334
|
-
const selectSuggestion = (s) => {
|
|
335
|
-
setPath(s); setErr(''); setSuggestOpen(false)
|
|
336
|
-
continueSuggest(s)
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Commit an explicit remote path as the workspace (race from the input).
|
|
340
|
-
const commitPath = (p) => {
|
|
341
|
-
const target = String(p || '').trim()
|
|
342
|
-
if (!target || !machineId || busy) return
|
|
343
|
-
setPopOpen(false); setSuggestOpen(false)
|
|
344
|
-
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)))
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Confirm the highlighted directory from the browser popup by filling the
|
|
348
|
-
// path input (not committing), so the user can review/edit before commit.
|
|
349
|
-
const acceptBrowserPick = (p) => {
|
|
350
|
-
setPath(String(p || ''))
|
|
351
|
-
setSuggestOpen(false)
|
|
352
|
-
setPopOpen(false)
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function renderDirPopup() {
|
|
356
|
-
if (!levels || !levels.length) {
|
|
357
|
-
return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
|
|
358
|
-
}
|
|
359
|
-
const last = levels[levels.length - 1]
|
|
360
|
-
const entries = last.all || []
|
|
361
|
-
// Inline panel anchored inside the dialog (NOT a full-viewport overlay):
|
|
362
|
-
// a height-capped box whose directory list scrolls internally, so the
|
|
363
|
-
// dialog's own confirm row ("设为远程工作区") stays visible below it.
|
|
364
|
-
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' } },
|
|
365
|
-
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
|
|
366
|
-
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 }, '回上一级 ▴'),
|
|
367
|
-
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 }))),
|
|
368
|
-
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
|
|
369
|
-
),
|
|
370
|
-
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
371
|
-
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
|
|
372
|
-
: (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
373
|
-
key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
|
|
374
|
-
onClick: it.dir ? () => enterDir(it.name) : undefined,
|
|
375
|
-
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 },
|
|
376
|
-
},
|
|
377
|
-
React.createElement('span', null, it.dir ? '📁' : '📄'),
|
|
378
|
-
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
|
|
379
|
-
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
|
|
380
|
-
),
|
|
381
|
-
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
|
|
382
|
-
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
|
|
383
|
-
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
|
|
384
|
-
),
|
|
385
|
-
)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
if (!open) return null
|
|
389
|
-
const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
|
|
390
|
-
// The picker is a full-viewport centered modal (backdrop + panel), so it
|
|
391
|
-
// renders identically in the narrow sidebar and in the conversation and
|
|
392
|
-
// is never squeezed into a cramped in-place column.
|
|
393
|
-
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() } },
|
|
394
|
-
React.createElement('div', { style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', maxHeight: 'min(620px, 90vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
|
|
395
|
-
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
|
|
396
|
-
React.createElement('div', null, '选择工作目录'),
|
|
397
|
-
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
|
|
398
|
-
),
|
|
399
|
-
React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
|
|
400
|
-
tabBtn('local', '本机'),
|
|
401
|
-
tabBtn('remote', '远程'),
|
|
402
|
-
),
|
|
403
|
-
tab === 'local'
|
|
404
|
-
? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
405
|
-
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '系统选择器优先;不可用时直接输入本机目录。'),
|
|
406
|
-
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
407
|
-
React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
|
|
408
|
-
React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
|
|
409
|
-
),
|
|
410
|
-
React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
|
|
411
|
-
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
412
|
-
)
|
|
413
|
-
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
|
|
414
|
-
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
415
|
-
React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
|
|
416
|
-
React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
|
|
417
|
-
React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
|
|
418
|
-
(machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
|
|
419
|
-
),
|
|
420
|
-
mOpen
|
|
421
|
-
? 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 } },
|
|
422
|
-
machines.length
|
|
423
|
-
? 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 + ')'))
|
|
424
|
-
: React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
|
|
425
|
-
)
|
|
426
|
-
: null,
|
|
427
|
-
),
|
|
428
|
-
),
|
|
429
|
-
// 路径输入框(带自动补全)+ 打开浏览弹层按钮
|
|
430
|
-
// suggestRef 外层仍用于「点击外部关闭」判定;输入行与补全列表
|
|
431
|
-
// 是两级 div —— 补全列表以流式(非绝对定位)展开,确认按钮被
|
|
432
|
-
// 推到下方而不会被遮挡(issue #4「有子目录时选择框遮挡确定菜单」)。
|
|
433
|
-
React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
|
|
434
|
-
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
435
|
-
React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
|
|
436
|
-
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
|
|
437
|
-
),
|
|
438
|
-
// 自动补全下拉(流式展开,参与文档流,不覆盖确认按钮)
|
|
439
|
-
(suggestOpen && suggest.length)
|
|
440
|
-
? 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)' } },
|
|
441
|
-
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)),
|
|
442
|
-
)
|
|
443
|
-
: null,
|
|
444
|
-
),
|
|
445
|
-
// 目录浏览面板(内联,锚定在对话框内;确认按钮保持可见)
|
|
446
|
-
popOpen ? renderDirPopup() : null,
|
|
447
|
-
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
448
|
-
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
|
|
449
|
-
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
|
|
450
|
-
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
|
|
451
|
-
),
|
|
452
|
-
),
|
|
453
|
-
(tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
|
|
454
|
-
),
|
|
455
|
-
)
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// ── better-sidebar integration (optional) ────────────────────────────────
|
|
459
|
-
// The sidebar of dsh-better-sidebar lists LOCAL files (its fs tree reads
|
|
460
|
-
// the host filesystem). When dsh-remote is the active remote workspace the
|
|
461
|
-
// user wants to browse the REMOTE host's files live — not the local SFTP
|
|
462
|
-
// mirror snapshot (which rw_sync caps by depth/file-count/size).
|
|
463
|
-
//
|
|
464
|
-
// Through the public `ctx.betterSidebar` service (v0.4.0+; gracefully
|
|
465
|
-
// skipped when dsh-better-sidebar is not installed) we register TWO tabs:
|
|
466
|
-
// • `dsh-remote:explorer` — a live remote file tree (dirs expand over
|
|
467
|
-
// /dsh-remote/ls; every click lists the REMOTE host, not the mirror),
|
|
468
|
-
// • `dsh-remote:file` — a read-only remote file view (hidden from the
|
|
469
|
-
// + menu; opened by the explorer) that reads the file live over
|
|
470
|
-
// /dsh-remote/read and renders its text (or a binary notice).
|
|
471
|
-
//
|
|
472
|
-
// Both are READ-ONLY on purpose: the sidebar editor's save path writes to
|
|
473
|
-
// the LOCAL fs, so an editable remote file would silently save into the
|
|
474
|
-
// mirror. Editing stays in the rw_* tools / the mirror workflow. We do NOT
|
|
475
|
-
// register a file viewer (better-sidebar's matching is extension/priority
|
|
476
|
-
// based and cannot tell a remote path from a local one — a catch-all
|
|
477
|
-
// viewer would hijack every local file open).
|
|
478
|
-
|
|
479
|
-
// A stable id namespace so the persisted sidebar tabs survive reloads.
|
|
480
|
-
const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
|
|
481
|
-
const SIDEBAR_FILE_ID = 'dsh-remote:file'
|
|
482
|
-
|
|
483
|
-
/** Remote workspace state the sidebar reads (host /dsh-remote/status). */
|
|
484
|
-
function fetchRemoteStatus() {
|
|
485
|
-
return api('GET', '/dsh-remote/status').catch(() => null)
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/** Read a remote file (live) → {binary, content?, head?, size?, truncated?}. */
|
|
489
|
-
function readRemoteFile(path, maxBytes) {
|
|
490
|
-
return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
|
|
491
|
-
throw new Error('远程读取失败: ' + ((e && e.message) || e))
|
|
492
|
-
})
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/** List a remote dir (live) → items [{name, type}] (server-driven types). */
|
|
496
|
-
function listRemoteDir(path) {
|
|
497
|
-
return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
|
|
498
|
-
throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
|
|
499
|
-
})
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/** Join a remote dir + entry name, honoring the dir's separator style. */
|
|
503
|
-
function joinRemote(base, name) {
|
|
504
|
-
if (!base) return name
|
|
505
|
-
return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// ── Remote explorer tab ────────────────────────────────────────────────
|
|
509
|
-
function RemoteExplorerTab(props) {
|
|
510
|
-
const [status, setStatus] = React.useState(null)
|
|
511
|
-
const [levels, setLevels] = React.useState([]) // [{path, items}]
|
|
512
|
-
const [err, setErr] = React.useState('')
|
|
513
|
-
const [loading, setLoading] = React.useState(false)
|
|
514
|
-
const [picker, setPicker] = React.useState(false)
|
|
515
|
-
|
|
516
|
-
const refreshStatus = () => {
|
|
517
|
-
fetchRemoteStatus().then((s) => {
|
|
518
|
-
setStatus(s)
|
|
519
|
-
if (s && s.workspace) {
|
|
520
|
-
setLevels((prev) => {
|
|
521
|
-
// First open with a workspace: seed the root level AND load it,
|
|
522
|
-
// so the tree shows the remote listing immediately instead of
|
|
523
|
-
// an empty "(空目录)" until the user hits ↻.
|
|
524
|
-
if (!prev.length) {
|
|
525
|
-
loadDir(s.workspace, 0)
|
|
526
|
-
return [{ path: s.workspace, items: null }]
|
|
527
|
-
}
|
|
528
|
-
return prev
|
|
529
|
-
})
|
|
530
|
-
}
|
|
531
|
-
})
|
|
532
|
-
}
|
|
533
|
-
React.useEffect(() => { refreshStatus() }, [])
|
|
534
|
-
// When the tab becomes visible, re-verify the workspace still exists
|
|
535
|
-
// (the remote connection may have been switched in settings).
|
|
536
|
-
React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
|
|
537
|
-
|
|
538
|
-
const loadDir = (p, idx) => {
|
|
539
|
-
if (loading) return
|
|
540
|
-
setLoading(true); setErr('')
|
|
541
|
-
listRemoteDir(p).then((res) => {
|
|
542
|
-
const items = Array.isArray(res && res.items) ? res.items : []
|
|
543
|
-
setLevels((prev) => {
|
|
544
|
-
const base = prev.length ? prev.slice() : [{ path: p, items: null }]
|
|
545
|
-
const list = items.map((it) => ({ name: it.name, type: it.type === 'dir' ? 'dir' : 'file' }))
|
|
546
|
-
if (idx < 0 || idx >= base.length) return base.concat([{ path: p, items: list }])
|
|
547
|
-
base[idx] = { path: p, items: list }
|
|
548
|
-
return base.slice(0, idx + 1)
|
|
549
|
-
})
|
|
550
|
-
}).catch((e) => setErr(String((e && e.message) || e)))
|
|
551
|
-
.finally(() => setLoading(false))
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// Open a directory: append it as the new deepest level.
|
|
555
|
-
const enterDir = (name) => {
|
|
556
|
-
const last = levels.length ? levels[levels.length - 1] : null
|
|
557
|
-
if (!last || !last.items) return
|
|
558
|
-
const base = last.path
|
|
559
|
-
const sep = base.includes('\\') ? '\\' : '/'
|
|
560
|
-
const next = base === '/' ? '/' + name : base + sep + name
|
|
561
|
-
loadDir(next, levels.length)
|
|
562
|
-
}
|
|
563
|
-
// Jump to a breadcrumb ancestor.
|
|
564
|
-
const jumpTo = (p) => {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
key:
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
React.createElement('div', { style: {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
:
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
)
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1
|
+
// dsh-remote — client half.
|
|
2
|
+
// Settings → 远程工作区: a multi-machine SSH registry (add/edit/select current;
|
|
3
|
+
// password stored host-side and kept private). No workspace path here — the
|
|
4
|
+
// path is picked at workspace-add time.
|
|
5
|
+
//
|
|
6
|
+
// A unified workspace directory picker fills ui-workspace's two directory-flow
|
|
7
|
+
// holes (sidebar + conversation hero):
|
|
8
|
+
// • 本机 tab → opens the NATIVE OS folder chooser (ctx.workspaces.pickDirectory)
|
|
9
|
+
// and returns the picked local path (works with local workspaces).
|
|
10
|
+
// • 远程 tab → pick a machine (dropdown), list its directories over
|
|
11
|
+
// /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
|
|
12
|
+
// real LOCAL mirror → onPicked(localMirror) so host adopts it as a workspace.
|
|
13
|
+
//
|
|
14
|
+
// Client entries must be classic scripts registered via window.__ModuleLoader__.load
|
|
15
|
+
// ({ id, factory }); the factory receives a synchronous `require`.
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: 'dsh-remote',
|
|
18
|
+
factory: (require) => {
|
|
19
|
+
var module = { exports: {} }
|
|
20
|
+
var exports = module.exports
|
|
21
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
22
|
+
|
|
23
|
+
const React = require('react')
|
|
24
|
+
const name = 'dsh-remote'
|
|
25
|
+
|
|
26
|
+
async function api(method, path, body) {
|
|
27
|
+
const opts = { method, headers: {} }
|
|
28
|
+
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body) }
|
|
29
|
+
const res = await fetch(path, opts)
|
|
30
|
+
const data = await res.json().catch(() => ({}))
|
|
31
|
+
if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
|
|
32
|
+
return data
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Theme via DSH design tokens so this follows the harness light/dark theme.
|
|
36
|
+
const v = (name, fb) => `var(${name}, ${fb})`
|
|
37
|
+
const T = {
|
|
38
|
+
bg: v('--dsw-alias-bg-layer-1', 'rgba(128,128,128,0.07)'),
|
|
39
|
+
bg2: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.10)'),
|
|
40
|
+
border: v('--dsw-alias-border-l2', 'rgba(128,128,128,0.35)'),
|
|
41
|
+
borderStrong: v('--dsw-alias-border-l3', 'rgba(128,128,128,0.5)'),
|
|
42
|
+
danger: v('--dsw-static-red-500', '#e06c75'),
|
|
43
|
+
dangerText: v('--dsw-static-red-400', '#e06c75'),
|
|
44
|
+
ok: v('--dsw-static-green-500', '#4caf7d'),
|
|
45
|
+
radius: 8,
|
|
46
|
+
muted: v('--dsw-alias-label-tertiary', 'rgba(128,128,128,0.7)'),
|
|
47
|
+
label: v('--dsw-alias-label-primary', '#e4e4e7'),
|
|
48
|
+
primary: v('--dsw-alias-button-primary-fill', '#2563eb'),
|
|
49
|
+
onPrimary: v('--dsw-alias-button-contrast-fill', '#fff'),
|
|
50
|
+
hoverBg: v('--dsw-alias-interactive-bg-hover', 'rgba(128,128,128,0.14)'),
|
|
51
|
+
}
|
|
52
|
+
const inputS = { flex: 1, padding: '6px 10px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, outline: 'none' }
|
|
53
|
+
const buttonS = { padding: '6px 12px', borderRadius: T.radius, border: '1px solid ' + T.border, background: T.bg, color: T.label, cursor: 'pointer' }
|
|
54
|
+
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
|
+
const box = { border: '1px solid ' + T.border, borderRadius: T.radius, background: T.bg, padding: 10 }
|
|
57
|
+
const boxS = box
|
|
58
|
+
|
|
59
|
+
let WORKSPACES = null // ctx.get('workspaces'), set in apply()
|
|
60
|
+
|
|
61
|
+
// ── Settings → 远程工作区 (machine registry) ─────────────────────────────
|
|
62
|
+
function RemoteWorkspacePage() {
|
|
63
|
+
const [machines, setMachines] = React.useState([])
|
|
64
|
+
const [currentId, setCurrentId] = React.useState('')
|
|
65
|
+
const [form, setForm] = React.useState({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' })
|
|
66
|
+
const [busy, setBusy] = React.useState(false)
|
|
67
|
+
const [msg, setMsg] = React.useState('')
|
|
68
|
+
const [err, setErr] = React.useState('')
|
|
69
|
+
const [tst, setTst] = React.useState('') // idle | testing | ok | failing
|
|
70
|
+
|
|
71
|
+
const testConn = () => {
|
|
72
|
+
if (!form.host.trim() || tst === 'testing') return
|
|
73
|
+
setTst('testing'); setErr(''); setMsg('')
|
|
74
|
+
api('POST', '/dsh-remote/test-connect', {
|
|
75
|
+
host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
|
|
76
|
+
password: form.password, privateKeyPath: form.privateKeyPath,
|
|
77
|
+
})
|
|
78
|
+
.then((r) => {
|
|
79
|
+
if (r && r.ok) { setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)` : '连接成功') }
|
|
80
|
+
else { setTst('failing'); setErr((r && r.error) || '连接失败') }
|
|
81
|
+
})
|
|
82
|
+
.catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const refresh = () => api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setCurrentId(r.currentId || '') })
|
|
86
|
+
React.useEffect(() => { refresh() }, [])
|
|
87
|
+
|
|
88
|
+
const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
|
|
89
|
+
const startEdit = (m) => setForm({ name: m.name, host: m.host, port: String(m.port || 22), username: m.username || 'root', password: '', privateKeyPath: m.privateKeyPath || '', id: m.id })
|
|
90
|
+
const save = (action) => {
|
|
91
|
+
setBusy(true); setErr(''); setMsg('')
|
|
92
|
+
if (action === 'delete') {
|
|
93
|
+
api('POST', '/dsh-remote/machines', { action: 'delete', id: form.id }).then(refresh).then(() => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg('已删除') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
api('POST', '/dsh-remote/machines', {
|
|
97
|
+
action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
|
|
98
|
+
username: form.username, password: form.password, privateKeyPath: form.privateKeyPath,
|
|
99
|
+
}).then((r) => { refresh(); setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
100
|
+
}
|
|
101
|
+
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
|
+
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
|
+
|
|
104
|
+
const row = (label, ctrl, k) => React.createElement('div', { key: k, style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
105
|
+
React.createElement('label', { style: { width: 76, fontSize: 12, opacity: 0.8 } }, label), ctrl)
|
|
106
|
+
|
|
107
|
+
return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 760 } },
|
|
108
|
+
React.createElement('div', { style: { fontSize: 15, fontWeight: 600 } }, '远程工作区(dsh-remote)'),
|
|
109
|
+
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } },
|
|
110
|
+
'维护多台 SSH 机器。路径不在设置里配置 —— 新建/选择工作区时,「本机」走系统文件夹对话框;「远程」选一台机器在远程目录中选择。'),
|
|
111
|
+
React.createElement('div', { style: boxS },
|
|
112
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, '已配置的机器'),
|
|
113
|
+
machines.length
|
|
114
|
+
? machines.map((m) => React.createElement('div', { key: m.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderBottom: '1px solid ' + T.border } },
|
|
115
|
+
React.createElement('div', { style: { flex: 1, fontSize: 13 } },
|
|
116
|
+
m.name + ' ',
|
|
117
|
+
React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
|
|
118
|
+
m.passwordSet ? ' 🔒' : '',
|
|
119
|
+
m.id === currentId ? React.createElement('span', { style: { color: T.ok, fontSize: 12 } }, ' · 当前') : null),
|
|
120
|
+
React.createElement('button', { style: buttonS, onClick: () => startEdit(m) }, '编辑'),
|
|
121
|
+
React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
|
|
122
|
+
React.createElement('button', { style: buttonS, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
|
|
123
|
+
: React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
|
|
124
|
+
),
|
|
125
|
+
React.createElement('div', { style: boxS },
|
|
126
|
+
React.createElement('div', { style: { marginBottom: 6, fontSize: 13, fontWeight: 600 } }, form.id ? '编辑机器' : '添加机器'),
|
|
127
|
+
row('名称', React.createElement('input', { value: form.name, onChange: setF('name'), placeholder: '例如 编译机', style: inputS }), 'n'),
|
|
128
|
+
row('主机', React.createElement('input', { value: form.host, onChange: setF('host'), placeholder: 'IP 或 hostname', style: inputS }), 'h'),
|
|
129
|
+
row('端口', React.createElement('input', { value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
|
|
130
|
+
row('用户', React.createElement('input', { value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
|
|
131
|
+
row('密码', React.createElement('input', { type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH 无 key 时用(不回显、仅保存)', style: inputS }), 'w'),
|
|
132
|
+
row('私钥路径', React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: '留空用默认 key', style: inputS }), 'k'),
|
|
133
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
|
|
134
|
+
msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
|
|
135
|
+
form.id ? React.createElement('button', { style: buttonS, onClick: () => save('delete') }, '删除') : null,
|
|
136
|
+
React.createElement('button', { style: buttonS, onClick: () => { setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', id: '' }); setErr(''); setTst('idle') } }, form.id ? '取消编辑' : '清空'),
|
|
137
|
+
React.createElement('button', { style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
|
|
138
|
+
tst === 'testing' ? '连接中…' : tst === 'ok' ? '✓ 连接成功' : '测试连接'),
|
|
139
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 700 }, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? '保存中…' : '保存'),
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 13 } }, err) : null,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Unified picker (fill the directory-flow holes) ───────────────────
|
|
147
|
+
function parseLs(text) {
|
|
148
|
+
const out = []
|
|
149
|
+
for (const ln of String(text || '').split('\n')) {
|
|
150
|
+
const t = ln.trim()
|
|
151
|
+
if (!t || !t.length || /^total\b/i.test(t)) continue
|
|
152
|
+
const parts = t.split(/\s+/).filter(Boolean)
|
|
153
|
+
// ls -l row: mode links owner group size month day [hh:mm|year] name...
|
|
154
|
+
// NAME starts at column 8 (everything before it is the fixed metadata).
|
|
155
|
+
if (parts.length < 8) continue
|
|
156
|
+
const mode = parts[0]
|
|
157
|
+
const name = parts.slice(8).join(' ')
|
|
158
|
+
if (!name || name === '.' || name === '..') continue
|
|
159
|
+
const isDir = mode.charAt(0) === 'd'
|
|
160
|
+
out.push({ name, dir: isDir })
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Clickable breadcrumb of the current remote path; clicking a segment jumps
|
|
166
|
+
// to that ancestor level (finite-width, one line, ellipsized at the front).
|
|
167
|
+
function breadcrumb(active, cur, jumpTo) {
|
|
168
|
+
const norm = String(cur || '').replace(/\/+$/, '')
|
|
169
|
+
const segs = norm === '' || norm === '/' ? [] : norm.split('/')
|
|
170
|
+
const crumbs = []
|
|
171
|
+
crumbs.push(React.createElement('span', { key: 'root', style: { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted }, onClick: active ? () => jumpTo('/') : undefined }, '/'))
|
|
172
|
+
let acc = ''
|
|
173
|
+
for (const s of segs) {
|
|
174
|
+
if (!s) continue
|
|
175
|
+
acc += '/' + s
|
|
176
|
+
crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
|
|
177
|
+
crumbs.push(React.createElement('span', {
|
|
178
|
+
key: acc,
|
|
179
|
+
style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === norm ? 700 : 400, whiteSpace: 'nowrap' },
|
|
180
|
+
onClick: active ? () => jumpTo(acc) : undefined,
|
|
181
|
+
}, s))
|
|
182
|
+
}
|
|
183
|
+
if (!crumbs.length) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, '/'))
|
|
184
|
+
return React.createElement('span', { key: 'crumb' }, crumbs)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function DirPicker(props) {
|
|
188
|
+
const { open, busy, onPicked, onCancel } = props
|
|
189
|
+
const [tab, setTab] = React.useState('local')
|
|
190
|
+
const [machines, setMachines] = React.useState([])
|
|
191
|
+
const [machineId, setMachineId] = React.useState('')
|
|
192
|
+
const [path, setPath] = React.useState('')
|
|
193
|
+
const [items, setItems] = React.useState(null)
|
|
194
|
+
const [err, setErr] = React.useState('')
|
|
195
|
+
const [loading, setLoading] = React.useState(false)
|
|
196
|
+
// 级联下钻状态:每一格是 { path, dirs } —— dirs 是该路径下的目录列表。
|
|
197
|
+
const [levels, setLevels] = React.useState(null)
|
|
198
|
+
const [popOpen, setPopOpen] = React.useState(false)
|
|
199
|
+
const [suggest, setSuggest] = React.useState([])
|
|
200
|
+
const [suggestOpen, setSuggestOpen] = React.useState(false)
|
|
201
|
+
const suggestTimer = React.useRef(null)
|
|
202
|
+
const suggestRef = React.useRef(null)
|
|
203
|
+
// 点击自动补全下拉外部的空白处时收起补全列表(上游 0.5.10)
|
|
204
|
+
React.useEffect(() => {
|
|
205
|
+
if (!suggestOpen) return
|
|
206
|
+
const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
|
|
207
|
+
document.addEventListener('mousedown', onDown)
|
|
208
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
209
|
+
}, [suggestOpen])
|
|
210
|
+
// 机器下拉(自绘 dropdown):点外部任意处关闭,避免候选层常驻遮挡下方按钮(上游 0.5.8)
|
|
211
|
+
const [mOpen, setMOpen] = React.useState(false)
|
|
212
|
+
const ddRef = React.useRef(null)
|
|
213
|
+
React.useEffect(() => {
|
|
214
|
+
if (!mOpen) return
|
|
215
|
+
const onDown = (ev) => { if (ddRef.current && !ddRef.current.contains(ev.target)) setMOpen(false) }
|
|
216
|
+
document.addEventListener('mousedown', onDown)
|
|
217
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
218
|
+
}, [mOpen])
|
|
219
|
+
|
|
220
|
+
const loadLevels = (id, p, toIndex) => {
|
|
221
|
+
if (!id) return
|
|
222
|
+
setLoading(true); setErr('')
|
|
223
|
+
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
224
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
|
|
225
|
+
.then((res) => {
|
|
226
|
+
const real = res && res.path ? res.path : (p || '')
|
|
227
|
+
// server returns [{ type:'dir'|'file', name }]; entering a dir is
|
|
228
|
+
// decided by type dir (server already follows symlinks to dirs).
|
|
229
|
+
const list = Array.isArray(res && res.items)
|
|
230
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
231
|
+
: parseLs(res && res.text)
|
|
232
|
+
const node = { path: real, dirs: list.filter((it) => it.dir), all: list }
|
|
233
|
+
setLevels((prev) => {
|
|
234
|
+
const base = prev && prev.length ? prev.slice() : []
|
|
235
|
+
// toIndex >= 0 → put this node at that position, truncating below it.
|
|
236
|
+
let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
|
|
237
|
+
if (idx >= base.length) return base.concat([node])
|
|
238
|
+
base[idx] = node
|
|
239
|
+
return base.slice(0, idx + 1)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
243
|
+
.finally(() => setLoading(false))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
React.useEffect(() => { if (open) { api('GET', '/dsh-remote/machines').then((r) => { setMachines(r.machines || []); setMachineId(r.currentId || (r.machines && r.machines[0] && r.machines[0].id) || '') }) } }, [open])
|
|
247
|
+
|
|
248
|
+
const chooseLocal = () => {
|
|
249
|
+
setLoading(true); setErr('')
|
|
250
|
+
api('POST', '/dsh-remote/local-pick')
|
|
251
|
+
.then((r) => {
|
|
252
|
+
if (r && r.path) onPicked(String(r.path))
|
|
253
|
+
else if (r && r.cancelled) setErr('已取消选择')
|
|
254
|
+
else setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径')
|
|
255
|
+
})
|
|
256
|
+
.catch((e) => setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径'))
|
|
257
|
+
.finally(() => setLoading(false))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const switchTab = (t) => {
|
|
261
|
+
setTab(t); setErr('')
|
|
262
|
+
if (t === 'remote') {
|
|
263
|
+
if (machineId) loadLevels(machineId, '', 0)
|
|
264
|
+
// Prime the field with the machine's root so the user immediately gets
|
|
265
|
+
// a '/'-level completion list without typing anything.
|
|
266
|
+
if (!path.trim()) { setPath('/'); loadSuggestions('/') }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// enterDir(name): drive into the named subdir of the current deepest level,
|
|
271
|
+
// appending that directory as the new deepest level (path is pieced together).
|
|
272
|
+
const enterDir = (name) => {
|
|
273
|
+
if (busy || loading) return
|
|
274
|
+
const last = levels && levels.length ? levels[levels.length - 1] : null
|
|
275
|
+
const base = last && last.path ? last.path : ''
|
|
276
|
+
const sep = base.includes('\\') ? '\\' : '/'
|
|
277
|
+
const next = base === '/' ? '/' + name : (base ? base + sep + name : name)
|
|
278
|
+
loadLevels(machineId, next, (levels ? levels.length : 0))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Autocomplete: given a partial remote path, list children of its parent dir
|
|
282
|
+
// that start with the last segment (powered by the structured ls endpoint).
|
|
283
|
+
const loadSuggestions = (raw, mid) => {
|
|
284
|
+
const id = mid || machineId
|
|
285
|
+
if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
|
|
286
|
+
const t = String(raw || '').trim()
|
|
287
|
+
if (!t) { setSuggest([]); setSuggestOpen(false); return }
|
|
288
|
+
// Windows drive path (D:\...) uses backslashes; POSIX uses '/'.
|
|
289
|
+
const isWin = t.includes('\\')
|
|
290
|
+
const sep = isWin ? '\\' : '/'
|
|
291
|
+
const slash = t.lastIndexOf(sep)
|
|
292
|
+
const parent = slash <= 0 ? (isWin ? t.match(/^[a-zA-Z]:\\?/) ? t : '' : '/') : t.slice(0, slash)
|
|
293
|
+
const lastSeg = slash < 0 ? t : t.slice(slash + 1)
|
|
294
|
+
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
295
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
|
|
296
|
+
.then((res) => {
|
|
297
|
+
const list = Array.isArray(res && res.items)
|
|
298
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
299
|
+
: parseLs(res && res.text)
|
|
300
|
+
const base = parent === '/' ? '/' : parent
|
|
301
|
+
const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
|
|
302
|
+
.slice(0, 40).map((it) => (base === '/' || !base ? sep + it.name : base + sep + it.name))
|
|
303
|
+
setSuggest(matches)
|
|
304
|
+
setSuggestOpen(!!matches.length)
|
|
305
|
+
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const onPathChange = (raw) => {
|
|
309
|
+
setPath(raw); setErr('')
|
|
310
|
+
if (suggestTimer.current) clearTimeout(suggestTimer.current)
|
|
311
|
+
suggestTimer.current = setTimeout(() => loadSuggestions(raw), 220)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// After a directory is chosen, immediately reveal the next level: list the
|
|
315
|
+
// chosen directory's children as fresh completions (no keystroke needed).
|
|
316
|
+
const continueSuggest = (dir) => {
|
|
317
|
+
if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
|
|
318
|
+
setSuggestOpen(false)
|
|
319
|
+
const sep = String(dir).includes('\\') ? '\\' : '/'
|
|
320
|
+
api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
|
|
321
|
+
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
|
|
322
|
+
.then((res) => {
|
|
323
|
+
const list = Array.isArray(res && res.items)
|
|
324
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
325
|
+
: parseLs(res && res.text)
|
|
326
|
+
const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
|
|
327
|
+
const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + sep + it.name))
|
|
328
|
+
setSuggest(kids)
|
|
329
|
+
setSuggestOpen(!!kids.length)
|
|
330
|
+
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Choose a suggestion: fill the field and immediately open its next level.
|
|
334
|
+
const selectSuggestion = (s) => {
|
|
335
|
+
setPath(s); setErr(''); setSuggestOpen(false)
|
|
336
|
+
continueSuggest(s)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Commit an explicit remote path as the workspace (race from the input).
|
|
340
|
+
const commitPath = (p) => {
|
|
341
|
+
const target = String(p || '').trim()
|
|
342
|
+
if (!target || !machineId || busy) return
|
|
343
|
+
setPopOpen(false); setSuggestOpen(false)
|
|
344
|
+
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)))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Confirm the highlighted directory from the browser popup by filling the
|
|
348
|
+
// path input (not committing), so the user can review/edit before commit.
|
|
349
|
+
const acceptBrowserPick = (p) => {
|
|
350
|
+
setPath(String(p || ''))
|
|
351
|
+
setSuggestOpen(false)
|
|
352
|
+
setPopOpen(false)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function renderDirPopup() {
|
|
356
|
+
if (!levels || !levels.length) {
|
|
357
|
+
return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
|
|
358
|
+
}
|
|
359
|
+
const last = levels[levels.length - 1]
|
|
360
|
+
const entries = last.all || []
|
|
361
|
+
// Inline panel anchored inside the dialog (NOT a full-viewport overlay):
|
|
362
|
+
// a height-capped box whose directory list scrolls internally, so the
|
|
363
|
+
// dialog's own confirm row ("设为远程工作区") stays visible below it.
|
|
364
|
+
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' } },
|
|
365
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
|
|
366
|
+
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 }, '回上一级 ▴'),
|
|
367
|
+
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 }))),
|
|
368
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
|
|
369
|
+
),
|
|
370
|
+
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
371
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
|
|
372
|
+
: (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
373
|
+
key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
|
|
374
|
+
onClick: it.dir ? () => enterDir(it.name) : undefined,
|
|
375
|
+
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 },
|
|
376
|
+
},
|
|
377
|
+
React.createElement('span', null, it.dir ? '📁' : '📄'),
|
|
378
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
|
|
379
|
+
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
|
|
380
|
+
),
|
|
381
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
|
|
382
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
|
|
383
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!open) return null
|
|
389
|
+
const tabBtn = (t, lbl) => React.createElement('button', { onClick: () => switchTab(t), style: { ...buttonS, fontWeight: tab === t ? 700 : 400 } }, lbl)
|
|
390
|
+
// The picker is a full-viewport centered modal (backdrop + panel), so it
|
|
391
|
+
// renders identically in the narrow sidebar and in the conversation and
|
|
392
|
+
// is never squeezed into a cramped in-place column.
|
|
393
|
+
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() } },
|
|
394
|
+
React.createElement('div', { style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', maxHeight: 'min(620px, 90vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
|
|
395
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 } },
|
|
396
|
+
React.createElement('div', null, '选择工作目录'),
|
|
397
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => { if (!busy) onCancel() }, disabled: busy }, '关闭 ✕'),
|
|
398
|
+
),
|
|
399
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 8 } },
|
|
400
|
+
tabBtn('local', '本机'),
|
|
401
|
+
tabBtn('remote', '远程'),
|
|
402
|
+
),
|
|
403
|
+
tab === 'local'
|
|
404
|
+
? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
405
|
+
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '系统选择器优先;不可用时直接输入本机目录。'),
|
|
406
|
+
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
407
|
+
React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
|
|
408
|
+
React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
|
|
409
|
+
),
|
|
410
|
+
React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
|
|
411
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
412
|
+
)
|
|
413
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
|
|
414
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
415
|
+
React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, '远程机器:'),
|
|
416
|
+
React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
|
|
417
|
+
React.createElement('button', { style: { ...inputS, textAlign: 'left', cursor: 'pointer', minWidth: 200, maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, onClick: () => setMOpen((x) => !x) },
|
|
418
|
+
(machines.find((m) => m.id === machineId) || {}).name || (machineId ? '…' : '— 选择机器 —'),
|
|
419
|
+
),
|
|
420
|
+
mOpen
|
|
421
|
+
? 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 } },
|
|
422
|
+
machines.length
|
|
423
|
+
? 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 + ')'))
|
|
424
|
+
: React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
|
|
425
|
+
)
|
|
426
|
+
: null,
|
|
427
|
+
),
|
|
428
|
+
),
|
|
429
|
+
// 路径输入框(带自动补全)+ 打开浏览弹层按钮
|
|
430
|
+
// suggestRef 外层仍用于「点击外部关闭」判定;输入行与补全列表
|
|
431
|
+
// 是两级 div —— 补全列表以流式(非绝对定位)展开,确认按钮被
|
|
432
|
+
// 推到下方而不会被遮挡(issue #4「有子目录时选择框遮挡确定菜单」)。
|
|
433
|
+
React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
|
|
434
|
+
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
435
|
+
React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
|
|
436
|
+
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
|
|
437
|
+
),
|
|
438
|
+
// 自动补全下拉(流式展开,参与文档流,不覆盖确认按钮)
|
|
439
|
+
(suggestOpen && suggest.length)
|
|
440
|
+
? 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)' } },
|
|
441
|
+
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)),
|
|
442
|
+
)
|
|
443
|
+
: null,
|
|
444
|
+
),
|
|
445
|
+
// 目录浏览面板(内联,锚定在对话框内;确认按钮保持可见)
|
|
446
|
+
popOpen ? renderDirPopup() : null,
|
|
447
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
448
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' } },
|
|
449
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? '所选: ' + path : ''),
|
|
450
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? '镜像中…' : '设为远程工作区'),
|
|
451
|
+
),
|
|
452
|
+
),
|
|
453
|
+
(tab === 'local') ? React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } }, React.createElement('button', { style: { background: 'transparent' }, onClick: onCancel }, '取消')) : null,
|
|
454
|
+
),
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── better-sidebar integration (optional) ────────────────────────────────
|
|
459
|
+
// The sidebar of dsh-better-sidebar lists LOCAL files (its fs tree reads
|
|
460
|
+
// the host filesystem). When dsh-remote is the active remote workspace the
|
|
461
|
+
// user wants to browse the REMOTE host's files live — not the local SFTP
|
|
462
|
+
// mirror snapshot (which rw_sync caps by depth/file-count/size).
|
|
463
|
+
//
|
|
464
|
+
// Through the public `ctx.betterSidebar` service (v0.4.0+; gracefully
|
|
465
|
+
// skipped when dsh-better-sidebar is not installed) we register TWO tabs:
|
|
466
|
+
// • `dsh-remote:explorer` — a live remote file tree (dirs expand over
|
|
467
|
+
// /dsh-remote/ls; every click lists the REMOTE host, not the mirror),
|
|
468
|
+
// • `dsh-remote:file` — a read-only remote file view (hidden from the
|
|
469
|
+
// + menu; opened by the explorer) that reads the file live over
|
|
470
|
+
// /dsh-remote/read and renders its text (or a binary notice).
|
|
471
|
+
//
|
|
472
|
+
// Both are READ-ONLY on purpose: the sidebar editor's save path writes to
|
|
473
|
+
// the LOCAL fs, so an editable remote file would silently save into the
|
|
474
|
+
// mirror. Editing stays in the rw_* tools / the mirror workflow. We do NOT
|
|
475
|
+
// register a file viewer (better-sidebar's matching is extension/priority
|
|
476
|
+
// based and cannot tell a remote path from a local one — a catch-all
|
|
477
|
+
// viewer would hijack every local file open).
|
|
478
|
+
|
|
479
|
+
// A stable id namespace so the persisted sidebar tabs survive reloads.
|
|
480
|
+
const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
|
|
481
|
+
const SIDEBAR_FILE_ID = 'dsh-remote:file'
|
|
482
|
+
|
|
483
|
+
/** Remote workspace state the sidebar reads (host /dsh-remote/status). */
|
|
484
|
+
function fetchRemoteStatus() {
|
|
485
|
+
return api('GET', '/dsh-remote/status').catch(() => null)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Read a remote file (live) → {binary, content?, head?, size?, truncated?}. */
|
|
489
|
+
function readRemoteFile(path, maxBytes) {
|
|
490
|
+
return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
|
|
491
|
+
throw new Error('远程读取失败: ' + ((e && e.message) || e))
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** List a remote dir (live) → items [{name, type}] (server-driven types). */
|
|
496
|
+
function listRemoteDir(path) {
|
|
497
|
+
return api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(path || '')).catch((e) => {
|
|
498
|
+
throw new Error('远程目录读取失败: ' + ((e && e.message) || e))
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Join a remote dir + entry name, honoring the dir's separator style. */
|
|
503
|
+
function joinRemote(base, name) {
|
|
504
|
+
if (!base) return name
|
|
505
|
+
return base.replace(/[\\/]+$/, '') + (base.includes('\\') ? '\\' : '/') + name
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── Remote explorer tab ────────────────────────────────────────────────
|
|
509
|
+
function RemoteExplorerTab(props) {
|
|
510
|
+
const [status, setStatus] = React.useState(null)
|
|
511
|
+
const [levels, setLevels] = React.useState([]) // [{path, items}]
|
|
512
|
+
const [err, setErr] = React.useState('')
|
|
513
|
+
const [loading, setLoading] = React.useState(false)
|
|
514
|
+
const [picker, setPicker] = React.useState(false)
|
|
515
|
+
|
|
516
|
+
const refreshStatus = () => {
|
|
517
|
+
fetchRemoteStatus().then((s) => {
|
|
518
|
+
setStatus(s)
|
|
519
|
+
if (s && s.workspace) {
|
|
520
|
+
setLevels((prev) => {
|
|
521
|
+
// First open with a workspace: seed the root level AND load it,
|
|
522
|
+
// so the tree shows the remote listing immediately instead of
|
|
523
|
+
// an empty "(空目录)" until the user hits ↻.
|
|
524
|
+
if (!prev.length) {
|
|
525
|
+
loadDir(s.workspace, 0)
|
|
526
|
+
return [{ path: s.workspace, items: null }]
|
|
527
|
+
}
|
|
528
|
+
return prev
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
React.useEffect(() => { refreshStatus() }, [])
|
|
534
|
+
// When the tab becomes visible, re-verify the workspace still exists
|
|
535
|
+
// (the remote connection may have been switched in settings).
|
|
536
|
+
React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
|
|
537
|
+
|
|
538
|
+
const loadDir = (p, idx) => {
|
|
539
|
+
if (loading) return
|
|
540
|
+
setLoading(true); setErr('')
|
|
541
|
+
listRemoteDir(p).then((res) => {
|
|
542
|
+
const items = Array.isArray(res && res.items) ? res.items : []
|
|
543
|
+
setLevels((prev) => {
|
|
544
|
+
const base = prev.length ? prev.slice() : [{ path: p, items: null }]
|
|
545
|
+
const list = items.map((it) => ({ name: it.name, type: it.type === 'dir' ? 'dir' : 'file' }))
|
|
546
|
+
if (idx < 0 || idx >= base.length) return base.concat([{ path: p, items: list }])
|
|
547
|
+
base[idx] = { path: p, items: list }
|
|
548
|
+
return base.slice(0, idx + 1)
|
|
549
|
+
})
|
|
550
|
+
}).catch((e) => setErr(String((e && e.message) || e)))
|
|
551
|
+
.finally(() => setLoading(false))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Open a directory: append it as the new deepest level.
|
|
555
|
+
const enterDir = (name) => {
|
|
556
|
+
const last = levels.length ? levels[levels.length - 1] : null
|
|
557
|
+
if (!last || !last.items) return
|
|
558
|
+
const base = last.path
|
|
559
|
+
const sep = base.includes('\\') ? '\\' : '/'
|
|
560
|
+
const next = base === '/' ? '/' + name : base + sep + name
|
|
561
|
+
loadDir(next, levels.length)
|
|
562
|
+
}
|
|
563
|
+
// Jump to a breadcrumb ancestor.
|
|
564
|
+
const jumpTo = (p) => {
|
|
565
|
+
// Breadcrumb segments may lack the leading separator (built by
|
|
566
|
+
// splitting the absolute path), so normalize to the absolute form
|
|
567
|
+
// before matching the level stack.
|
|
568
|
+
const target = p.startsWith('/') || p.startsWith('\\') || /^[A-Za-z]:/.test(p) ? p : '/' + p
|
|
569
|
+
const idx = levels.findIndex((lv) => lv.path === target)
|
|
570
|
+
if (idx < 0) return
|
|
571
|
+
setLevels(levels.slice(0, idx + 1))
|
|
572
|
+
}
|
|
573
|
+
// Open a file: open the dedicated REMOTE file tab (reads the file live
|
|
574
|
+
// over /dsh-remote/read) — NOT the builtin editor, whose fs.read would
|
|
575
|
+
// hit the local filesystem and fail on a remote path.
|
|
576
|
+
const openFile = (p) => {
|
|
577
|
+
const name = String(p).split(/[\\/]/).pop() || p
|
|
578
|
+
props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const allItems = levels.length ? (levels[levels.length - 1].items || []) : []
|
|
582
|
+
const curPath = levels.length ? levels[levels.length - 1].path : ''
|
|
583
|
+
const segs = String(curPath || '').split(/[\\/]+/).filter(Boolean)
|
|
584
|
+
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([]) } }, '⌂')]
|
|
585
|
+
let acc = ''
|
|
586
|
+
segs.forEach((s, i) => {
|
|
587
|
+
acc = acc ? acc + '/' + s : s
|
|
588
|
+
const isLast = i === segs.length - 1
|
|
589
|
+
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))
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
const row = (it, i) => React.createElement('div', {
|
|
593
|
+
key: it.name + '|' + i,
|
|
594
|
+
title: (it.type === 'dir' ? '进入目录 ' : '打开文件 ') + it.name,
|
|
595
|
+
onClick: () => (it.type === 'dir' ? enterDir(it.name) : openFile(joinRemote(curPath, it.name))),
|
|
596
|
+
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)' },
|
|
597
|
+
}, React.createElement('span', { style: { flexShrink: 0 } }, it.type === 'dir' ? '📁' : '📄'), React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name))
|
|
598
|
+
|
|
599
|
+
const rootHint = status && status.workspace
|
|
600
|
+
? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + status.workspace + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
|
|
601
|
+
: React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区')
|
|
602
|
+
|
|
603
|
+
// Explicit "up one level" button: the breadcrumb line ellipsizes and
|
|
604
|
+
// its segments may be hard to hit in a narrow sidebar, so give the
|
|
605
|
+
// user a direct way back to the parent level.
|
|
606
|
+
const goUpOneLevel = () => {
|
|
607
|
+
if (levels.length <= 1) return
|
|
608
|
+
setLevels(levels.slice(0, levels.length - 1))
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
612
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
|
|
613
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => { const w = status && status.workspace; if (w) { setLevels([{ path: w, items: null }]); loadDir(w, 0) } else setPicker(true) } }, '↻'),
|
|
614
|
+
React.createElement('button', {
|
|
615
|
+
style: { ...buttonS, padding: '3px 8px', fontSize: 12 },
|
|
616
|
+
onClick: goUpOneLevel,
|
|
617
|
+
disabled: levels.length <= 1,
|
|
618
|
+
title: '返回上一级目录',
|
|
619
|
+
}, '↑ 上一级'),
|
|
620
|
+
React.createElement('div', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontFamily: 'monospace' } }, crumbs),
|
|
621
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
|
|
622
|
+
),
|
|
623
|
+
rootHint,
|
|
624
|
+
picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
|
|
625
|
+
open: true, busy: false, onPicked: (localMirror) => {
|
|
626
|
+
setPicker(false)
|
|
627
|
+
// The picker commits a mirror + workspace; refresh the tree.
|
|
628
|
+
refreshStatus()
|
|
629
|
+
fetchRemoteStatus().then((s) => { if (s && s.workspace) { setLevels([{ path: s.workspace, items: null }]); loadDir(s.workspace, 0) } })
|
|
630
|
+
}, onCancel: () => setPicker(false),
|
|
631
|
+
})) : null,
|
|
632
|
+
React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0 } },
|
|
633
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
|
|
634
|
+
: (allItems.length ? allItems.slice(0, 500).map(row)
|
|
635
|
+
: (curPath ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '(空目录)')
|
|
636
|
+
: React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区'))),
|
|
637
|
+
),
|
|
638
|
+
err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, err) : null,
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── Remote file tab (opened by the explorer; reads the file live) ──────
|
|
643
|
+
// A dedicated tab type instead of a file viewer: better-sidebar's viewer
|
|
644
|
+
// matching is extension/priority-based and cannot tell a REMOTE path from
|
|
645
|
+
// a LOCAL one (a catch-all viewer would hijack every local file open and
|
|
646
|
+
// then fail its local fs.read). Our own tab carries the remote path and
|
|
647
|
+
// reads it straight over /dsh-remote/read — read-only by design (the
|
|
648
|
+
// sidebar editor's save writes to the LOCAL fs, so an editable remote
|
|
649
|
+
// file would silently save into the mirror).
|
|
650
|
+
function RemoteFileTab(props) {
|
|
651
|
+
const { tab, scope } = props
|
|
652
|
+
const path = tab.path || ''
|
|
653
|
+
const [data, setData] = React.useState(null)
|
|
654
|
+
const [err, setErr] = React.useState('')
|
|
655
|
+
const [loading, setLoading] = React.useState(true)
|
|
656
|
+
|
|
657
|
+
React.useEffect(() => {
|
|
658
|
+
if (!path) { setLoading(false); return }
|
|
659
|
+
let cancelled = false
|
|
660
|
+
setLoading(true); setErr(''); setData(null)
|
|
661
|
+
readRemoteFile(path, 256 * 1024)
|
|
662
|
+
.then((d) => { if (!cancelled) { setData(d); setLoading(false) } })
|
|
663
|
+
.catch((e) => { if (!cancelled) { setErr(String((e && e.message) || e)); setLoading(false) } })
|
|
664
|
+
return () => { cancelled = true }
|
|
665
|
+
}, [path])
|
|
666
|
+
|
|
667
|
+
const baseName = path.split(/[\\/]/).pop() || path
|
|
668
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
669
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,0.25)', fontSize: 12 } },
|
|
670
|
+
React.createElement('span', { style: { fontWeight: 600, whiteSpace: 'nowrap' } }, '📄 ' + baseName),
|
|
671
|
+
React.createElement('code', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.75 } }, path),
|
|
672
|
+
React.createElement('span', { style: { opacity: 0.7, fontSize: 11, flexShrink: 0 } }, '只读'),
|
|
673
|
+
),
|
|
674
|
+
React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0 } },
|
|
675
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
|
|
676
|
+
: err ? React.createElement('div', { style: { color: '#e06c75', padding: 12, fontSize: 13 } }, err)
|
|
677
|
+
: (data && data.binary
|
|
678
|
+
? React.createElement('div', { style: { padding: 12, fontSize: 13, color: '#e6c07b' } },
|
|
679
|
+
'二进制文件 (' + (data.size != null ? data.size + ' bytes' : '未知大小') + ') — 远程查看器为只读,请用 rw_download / 本地镜像查看。')
|
|
680
|
+
: 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' } },
|
|
681
|
+
(data && data.content != null) ? data.content : '(空文件)',
|
|
682
|
+
(data && data.truncated)
|
|
683
|
+
? React.createElement('div', { style: { color: '#e6c07b', fontSize: 12, marginTop: 8 } }, '… 内容已截断(只读远程查看,完整文件请用 rw_read_file / 下载)')
|
|
684
|
+
: null,
|
|
685
|
+
)),
|
|
686
|
+
),
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ── better-sidebar registration (guarded: plugin may be absent) ────────
|
|
691
|
+
function registerSidebarIntegration(ctx) {
|
|
692
|
+
const bs = (ctx && ctx.get && ctx.get('betterSidebar')) || null
|
|
693
|
+
if (!bs || typeof bs.registerTab !== 'function') return
|
|
694
|
+
const disposers = []
|
|
695
|
+
try {
|
|
696
|
+
disposers.push(bs.registerTab({
|
|
697
|
+
id: SIDEBAR_EXPLORER_ID,
|
|
698
|
+
title: () => '远程文件',
|
|
699
|
+
icon: (size) => React.createElement('span', { style: { fontSize: (size || 14), lineHeight: 1 } }, '🌐'),
|
|
700
|
+
order: 55,
|
|
701
|
+
single: true,
|
|
702
|
+
component: (props) => React.createElement(RemoteExplorerTab, props),
|
|
703
|
+
}))
|
|
704
|
+
disposers.push(bs.registerTab({
|
|
705
|
+
id: SIDEBAR_FILE_ID,
|
|
706
|
+
title: () => '远程文件',
|
|
707
|
+
hidden: true, // not in the + menu; opened from the explorer tree
|
|
708
|
+
dedupeKey: (tab) => tab.path,
|
|
709
|
+
component: (props) => React.createElement(RemoteFileTab, props),
|
|
710
|
+
}))
|
|
711
|
+
} catch (e) {
|
|
712
|
+
console.warn('[dsh-remote] better-sidebar integration skipped:', e)
|
|
713
|
+
disposers.forEach((d) => { try { d() } catch {} })
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
// When a remote workspace is already configured, auto-open the remote
|
|
717
|
+
// explorer tab so the sidebar shows REMOTE files (not the local mirror
|
|
718
|
+
// tree the built-in Files tab lists). openTab with a path seed expands
|
|
719
|
+
// the panel and lands the tab in the active pane (the right panel);
|
|
720
|
+
// on the single:true explorer descriptor a repeat open focuses it.
|
|
721
|
+
//
|
|
722
|
+
// Timing: at plugin apply there is usually no active session yet, so
|
|
723
|
+
// openTab would be a no-op (store.reduce without a session). Subscribe
|
|
724
|
+
// to the sidebar snapshot and open the FIRST time a session appears;
|
|
725
|
+
// the subscription is also the cleanup channel for the auto-open fiber.
|
|
726
|
+
let autoOpened = false
|
|
727
|
+
let disposeAuto = null
|
|
728
|
+
const tryAutoOpen = () => {
|
|
729
|
+
if (autoOpened || typeof bs.subscribeState !== 'function') return
|
|
730
|
+
const snap = bs.getSnapshot && bs.getSnapshot()
|
|
731
|
+
if (!snap || !snap.sessionId) return
|
|
732
|
+
fetchRemoteStatus().then((s) => {
|
|
733
|
+
if (!s || !s.workspace || autoOpened) return
|
|
734
|
+
autoOpened = true
|
|
735
|
+
try {
|
|
736
|
+
bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: s.workspace })
|
|
737
|
+
} catch (e2) {
|
|
738
|
+
console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
tryAutoOpen()
|
|
743
|
+
if (typeof bs.subscribeState === 'function') {
|
|
744
|
+
disposeAuto = bs.subscribeState(tryAutoOpen)
|
|
745
|
+
disposers.push(disposeAuto)
|
|
746
|
+
}
|
|
747
|
+
return () => disposers.forEach((d) => { try { d() } catch {} })
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function apply(ctx) {
|
|
751
|
+
const slots = ctx.get('slots')
|
|
752
|
+
if (slots === undefined) return
|
|
753
|
+
WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
|
|
754
|
+
slots.inject('settings.section', () =>
|
|
755
|
+
slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
|
|
756
|
+
)
|
|
757
|
+
slots.inject(
|
|
758
|
+
'conversation.hero.workspace.directoryFlow',
|
|
759
|
+
() => slots.inject('sidebar.workspaces.directoryFlow',
|
|
760
|
+
function* () {
|
|
761
|
+
yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
|
|
762
|
+
yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
|
|
763
|
+
},
|
|
764
|
+
),
|
|
765
|
+
)
|
|
766
|
+
// better-sidebar integration: register the remote explorer + file tabs
|
|
767
|
+
// when dsh-better-sidebar is installed. Guarded — the plugin is optional
|
|
768
|
+
// and everything above works without it.
|
|
769
|
+
//
|
|
770
|
+
// Timing: bundle order may apply dsh-remote BEFORE dsh-better-sidebar,
|
|
771
|
+
// so `ctx.get('betterSidebar')` at apply time can be undefined. Use
|
|
772
|
+
// ctx.inject (cordis: "run once the requested services are available",
|
|
773
|
+
// re-run when a required service changes) so the integration registers
|
|
774
|
+
// the moment better-sidebar provides its service; when the plugin is
|
|
775
|
+
// never installed the fiber simply never runs — the optional integration
|
|
776
|
+
// stays inert. ctx.effect inside keeps the disposer fiber-bound.
|
|
777
|
+
ctx.inject(['betterSidebar'], (inner) => {
|
|
778
|
+
const bsDispose = registerSidebarIntegration(inner)
|
|
779
|
+
if (bsDispose) {
|
|
780
|
+
inner.effect(() => bsDispose, 'dsh-remote.betterSidebar')
|
|
781
|
+
}
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
exports.name = name
|
|
786
|
+
exports.apply = apply
|
|
787
|
+
return module.exports
|
|
788
|
+
},
|
|
771
789
|
})
|