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/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) { setTst('ok'); setMsg(r.latencyMs != null ? `连接成功(${r.user}@${r.host},${r.latencyMs}ms)` : '连接成功'); refresh() }
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
- function breadcrumb(active, cur, jumpTo) {
406
- const norm = String(cur || '').replace(/\/+$/, '')
407
- const segs = norm === '' || norm === '/' ? [] : norm.split('/')
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
- crumbs.push(React.createElement('span', { key: 'root', style: { cursor: active ? 'pointer' : 'default', color: active ? T.ok : T.muted }, onClick: active ? () => jumpTo('/') : undefined }, '/'))
410
- let acc = ''
411
- for (const s of segs) {
412
- if (!s) continue
413
- acc += '/' + s
414
- crumbs.push(React.createElement('span', { key: s + '|' + acc, style: { color: T.muted } }, '/'))
415
- crumbs.push(React.createElement('span', {
416
- key: acc,
417
- style: { cursor: active ? 'pointer' : 'default', color: active ? T.label : T.muted, fontWeight: acc === norm ? 700 : 400, whiteSpace: 'nowrap' },
418
- onClick: active ? () => jumpTo(acc) : undefined,
419
- }, s))
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 (!crumbs.length) crumbs.push(React.createElement('span', { key: 'empty', style: { color: T.muted } }, '/'))
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
- const enterDir = (name) => {
550
- if (busy || loading) return
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
- const base = last && last.path ? last.path : ''
553
- const sep = base.includes('\\') ? '\\' : '/'
554
- const next = base === '/' ? '/' + name : (base ? base + sep + name : name)
555
- loadLevels(machineId, next, (levels ? levels.length : 0))
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
- const isWin = t.includes('\\')
564
- const sep = isWin ? '\\' : '/'
565
- const slash = t.lastIndexOf(sep)
566
- const parent = slash <= 0 ? (isWin ? t.match(/^[a-zA-Z]:\\?/) ? t : '' : '/') : t.slice(0, slash)
567
- const lastSeg = slash < 0 ? t : t.slice(slash + 1)
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
- : parseLs(res && res.text)
574
- const base = parent === '/' ? '/' : parent
575
- const matches = list.filter((it) => it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
576
- .slice(0, 40).map((it) => (base === '/' || !base ? sep + it.name : base + sep + it.name))
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
- : parseLs(res && res.text)
598
- const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
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: () => setLevels((p) => p && p.length > 1 ? p.slice(0, p.length - 1) : p), disabled: levels.length <= 1 || loading }, '回上一级 ▴'),
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 ? '进入 ' : '文件: ') + it.name,
736
- onClick: it.dir ? () => enterDir(it.name) : undefined,
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) { 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 + ')'))
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 should show: prefer the session's
834
- // own cwd (a remote mirror dir) mapped through /dsh-remote/resolve-mirror;
835
- // fall back to the machine workspace when the session has no cwd or the
836
- // host side has no mirror match.
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) => storeLevel(dir, { error: String((e && e.message) || 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 refreshStatus = React.useCallback(() => {
940
- const applyRoot = (r) => {
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
- setData({})
943
- dataRef.current = {}
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
- storeLevel(r, { loading: true })
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
- applyRoot(r || (s && s.workspace) || '')
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 close = () => setMenu(null)
984
- document.addEventListener('mousedown', close)
985
- return () => document.removeEventListener('mousedown', close)
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
- entry.size != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, humanSize(entry.size)) : null,
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
- if (!items.length) return null
1075
- return React.createElement('div', { key: dir + ':list' }, items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)))
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
- menu ? React.createElement('div', { style: { position: 'fixed', left: menu.x, top: menu.y, zIndex: 9998, background: v('--dsw-alias-bg-overlay', '#1f1f23'), border: '1px solid ' + T.borderStrong, borderRadius: 8, padding: 4, boxShadow: '0 8px 24px rgba(0,0,0,0.4)', minWidth: 170 }, onClick: (e) => e.stopPropagation() },
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)