dsh-remote 0.8.7 → 0.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/README.zh.md +13 -1
- package/lib/client.js +295 -92
- package/lib/index.js +412 -79
- package/lib/paths.js +48 -0
- package/lib/registry.js +89 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -86,6 +86,29 @@ window.__ModuleLoader__.load({
|
|
|
86
86
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Write text to the clipboard. Prefers the async Clipboard API (works on
|
|
90
|
+
// http://127.0.0.1 secure contexts) and falls back to the legacy
|
|
91
|
+
// execCommand('copy') path, mirroring primitives' writeClipboard semantics.
|
|
92
|
+
function copyToClipboard(text) {
|
|
93
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
94
|
+
return navigator.clipboard.writeText(text).then(() => true).catch(() => false)
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const ta = document.createElement('textarea')
|
|
98
|
+
ta.value = String(text)
|
|
99
|
+
ta.style.position = 'fixed'
|
|
100
|
+
ta.style.opacity = '0'
|
|
101
|
+
document.body.appendChild(ta)
|
|
102
|
+
ta.focus()
|
|
103
|
+
ta.select()
|
|
104
|
+
const ok = document.execCommand('copy')
|
|
105
|
+
ta.remove()
|
|
106
|
+
return Promise.resolve(ok)
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return Promise.resolve(false)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
let WORKSPACES = null // ctx.get('workspaces'), set in apply()
|
|
90
113
|
let SESSIONS = null // ctx.get('sessions'), set in apply()
|
|
91
114
|
|
|
@@ -118,7 +141,10 @@ window.__ModuleLoader__.load({
|
|
|
118
141
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
119
142
|
})
|
|
120
143
|
.then((r) => {
|
|
121
|
-
if (r && r.ok) {
|
|
144
|
+
if (r && r.ok) {
|
|
145
|
+
const shellTag = r.platform === 'windows' ? (r.gitBash ? '(Windows · Git Bash)' : '(Windows · 未找到 Git Bash)') : (r.platform === 'posix' ? '(POSIX)' : '')
|
|
146
|
+
setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)${shellTag}` : '连接成功'); refresh()
|
|
147
|
+
}
|
|
122
148
|
else { setTst('failing'); setErr((r && r.error) || '连接失败') }
|
|
123
149
|
})
|
|
124
150
|
.catch((e) => { setTst('failing'); setErr(String((e && e.message) || e)) })
|
|
@@ -204,6 +230,9 @@ window.__ModuleLoader__.load({
|
|
|
204
230
|
}).then((r) => { refresh(); clearForm(); setMsg(form.id ? '已保存更新' : '已添加 — 可设为当前') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
205
231
|
}
|
|
206
232
|
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)) }
|
|
233
|
+
// Issue #13: "active remote = none" — saved machines stay, but no machine
|
|
234
|
+
// is current, so local sessions are never dragged into a remote context.
|
|
235
|
+
const clearCurrent = () => { setBusy(true); api('POST', '/dsh-remote/current', { id: '' }).then((r) => { setCurrentId(''); setMsg('已取消当前远程机 — 本地会话不再自动使用远程上下文') }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
|
|
207
236
|
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))) } }
|
|
208
237
|
|
|
209
238
|
const importSsh = () => {
|
|
@@ -261,6 +290,11 @@ window.__ModuleLoader__.load({
|
|
|
261
290
|
React.createElement('button', { style: buttonS, onClick: () => del(m.id) }, '删除'),
|
|
262
291
|
React.createElement('button', { style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' }, onClick: () => useNow(m.id), disabled: m.id === currentId }, '设为当前')))
|
|
263
292
|
: React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有机器。在下方添加。'),
|
|
293
|
+
currentId
|
|
294
|
+
? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 6, fontSize: 12, color: T.ok } },
|
|
295
|
+
'当前远程机:' + ((machines.find((m) => m.id === currentId) || {}).name || currentId),
|
|
296
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px', fontSize: 12, marginLeft: 'auto' }, onClick: clearCurrent }, '取消设为当前(active remote = none)'))
|
|
297
|
+
: React.createElement('div', { style: { paddingTop: 6, fontSize: 12, opacity: 0.6 } }, '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。'),
|
|
264
298
|
),
|
|
265
299
|
React.createElement('div', { style: boxS },
|
|
266
300
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
|
|
@@ -401,25 +435,40 @@ window.__ModuleLoader__.load({
|
|
|
401
435
|
return out
|
|
402
436
|
}
|
|
403
437
|
|
|
404
|
-
// Clickable breadcrumb of the current remote path.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
438
|
+
// Clickable breadcrumb of the current remote path. Windows remotes show a
|
|
439
|
+
// "This PC" root and `\` separators (此电脑 / C:\ / Users / dev); POSIX `/`.
|
|
440
|
+
function breadcrumb(active, cur, win, jumpTo) {
|
|
441
|
+
const raw = String(cur || '')
|
|
442
|
+
const isWinPath = win && (raw === '' || /^[a-zA-Z]:/.test(raw))
|
|
443
|
+
const rootLabel = isWinPath ? '此电脑' : '/'
|
|
408
444
|
const crumbs = []
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
crumbs.push(React.createElement('span', { key:
|
|
415
|
-
crumbs.push(React.createElement('span', {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
445
|
+
const rootStyle = { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted, whiteSpace: 'nowrap' }
|
|
446
|
+
crumbs.push(React.createElement('span', { key: 'root', style: rootStyle, onClick: active ? () => jumpTo('') : undefined }, rootLabel))
|
|
447
|
+
if (isWinPath && raw !== '') {
|
|
448
|
+
const driveM = raw.match(/^([a-zA-Z]):(.*)$/)
|
|
449
|
+
const drive = driveM[1].toUpperCase() + ':\\'
|
|
450
|
+
crumbs.push(React.createElement('span', { key: 'sep0', style: { color: T.muted } }, '\\'))
|
|
451
|
+
crumbs.push(React.createElement('span', { key: 'drive', style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: raw === drive ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(drive) : undefined }, drive))
|
|
452
|
+
let acc = drive
|
|
453
|
+
for (const s of (driveM[2] || '').split('\\')) {
|
|
454
|
+
if (!s) continue
|
|
455
|
+
acc += s + '\\'
|
|
456
|
+
const target = acc.slice(0, -1)
|
|
457
|
+
crumbs.push(React.createElement('span', { key: 's|' + target, style: { color: T.muted } }, '\\'))
|
|
458
|
+
crumbs.push(React.createElement('span', { key: target, style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: target === raw ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(target) : undefined }, s))
|
|
459
|
+
}
|
|
460
|
+
} else if (!isWinPath && raw !== '' && raw !== '/') {
|
|
461
|
+
const segs = raw.replace(/\/+$/, '').split('/')
|
|
462
|
+
let acc = ''
|
|
463
|
+
for (const s of segs) {
|
|
464
|
+
if (!s) continue
|
|
465
|
+
acc += '/' + s
|
|
466
|
+
crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
|
|
467
|
+
crumbs.push(React.createElement('span', { key: acc, style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === raw ? 700 : 400, whiteSpace: 'nowrap' }, onClick: active ? () => jumpTo(acc) : undefined }, s))
|
|
468
|
+
}
|
|
420
469
|
}
|
|
421
|
-
if (
|
|
422
|
-
return React.createElement('span', { key: 'crumb' }, crumbs)
|
|
470
|
+
if (crumbs.length === 1) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, isWinPath ? ' 选择驱动器' : ''))
|
|
471
|
+
return React.createElement('span', { key: 'crumb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block', maxWidth: '100%' } }, crumbs)
|
|
423
472
|
}
|
|
424
473
|
|
|
425
474
|
function DirPicker(props) {
|
|
@@ -436,6 +485,7 @@ window.__ModuleLoader__.load({
|
|
|
436
485
|
const [suggest, setSuggest] = React.useState([])
|
|
437
486
|
const [suggestOpen, setSuggestOpen] = React.useState(false)
|
|
438
487
|
const [recent, setRecent] = React.useState([])
|
|
488
|
+
const [win, setWin] = React.useState(false) // remote platform: windows = Git-Bash host
|
|
439
489
|
// 本机浏览浮层(browse 后端):{ path, home, crumbs, entries, truncated }。
|
|
440
490
|
const [localPop, setLocalPop] = React.useState(null)
|
|
441
491
|
const [newDirName, setNewDirName] = React.useState('')
|
|
@@ -464,15 +514,29 @@ window.__ModuleLoader__.load({
|
|
|
464
514
|
return dirs.concat(files)
|
|
465
515
|
}
|
|
466
516
|
|
|
517
|
+
// Client-side full-path fallback: when the server did not attach a `path`
|
|
518
|
+
// (older host half), join base + name with the base's own separator.
|
|
519
|
+
const joinDisplay = (base, name) => {
|
|
520
|
+
const b = String(base || '')
|
|
521
|
+
const n = String(name || '')
|
|
522
|
+
if (!b || b === '/') return '/' + n
|
|
523
|
+
const sep = /^[a-zA-Z]:/.test(b) || b.includes('\\') ? '\\' : '/'
|
|
524
|
+
return b.replace(/[\\/]+$/, '') + sep + n
|
|
525
|
+
}
|
|
526
|
+
|
|
467
527
|
const loadLevels = (id, p, toIndex) => {
|
|
468
528
|
if (!id) return
|
|
469
529
|
setLoading(true); setErr('')
|
|
470
530
|
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
471
531
|
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
|
|
472
532
|
.then((res) => {
|
|
533
|
+
setWin(res && res.platform === 'windows')
|
|
473
534
|
const real = res && res.path ? res.path : (p || '')
|
|
535
|
+
// Server returns [{ type, name, size, mtime, path }] where `path` is
|
|
536
|
+
// the FULL display-form path (C:\Users\dev on Windows) — the client
|
|
537
|
+
// never joins paths itself, so drives and Windows paths just work.
|
|
474
538
|
const list = Array.isArray(res && res.items)
|
|
475
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime }))
|
|
539
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime, path: it.path || joinDisplay(real, it.name), drive: !!it.drive }))
|
|
476
540
|
: parseLs(res && res.text)
|
|
477
541
|
const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
|
|
478
542
|
setLevels((prev) => {
|
|
@@ -488,6 +552,9 @@ window.__ModuleLoader__.load({
|
|
|
488
552
|
}
|
|
489
553
|
|
|
490
554
|
React.useEffect(() => { if (open) {
|
|
555
|
+
// Bug fix: the picker must NOT reopen with the previous selection's
|
|
556
|
+
// path — reset the path bar (and the browse state) on every open.
|
|
557
|
+
setPath(''); setLevels(null); setWin(false)
|
|
491
558
|
setLocalPop(null); setNewDirName('')
|
|
492
559
|
api('GET', '/dsh-remote/machines').then((r) => {
|
|
493
560
|
setMachines(r.machines || [])
|
|
@@ -540,19 +607,47 @@ window.__ModuleLoader__.load({
|
|
|
540
607
|
|
|
541
608
|
const switchTab = (t) => {
|
|
542
609
|
setTab(t); setErr('')
|
|
610
|
+
// each tab keeps its own path — never leak the other tab's selection
|
|
611
|
+
setPath('')
|
|
543
612
|
if (t === 'remote') {
|
|
613
|
+
// Windows remotes show a "This PC" drive view at the root; POSIX shows
|
|
614
|
+
// the filesystem root. Either way loadLevels('') lists that root.
|
|
544
615
|
if (machineId) loadLevels(machineId, '', 0)
|
|
545
|
-
if (!path.trim()) { setPath('/'); loadSuggestions('/') }
|
|
546
616
|
}
|
|
547
617
|
}
|
|
548
618
|
|
|
549
|
-
|
|
550
|
-
|
|
619
|
+
// Parent path of a display-form remote path (client-side, both separators).
|
|
620
|
+
const parentOf = (p) => {
|
|
621
|
+
const t = String(p || '')
|
|
622
|
+
if (!t || t === '/') return null
|
|
623
|
+
const win = /^[a-zA-Z]:/.test(t)
|
|
624
|
+
if (win && /^[a-zA-Z]:\\?$/.test(t)) return null // drive root: no parent
|
|
625
|
+
const sep = win ? '\\' : '/'
|
|
626
|
+
const idx = t.lastIndexOf(sep)
|
|
627
|
+
if (idx <= 0) return null
|
|
628
|
+
const par = t.slice(0, idx)
|
|
629
|
+
if (win && /^[a-zA-Z]:$/.test(par)) return par + '\\'
|
|
630
|
+
return par || '/'
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// enterDir(it): drive into the given entry — `it.path` is the FULL
|
|
634
|
+
// display-form path from the server (drives C:\, Windows and POSIX alike).
|
|
635
|
+
const enterDir = (it) => {
|
|
636
|
+
if (busy || loading || !it || !it.dir) return
|
|
637
|
+
loadLevels(machineId, it.path, (levels ? levels.length : 0))
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Navigate one level up in the browse popup. When the current level was
|
|
641
|
+
// opened directly at a deep path (levels.length === 1 — e.g. the popup
|
|
642
|
+
// was opened at the path bar's value) the parent is loaded as the new top
|
|
643
|
+
// level instead of being disabled.
|
|
644
|
+
const goUp = () => {
|
|
645
|
+
if (loading) return
|
|
551
646
|
const last = levels && levels.length ? levels[levels.length - 1] : null
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
loadLevels(machineId,
|
|
647
|
+
if (!last) return
|
|
648
|
+
if (levels.length > 1) { setLevels((p) => p.slice(0, p.length - 1)); return }
|
|
649
|
+
const par = parentOf(last.path)
|
|
650
|
+
if (par) loadLevels(machineId, par, 1)
|
|
556
651
|
}
|
|
557
652
|
|
|
558
653
|
const loadSuggestions = (raw, mid) => {
|
|
@@ -560,20 +655,23 @@ window.__ModuleLoader__.load({
|
|
|
560
655
|
if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
|
|
561
656
|
const t = String(raw || '').trim()
|
|
562
657
|
if (!t) { setSuggest([]); setSuggestOpen(false); return }
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
const
|
|
566
|
-
const
|
|
567
|
-
|
|
658
|
+
// Separator-aware split: `\` when the input looks like a Windows path.
|
|
659
|
+
const backslash = t.includes('\\') || /^[a-zA-Z]:/.test(t)
|
|
660
|
+
const sep = backslash ? '\\' : '/'
|
|
661
|
+
const idx = t.lastIndexOf(sep)
|
|
662
|
+
let parent, lastSeg
|
|
663
|
+
if (idx < 0) { parent = backslash ? t + '\\' : '/'; lastSeg = backslash ? '' : t }
|
|
664
|
+
else if (idx === t.length - 1) { parent = t.slice(0, idx) || '/'; lastSeg = '' }
|
|
665
|
+
else { parent = t.slice(0, idx) || '/'; lastSeg = t.slice(idx + 1) }
|
|
568
666
|
api('POST', '/dsh-remote/current', { id }).catch(() => {})
|
|
569
667
|
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
|
|
570
668
|
.then((res) => {
|
|
571
669
|
const list = Array.isArray(res && res.items)
|
|
572
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
573
|
-
:
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
.slice(0, 40).map((it) =>
|
|
670
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(parent, it.name) }))
|
|
671
|
+
: []
|
|
672
|
+
const matches = list
|
|
673
|
+
.filter((it) => it.dir && (!lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase())))
|
|
674
|
+
.slice(0, 40).map((it) => it.path)
|
|
577
675
|
setSuggest(matches)
|
|
578
676
|
setSuggestOpen(!!matches.length)
|
|
579
677
|
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
@@ -588,23 +686,20 @@ window.__ModuleLoader__.load({
|
|
|
588
686
|
const continueSuggest = (dir) => {
|
|
589
687
|
if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
|
|
590
688
|
setSuggestOpen(false)
|
|
591
|
-
const sep = String(dir).includes('\\') ? '\\' : '/'
|
|
592
689
|
api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
|
|
593
690
|
.then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
|
|
594
691
|
.then((res) => {
|
|
595
692
|
const list = Array.isArray(res && res.items)
|
|
596
|
-
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
|
|
597
|
-
:
|
|
598
|
-
const
|
|
599
|
-
// Show every entry (dirs + files), matching loadSuggestions — the
|
|
600
|
-
// previous dir-only filter made the next-level list look truncated.
|
|
601
|
-
const kids = list.slice(0, 40).map((it) => (base === '/' ? sep + it.name : base + sep + it.name))
|
|
693
|
+
? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(dir, it.name) }))
|
|
694
|
+
: []
|
|
695
|
+
const kids = list.slice(0, 40).map((it) => it.path)
|
|
602
696
|
setSuggest(kids)
|
|
603
697
|
setSuggestOpen(!!kids.length)
|
|
604
698
|
}).catch(() => { setSuggest([]); setSuggestOpen(false) })
|
|
605
699
|
}
|
|
606
700
|
|
|
607
701
|
const selectSuggestion = (s) => {
|
|
702
|
+
if (!s) { setSuggestOpen(false); return }
|
|
608
703
|
// Auto-append the trailing separator so the path reads as a directory
|
|
609
704
|
// and the user can keep typing the next segment right away.
|
|
610
705
|
const sep = String(s).includes('\\') ? '\\' : '/'
|
|
@@ -724,27 +819,27 @@ window.__ModuleLoader__.load({
|
|
|
724
819
|
const entries = last.all || []
|
|
725
820
|
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' } },
|
|
726
821
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid ' + T.border } },
|
|
727
|
-
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick:
|
|
728
|
-
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 }))),
|
|
822
|
+
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: goUp, disabled: loading || (levels.length <= 1 && !parentOf(last.path)) }, '回上一级 ▴'),
|
|
823
|
+
React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, breadcrumb(!!machineId, last.path, win, (p) => setLevels((prev) => { const cut = (prev || []).findIndex((lv) => lv.path === p); return cut >= 0 ? prev.slice(0, cut + 1) : prev }))),
|
|
729
824
|
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: mkdirHere }, '新建目录'),
|
|
730
825
|
React.createElement('button', { style: { ...buttonS, padding: '2px 8px' }, onClick: () => setPopOpen(false) }, '关闭 ✕'),
|
|
731
826
|
),
|
|
732
827
|
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
733
828
|
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
|
|
734
829
|
: (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
735
|
-
key: i, title: (it.dir ? '进入 ' : '文件: '
|
|
736
|
-
onClick: it.dir ? () => enterDir(it
|
|
830
|
+
key: i, title: it.drive ? '进入驱动器 ' + it.name : (it.dir ? '进入 ' + it.name : '文件: ' + it.name),
|
|
831
|
+
onClick: it.dir ? () => enterDir(it) : undefined,
|
|
737
832
|
style: { padding: '6px 10px', cursor: it.dir ? 'pointer' : 'default', color: T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
|
|
738
833
|
},
|
|
739
|
-
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label } }, it.dir
|
|
834
|
+
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label, fontSize: 13 } }, it.drive ? '💽' : (it.dir
|
|
740
835
|
? React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor' }, React.createElement('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2a1.5 1.5 0 0 1 1.06.44L8.5 3.7h4.5A1.5 1.5 0 0 1 14.5 5.2V12A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5Zm2.5 1a.5.5 0 0 0 0 1h1.2a.5.5 0 0 0 0-1H4Z' }))
|
|
741
|
-
: React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', opacity: 0.85 }, React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' }))),
|
|
836
|
+
: React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', opacity: 0.85 }, React.createElement('path', { d: 'M3.5 1.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V5.2a1 1 0 0 0-.29-.7L10.5 1.79a1 1 0 0 0-.7-.29H3.5Zm1.5 3h2.25a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5ZM5 9.5h6a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Zm0 2.25h4a.75.75 0 0 1 0 1.5H5a.75.75 0 0 1 0-1.5Z' })))),
|
|
742
837
|
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
|
|
743
838
|
React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
|
|
744
839
|
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
|
|
745
840
|
),
|
|
746
841
|
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', padding: '6px 10px', borderTop: '1px solid ' + T.border } },
|
|
747
|
-
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + last.path),
|
|
842
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + (last.path || (win ? '(驱动器根)' : '/'))),
|
|
748
843
|
React.createElement('button', { style: { ...buttonS, fontWeight: 600 }, onClick: () => { acceptBrowserPick(last.path); setPopOpen(false) } }, '选用此路径'),
|
|
749
844
|
),
|
|
750
845
|
)
|
|
@@ -783,7 +878,7 @@ window.__ModuleLoader__.load({
|
|
|
783
878
|
mOpen
|
|
784
879
|
? 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 } },
|
|
785
880
|
machines.length
|
|
786
|
-
? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setRecent(m.recentWorkspaces || []); setLevels(null); setMOpen(false); if (m.id)
|
|
881
|
+
? machines.map((m) => React.createElement('div', { key: m.id, onClick: () => { setMachineId(m.id); setRecent(m.recentWorkspaces || []); setLevels(null); setPath(''); setMOpen(false); if (m.id) loadLevels(m.id, '', 0) }, style: { padding: '6px 8px', borderRadius: 4, cursor: 'pointer', color: m.id === machineId ? T.ok : 'inherit' } }, m.name + ' (' + m.username + '@' + m.host + ':' + m.port + ')'))
|
|
787
882
|
: React.createElement('div', { style: { padding: 6, opacity: 0.6 } }, '还没有机器,请先在设置里添加'),
|
|
788
883
|
)
|
|
789
884
|
: null,
|
|
@@ -795,8 +890,8 @@ window.__ModuleLoader__.load({
|
|
|
795
890
|
recent.map((w, i) => React.createElement('button', { key: w + i, onClick: () => { setPath(w); setErr(''); continueSuggest(w) }, style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace' } }, w))) : null,
|
|
796
891
|
React.createElement('div', { ref: suggestRef, style: { position: 'relative' } },
|
|
797
892
|
React.createElement('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
798
|
-
React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? '输入远程路径(自动补全)' : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
|
|
799
|
-
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (machineId) setPopOpen(true) }, disabled: !machineId }, '浏览…'),
|
|
893
|
+
React.createElement('input', { value: path, onChange: (e) => onPathChange(e.target.value), onFocus: () => loadSuggestions(path), placeholder: (machineId ? (win ? '输入远程路径,如 C:\\Users\\…(自动补全)' : '输入远程路径(自动补全)') : '先选远程机器'), disabled: !machineId, style: { ...inputS, flex: 1, minWidth: 120 } }),
|
|
894
|
+
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: () => { if (!machineId) return; const p = path.trim(); loadLevels(machineId, p || '', 0); setPopOpen(true) }, disabled: !machineId }, '浏览…'),
|
|
800
895
|
React.createElement('button', { style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, '~ 主目录'),
|
|
801
896
|
),
|
|
802
897
|
(suggestOpen && suggest.length)
|
|
@@ -830,10 +925,12 @@ window.__ModuleLoader__.load({
|
|
|
830
925
|
return api('GET', '/dsh-remote/status').catch(() => null)
|
|
831
926
|
}
|
|
832
927
|
|
|
833
|
-
// Resolve the remote path this session
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
928
|
+
// Resolve the remote path this session shows. Issue #13: the ONLY source
|
|
929
|
+
// of truth is the session's own cwd mapped through /dsh-remote/resolve-
|
|
930
|
+
// mirror — a cwd outside any mirror dir means "this session is NOT a
|
|
931
|
+
// remote session", and the sidebar must NOT fall back to the machine
|
|
932
|
+
// workspace (that made an unrelated saved machine leak into local
|
|
933
|
+
// sessions).
|
|
837
934
|
function resolveSessionRemote(sessionId) {
|
|
838
935
|
if (!sessionId) return Promise.resolve('')
|
|
839
936
|
// 1) Prefer the client-side session list cwd (mirror dir under
|
|
@@ -906,6 +1003,8 @@ window.__ModuleLoader__.load({
|
|
|
906
1003
|
const [menu, setMenu] = React.useState(null) // { x, y, path, isDir, name }
|
|
907
1004
|
const [rootOpen, setRootOpen] = React.useState(true) // first level expanded by default
|
|
908
1005
|
const [hoverPath, setHoverPath] = React.useState('') // row hover highlight
|
|
1006
|
+
/** Row whose path was just copied — shows a brief「已复制」label. */
|
|
1007
|
+
const [copiedPath, setCopiedPath] = React.useState('')
|
|
909
1008
|
const expanded = Array.isArray(props.expanded) ? props.expanded : []
|
|
910
1009
|
|
|
911
1010
|
const storeLevel = (path, level) => {
|
|
@@ -913,8 +1012,13 @@ window.__ModuleLoader__.load({
|
|
|
913
1012
|
setData(dataRef.current)
|
|
914
1013
|
}
|
|
915
1014
|
|
|
916
|
-
const loadDir = React.useCallback((dir) => {
|
|
1015
|
+
const loadDir = React.useCallback((dir, opts) => {
|
|
917
1016
|
const cur = dataRef.current
|
|
1017
|
+
const existing = cur[dir]
|
|
1018
|
+
// If we already have data (entries loaded) and this is a background
|
|
1019
|
+
// refresh, keep what we have — don't flash a loading state over a
|
|
1020
|
+
// populated tree.
|
|
1021
|
+
if (existing && !existing.loading && opts && opts.keep) return
|
|
918
1022
|
if (cur[dir] !== undefined && !cur[dir].loading) return
|
|
919
1023
|
storeLevel(dir, { loading: true })
|
|
920
1024
|
listRemoteDir(dir)
|
|
@@ -927,7 +1031,17 @@ window.__ModuleLoader__.load({
|
|
|
927
1031
|
})
|
|
928
1032
|
storeLevel(dir, { entries: sorted })
|
|
929
1033
|
})
|
|
930
|
-
.catch((e) =>
|
|
1034
|
+
.catch((e) => {
|
|
1035
|
+
// Keep any previously-loaded entries under this dir (so a transient
|
|
1036
|
+
// SFTP failure doesn't collapse the whole subtree); surface a
|
|
1037
|
+
// message only when there was nothing to show before.
|
|
1038
|
+
const prev = dataRef.current[dir]
|
|
1039
|
+
if (prev && prev.entries) {
|
|
1040
|
+
storeLevel(dir, { ...prev, softError: String((e && e.message) || e) })
|
|
1041
|
+
} else {
|
|
1042
|
+
storeLevel(dir, { error: String((e && e.message) || e) })
|
|
1043
|
+
}
|
|
1044
|
+
})
|
|
931
1045
|
}, [])
|
|
932
1046
|
|
|
933
1047
|
// Resolve this session's remote root, then load it and every expanded dir.
|
|
@@ -936,13 +1050,32 @@ window.__ModuleLoader__.load({
|
|
|
936
1050
|
// own workspace dir.
|
|
937
1051
|
const scopeCwd = props.scope && props.scope.cwd
|
|
938
1052
|
const sessionId = props.scope && props.scope.sessionId
|
|
939
|
-
const
|
|
940
|
-
|
|
1053
|
+
const rootRef = React.useRef('')
|
|
1054
|
+
const refreshStatus = React.useCallback((opts) => {
|
|
1055
|
+
const applyRoot = (r, connected) => {
|
|
1056
|
+
const prevRoot = rootRef.current
|
|
1057
|
+
rootRef.current = r
|
|
941
1058
|
setRoot(r)
|
|
942
|
-
|
|
943
|
-
|
|
1059
|
+
// Full reset ONLY when the session's remote root actually changed
|
|
1060
|
+
// (different workspace / different conversation). When we just come
|
|
1061
|
+
// back to this tab with the same workspace, keep the loaded tree so
|
|
1062
|
+
// expanded dirs and their contents stay put.
|
|
1063
|
+
const sameRoot = prevRoot && r && prevRoot === r
|
|
1064
|
+
if (!sameRoot) {
|
|
1065
|
+
setData({})
|
|
1066
|
+
dataRef.current = {}
|
|
1067
|
+
}
|
|
944
1068
|
if (r) {
|
|
945
|
-
|
|
1069
|
+
// Not connected (e.g. freshly restarted, machine no longer active):
|
|
1070
|
+
// show a clear disconnected state instead of firing ls requests
|
|
1071
|
+
// that all fail with AggregateError / 500 in a loop.
|
|
1072
|
+
if (!connected) {
|
|
1073
|
+
storeLevel(r, { error: '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)' })
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
if (!sameRoot) {
|
|
1077
|
+
storeLevel(r, { loading: true })
|
|
1078
|
+
}
|
|
946
1079
|
listRemoteDir(r)
|
|
947
1080
|
.then((res) => {
|
|
948
1081
|
const items = Array.isArray(res && res.items) ? res.items : []
|
|
@@ -951,10 +1084,19 @@ window.__ModuleLoader__.load({
|
|
|
951
1084
|
if (a.type !== 'dir' && b.type === 'dir') return 1
|
|
952
1085
|
return String(a.name).localeCompare(String(b.name))
|
|
953
1086
|
})
|
|
954
|
-
storeLevel(r, { entries: sorted })
|
|
955
|
-
for (const dir of expanded) loadDir(dir)
|
|
1087
|
+
storeLevel(r, { entries: sorted, missing: !!(res && res.missing) })
|
|
1088
|
+
for (const dir of expanded) loadDir(dir, { keep: true })
|
|
1089
|
+
})
|
|
1090
|
+
.catch((e) => {
|
|
1091
|
+
// Transient connection failure: keep the previously visible
|
|
1092
|
+
// root level so the tree doesn't collapse to an error.
|
|
1093
|
+
const prev = dataRef.current[r]
|
|
1094
|
+
if (prev && prev.entries) {
|
|
1095
|
+
storeLevel(r, { ...prev, softError: String((e && e.message) || e) })
|
|
1096
|
+
} else {
|
|
1097
|
+
storeLevel(r, { error: String((e && e.message) || e) })
|
|
1098
|
+
}
|
|
956
1099
|
})
|
|
957
|
-
.catch((e) => storeLevel(r, { error: String((e && e.message) || e) }))
|
|
958
1100
|
}
|
|
959
1101
|
}
|
|
960
1102
|
const resolveRoot = () => {
|
|
@@ -969,20 +1111,47 @@ window.__ModuleLoader__.load({
|
|
|
969
1111
|
resolveRoot().then((r) => {
|
|
970
1112
|
fetchRemoteStatus().then((s) => {
|
|
971
1113
|
setStatus(s)
|
|
972
|
-
|
|
1114
|
+
// Issue #13: NEVER fall back to the machine-level workspace here —
|
|
1115
|
+
// a session whose cwd is not a mirror is a LOCAL session and shows
|
|
1116
|
+
// the "no remote workspace" empty state instead.
|
|
1117
|
+
applyRoot(r || '', !!(s && s.connected))
|
|
973
1118
|
})
|
|
974
1119
|
})
|
|
975
1120
|
}, [scopeCwd, sessionId])
|
|
976
1121
|
|
|
977
1122
|
React.useEffect(() => { refreshStatus() }, [refreshStatus])
|
|
978
|
-
React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
|
|
979
|
-
// Load dirs as they get expanded.
|
|
980
|
-
React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir) }, [root, expanded, loadDir])
|
|
1123
|
+
React.useEffect(() => { if (props.visible) refreshStatus({ visible: true }) }, [props.visible])
|
|
1124
|
+
// Load dirs as they get expanded (keep already-loaded content).
|
|
1125
|
+
React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir, { keep: true }) }, [root, expanded, loadDir])
|
|
1126
|
+
// ── Context-menu portal: append to document.body so the sidebar's
|
|
1127
|
+
// overflow/transform cannot clip it. Pure DOM, no React portal needed.
|
|
981
1128
|
React.useEffect(() => {
|
|
982
1129
|
if (!menu) return
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
1130
|
+
const host = document.createElement('div')
|
|
1131
|
+
host.style.cssText = 'position:fixed;z-index:999999;pointer-events:auto'
|
|
1132
|
+
const box = document.createElement('div')
|
|
1133
|
+
box.style.cssText = 'position:fixed;left:' + menu.x + 'px;top:' + menu.y + 'px;z-index:999999;background:#1f1f23;border:1px solid rgba(128,128,128,0.5);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.4);min-width:170;font-family:system-ui,sans-serif'
|
|
1134
|
+
const mkItem = (label, danger, fn) => {
|
|
1135
|
+
const d = document.createElement('div')
|
|
1136
|
+
d.textContent = label
|
|
1137
|
+
d.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;color:' + (danger ? '#e06c75' : '#e4e4e7') + ';border-radius:4px;white-space:nowrap;transition:background 0.1s'
|
|
1138
|
+
d.onmouseenter = () => { d.style.background = 'rgba(128,128,128,0.14)' }
|
|
1139
|
+
d.onmouseleave = () => { d.style.background = 'transparent' }
|
|
1140
|
+
d.addEventListener('click', (e) => { e.stopPropagation(); setMenu(null); fn() })
|
|
1141
|
+
return d
|
|
1142
|
+
}
|
|
1143
|
+
box.appendChild(mkItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }))
|
|
1144
|
+
box.appendChild(mkItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })))
|
|
1145
|
+
box.appendChild(mkItem('📋 复制相对地址', false, () => copyPath(relPath(menu.path), menu.path)))
|
|
1146
|
+
box.appendChild(mkItem('📋 复制绝对地址', false, () => copyPath(menu.path, menu.path)))
|
|
1147
|
+
box.appendChild(mkItem('✏ 重命名', false, () => { const nm = window.prompt('重命名为(新名字)', menu.name); if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true) }))
|
|
1148
|
+
box.appendChild(mkItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => { if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true) }))
|
|
1149
|
+
host.appendChild(box)
|
|
1150
|
+
document.body.appendChild(host)
|
|
1151
|
+
const close = (e) => { if (!box.contains(e.target)) { setMenu(null) } }
|
|
1152
|
+
// Delay adding listener so the same mousedown that opened the menu doesn't close it
|
|
1153
|
+
const tid = setTimeout(() => document.addEventListener('mousedown', close), 0)
|
|
1154
|
+
return () => { clearTimeout(tid); document.removeEventListener('mousedown', close); host.remove() }
|
|
986
1155
|
}, [menu])
|
|
987
1156
|
|
|
988
1157
|
const openFile = (p) => {
|
|
@@ -1015,6 +1184,32 @@ window.__ModuleLoader__.load({
|
|
|
1015
1184
|
style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
|
|
1016
1185
|
}, label)
|
|
1017
1186
|
|
|
1187
|
+
// The path shown by「复制相对地址」: relative to this session's remote
|
|
1188
|
+
// workspace root (mirror of the built-in local tree's cwd-relative copy).
|
|
1189
|
+
const relPath = (p) => {
|
|
1190
|
+
if (!root) return p
|
|
1191
|
+
const r = String(root).replace(/[\\/]+$/, '')
|
|
1192
|
+
const s = String(p)
|
|
1193
|
+
if (s === r) return '.'
|
|
1194
|
+
if (s.startsWith(r + '/')) return s.slice(r.length + 1)
|
|
1195
|
+
if (s.startsWith(r + '\\')) return s.slice(r.length + 1)
|
|
1196
|
+
return s
|
|
1197
|
+
}
|
|
1198
|
+
// Copy `text`, then briefly flag the row whose path was copied.
|
|
1199
|
+
const copyPath = (text, path) => {
|
|
1200
|
+
copyToClipboard(text).then((ok) => {
|
|
1201
|
+
if (!ok) return
|
|
1202
|
+
setCopiedPath(path)
|
|
1203
|
+
window.setTimeout(() => {
|
|
1204
|
+
setCopiedPath((cur) => (cur === path ? '' : cur))
|
|
1205
|
+
}, 1200)
|
|
1206
|
+
})
|
|
1207
|
+
}
|
|
1208
|
+
/** Trailing「已复制」feedback for a row (after a successful copy). */
|
|
1209
|
+
const copiedMark = (path) => (copiedPath === path
|
|
1210
|
+
? React.createElement('span', { style: { fontSize: 11, opacity: 0.75, flexShrink: 0, color: T.ok } }, '已复制')
|
|
1211
|
+
: null)
|
|
1212
|
+
|
|
1018
1213
|
// One tree row (dir toggles via the framework's onToggleDir; file opens).
|
|
1019
1214
|
// Text uses the normal foreground (theme-following) — no accent green.
|
|
1020
1215
|
const row = (entry, depth) => {
|
|
@@ -1042,6 +1237,7 @@ window.__ModuleLoader__.load({
|
|
|
1042
1237
|
},
|
|
1043
1238
|
React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(isOpen)),
|
|
1044
1239
|
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, fontWeight: 500 } }, entry.name),
|
|
1240
|
+
copiedMark(entry.path),
|
|
1045
1241
|
),
|
|
1046
1242
|
isOpen ? renderLevel(entry.path, depth + 1) : null,
|
|
1047
1243
|
)
|
|
@@ -1058,7 +1254,11 @@ window.__ModuleLoader__.load({
|
|
|
1058
1254
|
},
|
|
1059
1255
|
React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex' } }, IconFile()),
|
|
1060
1256
|
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, entry.name),
|
|
1061
|
-
|
|
1257
|
+
copiedMark(entry.path),
|
|
1258
|
+
(entry.size != null || entry.mtime)
|
|
1259
|
+
? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } },
|
|
1260
|
+
humanSize(entry.size) + (entry.mtime ? ' ' + fmtTime(entry.mtime) : ''))
|
|
1261
|
+
: null,
|
|
1062
1262
|
)
|
|
1063
1263
|
}
|
|
1064
1264
|
|
|
@@ -1067,12 +1267,21 @@ window.__ModuleLoader__.load({
|
|
|
1067
1267
|
if (level === undefined || (level.loading && !level.entries)) {
|
|
1068
1268
|
return React.createElement('div', { key: dir + ':loading', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '加载中…')
|
|
1069
1269
|
}
|
|
1270
|
+
if (level.missing) {
|
|
1271
|
+
return React.createElement('div', { key: dir + ':missing', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, opacity: 0.6 } }, '目录不存在或已被删除')
|
|
1272
|
+
}
|
|
1070
1273
|
if (level.error !== undefined) {
|
|
1071
1274
|
return React.createElement('div', { key: dir + ':err', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 13, color: '#e06c75' } }, level.error)
|
|
1072
1275
|
}
|
|
1073
1276
|
const items = level.entries || []
|
|
1074
|
-
|
|
1075
|
-
|
|
1277
|
+
const soft = level.softError
|
|
1278
|
+
? React.createElement('div', { key: dir + ':softerr', style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: depth * 22 + 6, fontSize: 11, opacity: 0.6, color: '#e6c07b' } }, '⚠ ' + level.softError + '(显示缓存,可点 ↻ 刷新)')
|
|
1279
|
+
: null
|
|
1280
|
+
if (!items.length) return soft || null
|
|
1281
|
+
return React.createElement('div', { key: dir + ':list' },
|
|
1282
|
+
items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)),
|
|
1283
|
+
soft,
|
|
1284
|
+
)
|
|
1076
1285
|
}
|
|
1077
1286
|
|
|
1078
1287
|
const rootName = root ? String(root).split(/[\\/]/).filter(Boolean).pop() || root : ''
|
|
@@ -1087,15 +1296,15 @@ window.__ModuleLoader__.load({
|
|
|
1087
1296
|
}, '新建目录'),
|
|
1088
1297
|
React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
|
|
1089
1298
|
),
|
|
1090
|
-
status && status.workspace
|
|
1299
|
+
status && status.workspace && root
|
|
1091
1300
|
? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + root + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
|
|
1092
|
-
: React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区'),
|
|
1301
|
+
: React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, root ? '未设置远程工作区' : '当前会话未使用远程工作区'),
|
|
1093
1302
|
picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
|
|
1094
1303
|
open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
|
|
1095
1304
|
})) : null,
|
|
1096
1305
|
React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
|
|
1097
1306
|
!root
|
|
1098
|
-
? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '
|
|
1307
|
+
? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。')
|
|
1099
1308
|
: React.createElement('div', { key: root },
|
|
1100
1309
|
React.createElement('div', {
|
|
1101
1310
|
role: 'button', tabIndex: 0,
|
|
@@ -1111,18 +1320,8 @@ window.__ModuleLoader__.load({
|
|
|
1111
1320
|
rootLevel && rootOpen ? renderLevel(root, 1) : null,
|
|
1112
1321
|
),
|
|
1113
1322
|
),
|
|
1114
|
-
// context menu
|
|
1115
|
-
|
|
1116
|
-
ctxItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }),
|
|
1117
|
-
ctxItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })),
|
|
1118
|
-
ctxItem('✏ 重命名', false, () => {
|
|
1119
|
-
const nm = window.prompt('重命名为(新名字)', menu.name)
|
|
1120
|
-
if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true)
|
|
1121
|
-
}),
|
|
1122
|
-
ctxItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => {
|
|
1123
|
-
if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true)
|
|
1124
|
-
}),
|
|
1125
|
-
) : null,
|
|
1323
|
+
// context menu — now rendered via DOM portal in useEffect above
|
|
1324
|
+
null,
|
|
1126
1325
|
err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
|
|
1127
1326
|
)
|
|
1128
1327
|
}
|
|
@@ -1243,6 +1442,10 @@ window.__ModuleLoader__.load({
|
|
|
1243
1442
|
if (!snap || !snap.sessionId) return
|
|
1244
1443
|
if (autoOpenedFor.has(snap.sessionId)) return
|
|
1245
1444
|
const sessionId = snap.sessionId
|
|
1445
|
+
// Issue #13: only auto-open the Remote Files tab for sessions that are
|
|
1446
|
+
// genuinely remote (cwd inside a mirror). A plain local session must
|
|
1447
|
+
// not get a remote tab popped open by a machine that happens to be
|
|
1448
|
+
// saved as "current".
|
|
1246
1449
|
resolveSessionRemote(sessionId).then((remotePath) => {
|
|
1247
1450
|
if (!remotePath || autoOpenedFor.has(sessionId)) return
|
|
1248
1451
|
autoOpenedFor.add(sessionId)
|