dsh-remote 0.8.3 → 0.8.5

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
@@ -87,6 +87,7 @@ window.__ModuleLoader__.load({
87
87
  }
88
88
 
89
89
  let WORKSPACES = null // ctx.get('workspaces'), set in apply()
90
+ let SESSIONS = null // ctx.get('sessions'), set in apply()
90
91
 
91
92
  // ── Settings → 远程工作区 (machine registry + forwards + audit) ──────────
92
93
  function RemoteWorkspacePage() {
@@ -562,15 +563,21 @@ window.__ModuleLoader__.load({
562
563
  ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir' }))
563
564
  : parseLs(res && res.text)
564
565
  const base = String(dir).replace(/[\\/]+$/, '') === '' ? '/' : String(dir).replace(/[\\/]+$/, '')
565
- const kids = list.filter((it) => it.dir).slice(0, 40).map((it) => (base === '/' ? '/' + it.name : base + sep + it.name))
566
+ // Show every entry (dirs + files), matching loadSuggestions — the
567
+ // previous dir-only filter made the next-level list look truncated.
568
+ const kids = list.slice(0, 40).map((it) => (base === '/' ? sep + it.name : base + sep + it.name))
566
569
  setSuggest(kids)
567
570
  setSuggestOpen(!!kids.length)
568
571
  }).catch(() => { setSuggest([]); setSuggestOpen(false) })
569
572
  }
570
573
 
571
574
  const selectSuggestion = (s) => {
572
- setPath(s); setErr(''); setSuggestOpen(false)
573
- continueSuggest(s)
575
+ // Auto-append the trailing separator so the path reads as a directory
576
+ // and the user can keep typing the next segment right away.
577
+ const sep = String(s).includes('\\') ? '\\' : '/'
578
+ const withSep = String(s).replace(/[\\/]+$/, '') + sep
579
+ setPath(withSep); setErr(''); setSuggestOpen(false)
580
+ continueSuggest(withSep)
574
581
  }
575
582
 
576
583
  const commitPath = (p) => {
@@ -623,9 +630,11 @@ window.__ModuleLoader__.load({
623
630
  : (entries.length ? entries.slice(0, 400).map((it, i) => React.createElement('div', {
624
631
  key: i, title: (it.dir ? '进入 ' : '文件: ') + it.name,
625
632
  onClick: it.dir ? () => enterDir(it.name) : undefined,
626
- 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 },
633
+ 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 },
627
634
  },
628
- React.createElement('span', null, it.dir ? '📁' : '📄'),
635
+ React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex', color: T.label } }, it.dir
636
+ ? 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' }))
637
+ : 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' }))),
629
638
  React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
630
639
  React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, it.dir ? '' : (humanSize(it.size) + (it.mtime ? ' ' + fmtTime(it.mtime) : ''))),
631
640
  )) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(空目录)')),
@@ -716,6 +725,33 @@ window.__ModuleLoader__.load({
716
725
  return api('GET', '/dsh-remote/status').catch(() => null)
717
726
  }
718
727
 
728
+ // Resolve the remote path this session should show: prefer the session's
729
+ // own cwd (a remote mirror dir) mapped through /dsh-remote/resolve-mirror;
730
+ // fall back to the machine workspace when the session has no cwd or the
731
+ // host side has no mirror match.
732
+ function resolveSessionRemote(sessionId) {
733
+ if (!sessionId) return Promise.resolve('')
734
+ // 1) Prefer the client-side session list cwd (mirror dir under
735
+ // $DSH_HOME/remote-workspaces/...) passed as ?local=.
736
+ let local = ''
737
+ try {
738
+ const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
739
+ ? SESSIONS.list.getSnapshot()
740
+ : null
741
+ const entry = list && list.byId && list.byId[sessionId]
742
+ if (entry && entry.cwd) local = String(entry.cwd)
743
+ } catch { /* sessions store shape differs */ }
744
+ if (local) {
745
+ return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(local))
746
+ .then((r) => (r && r.remotePath) || '')
747
+ .catch(() => '')
748
+ }
749
+ // 2) Fall back to host-side session header cwd.
750
+ return api('GET', '/dsh-remote/resolve-mirror?sessionId=' + encodeURIComponent(sessionId))
751
+ .then((r) => (r && r.remotePath) || '')
752
+ .catch(() => '')
753
+ }
754
+
719
755
  function readRemoteFile(path, maxBytes) {
720
756
  return api('POST', '/dsh-remote/read', { path, maxBytes }).catch((e) => {
721
757
  throw new Error('远程读取失败: ' + ((e && e.message) || e))
@@ -734,31 +770,109 @@ window.__ModuleLoader__.load({
734
770
  }
735
771
 
736
772
  // ── Remote explorer tab ────────────────────────────────────────────────
773
+ // Inline monochrome icons (fill: currentColor → follows the row text color,
774
+ // i.e. black on the sidebar). Folder open/closed + file.
775
+ const IconFolder = (open) => React.createElement('svg', {
776
+ width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0 },
777
+ },
778
+ open
779
+ ? 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' })
780
+ : 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' }),
781
+ )
782
+ const IconFile = () => React.createElement('svg', {
783
+ width: 14, height: 14, viewBox: '0 0 16 16', fill: 'currentColor', style: { flexShrink: 0, opacity: 0.85 },
784
+ },
785
+ 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' }),
786
+ )
787
+
788
+ // Tree-style remote file explorer. Uses the same `expanded` / `onToggleDir`
789
+ // contract as the built-in local file tree (better-sidebar framework
790
+ // manages + persists the expanded set), so interaction and look match the
791
+ // local files tab. Data is listed live from the remote host over
792
+ // /dsh-remote/ls, rooted at this session's remote workspace dir.
737
793
  function RemoteExplorerTab(props) {
738
794
  const [status, setStatus] = React.useState(null)
739
- const [levels, setLevels] = React.useState([])
795
+ const [root, setRoot] = React.useState('') // remote root path for this session
796
+ const [data, setData] = React.useState({}) // dir -> { entries | error | loading }
797
+ const dataRef = React.useRef(data)
740
798
  const [err, setErr] = React.useState('')
741
- const [loading, setLoading] = React.useState(false)
742
- const [picker, setPicker] = React.useState(false)
743
- const [menu, setMenu] = React.useState(null) // { x, y, item }
744
799
  const [busy, setBusy] = React.useState('')
800
+ const [picker, setPicker] = React.useState(false)
801
+ const [menu, setMenu] = React.useState(null) // { x, y, path, isDir, name }
802
+ const [rootOpen, setRootOpen] = React.useState(true) // first level expanded by default
803
+ const [hoverPath, setHoverPath] = React.useState('') // row hover highlight
804
+ const expanded = Array.isArray(props.expanded) ? props.expanded : []
805
+
806
+ const storeLevel = (path, level) => {
807
+ dataRef.current = { ...dataRef.current, [path]: level }
808
+ setData(dataRef.current)
809
+ }
745
810
 
746
- const refreshStatus = () => {
747
- fetchRemoteStatus().then((s) => {
748
- setStatus(s)
749
- if (s && s.workspace) {
750
- setLevels((prev) => {
751
- if (!prev.length) {
752
- loadDir(s.workspace, 0)
753
- return [{ path: s.workspace, items: null }]
754
- }
755
- return prev
811
+ const loadDir = React.useCallback((dir) => {
812
+ const cur = dataRef.current
813
+ if (cur[dir] !== undefined && !cur[dir].loading) return
814
+ storeLevel(dir, { loading: true })
815
+ listRemoteDir(dir)
816
+ .then((res) => {
817
+ const items = Array.isArray(res && res.items) ? res.items : []
818
+ const sorted = items.slice().sort((a, b) => {
819
+ if (a.type === 'dir' && b.type !== 'dir') return -1
820
+ if (a.type !== 'dir' && b.type === 'dir') return 1
821
+ return String(a.name).localeCompare(String(b.name))
756
822
  })
823
+ storeLevel(dir, { entries: sorted })
824
+ })
825
+ .catch((e) => storeLevel(dir, { error: String((e && e.message) || e) }))
826
+ }, [])
827
+
828
+ // Resolve this session's remote root, then load it and every expanded dir.
829
+ // Re-runs whenever the session scope changes (switching conversations, or
830
+ // the async sessionCwd fetch landing), so the sidebar follows the session's
831
+ // own workspace dir.
832
+ const scopeCwd = props.scope && props.scope.cwd
833
+ const sessionId = props.scope && props.scope.sessionId
834
+ const refreshStatus = React.useCallback(() => {
835
+ const applyRoot = (r) => {
836
+ setRoot(r)
837
+ setData({})
838
+ dataRef.current = {}
839
+ if (r) {
840
+ storeLevel(r, { loading: true })
841
+ listRemoteDir(r)
842
+ .then((res) => {
843
+ const items = Array.isArray(res && res.items) ? res.items : []
844
+ const sorted = items.slice().sort((a, b) => {
845
+ if (a.type === 'dir' && b.type !== 'dir') return -1
846
+ if (a.type !== 'dir' && b.type === 'dir') return 1
847
+ return String(a.name).localeCompare(String(b.name))
848
+ })
849
+ storeLevel(r, { entries: sorted })
850
+ for (const dir of expanded) loadDir(dir)
851
+ })
852
+ .catch((e) => storeLevel(r, { error: String((e && e.message) || e) }))
853
+ }
854
+ }
855
+ const resolveRoot = () => {
856
+ if (scopeCwd) {
857
+ return api('GET', '/dsh-remote/resolve-mirror?local=' + encodeURIComponent(scopeCwd))
858
+ .then((r) => (r && r.remotePath) || '')
859
+ .catch(() => '')
757
860
  }
861
+ if (sessionId) return resolveSessionRemote(sessionId)
862
+ return Promise.resolve('')
863
+ }
864
+ resolveRoot().then((r) => {
865
+ fetchRemoteStatus().then((s) => {
866
+ setStatus(s)
867
+ applyRoot(r || (s && s.workspace) || '')
868
+ })
758
869
  })
759
- }
760
- React.useEffect(() => { refreshStatus() }, [])
870
+ }, [scopeCwd, sessionId])
871
+
872
+ React.useEffect(() => { refreshStatus() }, [refreshStatus])
761
873
  React.useEffect(() => { if (props.visible) refreshStatus() }, [props.visible])
874
+ // Load dirs as they get expanded.
875
+ React.useEffect(() => { if (root) for (const dir of expanded) loadDir(dir) }, [root, expanded, loadDir])
762
876
  React.useEffect(() => {
763
877
  if (!menu) return
764
878
  const close = () => setMenu(null)
@@ -766,143 +880,142 @@ window.__ModuleLoader__.load({
766
880
  return () => document.removeEventListener('mousedown', close)
767
881
  }, [menu])
768
882
 
769
- const loadDir = (p, idx) => {
770
- if (loading) return
771
- setLoading(true); setErr('')
772
- listRemoteDir(p).then((res) => {
773
- const items = Array.isArray(res && res.items) ? res.items : []
774
- const sorted = items.slice().sort((a, b) => {
775
- if (a.type === 'dir' && b.type !== 'dir') return -1
776
- if (a.type !== 'dir' && b.type === 'dir') return 1
777
- return String(a.name).localeCompare(String(b.name))
778
- })
779
- setLevels((prev) => {
780
- const base = prev.length ? prev.slice() : [{ path: p, items: null }]
781
- const list = sorted.map((it) => ({ name: it.name, type: it.type === 'dir' ? 'dir' : 'file', size: it.size, mtime: it.mtime }))
782
- if (idx < 0 || idx >= base.length) return base.concat([{ path: p, items: list }])
783
- base[idx] = { path: p, items: list }
784
- return base.slice(0, idx + 1)
785
- })
786
- }).catch((e) => setErr(String((e && e.message) || e)))
787
- .finally(() => setLoading(false))
788
- }
789
-
790
- const enterDir = (name) => {
791
- const last = levels.length ? levels[levels.length - 1] : null
792
- if (!last || !last.items) return
793
- const base = last.path
794
- const sep = base.includes('\\') ? '\\' : '/'
795
- const next = base === '/' ? '/' + name : base + sep + name
796
- loadDir(next, levels.length)
797
- }
798
- const jumpTo = (p) => {
799
- // Breadcrumb segments may lack the leading separator (built by
800
- // splitting the absolute path), so normalize to the absolute form
801
- // before matching the level stack.
802
- const target = p.startsWith('/') || p.startsWith('\\') || /^[A-Za-z]:/.test(p) ? p : '/' + p
803
- const idx = levels.findIndex((lv) => lv.path === target)
804
- if (idx < 0) return
805
- setLevels(levels.slice(0, idx + 1))
806
- }
807
883
  const openFile = (p) => {
808
884
  const name = String(p).split(/[\\/]/).pop() || p
809
885
  props.ctx.betterSidebar.openTab({ type: SIDEBAR_FILE_ID, title: name, path: p }, props.scope)
810
886
  }
811
887
 
812
- const doFs = (op, payload) => {
888
+ const doFs = (op, payload, refreshParent) => {
813
889
  setBusy(op)
814
890
  return api('POST', '/dsh-remote/fs', { op, ...payload })
815
891
  .then(() => {
816
- const last = levels.length ? levels[levels.length - 1] : null
817
- if (last) loadDir(last.path, levels.length - 1)
818
892
  setErr('')
893
+ if (refreshParent) {
894
+ dataRef.current = {}
895
+ setData({})
896
+ if (root) { storeLevel(root, { loading: true }); loadDir(root) }
897
+ for (const dir of expanded) loadDir(dir)
898
+ }
819
899
  })
820
900
  .catch((e) => setErr(String((e && e.message) || e)))
821
901
  .finally(() => setBusy(''))
822
902
  }
823
903
 
824
- const onCtx = (e, item) => {
904
+ const onCtx = (e, path, isDir, name) => {
825
905
  e.preventDefault(); e.stopPropagation()
826
- setMenu({ x: e.clientX, y: e.clientY, item })
906
+ setMenu({ x: e.clientX, y: e.clientY, path, isDir, name })
827
907
  }
828
- const curPath = levels.length ? levels[levels.length - 1].path : ''
829
- const menuFull = menu ? joinRemote(curPath, menu.item.name) : ''
830
-
831
- const row = (it, i) => React.createElement('div', {
832
- key: it.name + '|' + i,
833
- title: (it.type === 'dir' ? '进入目录 ' : '打开文件 ') + it.name,
834
- onClick: () => (it.type === 'dir' ? enterDir(it.name) : openFile(joinRemote(curPath, it.name))),
835
- onContextMenu: (e) => onCtx(e, it),
836
- 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)' },
837
- },
838
- React.createElement('span', { style: { flexShrink: 0 } }, it.type === 'dir' ? '📁' : '📄'),
839
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, it.name),
840
- it.type !== 'dir' ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, humanSize(it.size)) : null)
841
-
842
- const rootHint = status && status.workspace
843
- ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + status.workspace + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
844
- : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区')
845
-
846
- const allItems = levels.length ? (levels[levels.length - 1].items || []) : []
847
- const segs = String(curPath || '').split(/[\\/]+/).filter(Boolean)
848
- 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([]) } }, '⌂')]
849
- let acc = ''
850
- segs.forEach((s, i) => {
851
- acc = acc ? acc + '/' + s : s
852
- const isLast = i === segs.length - 1
853
- 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))
854
- })
855
-
856
908
  const ctxItem = (label, danger, fn) => React.createElement('div', {
857
909
  onClick: (e) => { e.stopPropagation(); setMenu(null); fn() },
858
910
  style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, color: danger ? '#e06c75' : '#e4e4e7', borderRadius: 4, whiteSpace: 'nowrap' },
859
911
  }, label)
860
912
 
861
- // Explicit "up one level" button: the breadcrumb line ellipsizes and
862
- // its segments may be hard to hit in a narrow sidebar, so give the
863
- // user a direct way back to the parent level.
864
- const goUpOneLevel = () => {
865
- if (levels.length <= 1) return
866
- setLevels(levels.slice(0, levels.length - 1))
913
+ // One tree row (dir toggles via the framework's onToggleDir; file opens).
914
+ // Text uses the normal foreground (theme-following) — no accent green.
915
+ const row = (entry, depth) => {
916
+ const pad = depth * 22 + 6
917
+ const hovered = hoverPath === entry.path
918
+ const base = {
919
+ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: pad,
920
+ borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap',
921
+ overflow: 'hidden', textOverflow: 'ellipsis', transition: 'background 0.12s ease',
922
+ background: hovered ? 'rgba(128,128,128,0.14)' : 'transparent',
923
+ color: T.label,
924
+ }
925
+ if (entry.type === 'dir') {
926
+ const isOpen = expanded.includes(entry.path)
927
+ return React.createElement('div', { key: entry.path },
928
+ React.createElement('div', {
929
+ role: 'button', tabIndex: 0,
930
+ onClick: () => { if (props.onToggleDir) props.onToggleDir(entry.path) },
931
+ onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.onToggleDir) props.onToggleDir(entry.path) } },
932
+ onContextMenu: (e) => onCtx(e, entry.path, true, entry.name),
933
+ onMouseEnter: () => setHoverPath(entry.path),
934
+ onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
935
+ style: base,
936
+ title: entry.path,
937
+ },
938
+ React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(isOpen)),
939
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, fontWeight: 500 } }, entry.name),
940
+ ),
941
+ isOpen ? renderLevel(entry.path, depth + 1) : null,
942
+ )
943
+ }
944
+ return React.createElement('div', {
945
+ role: 'button', tabIndex: 0, key: entry.path,
946
+ onClick: () => openFile(entry.path),
947
+ onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(entry.path) } },
948
+ onContextMenu: (e) => onCtx(e, entry.path, false, entry.name),
949
+ onMouseEnter: () => setHoverPath(entry.path),
950
+ onMouseLeave: () => setHoverPath((p) => (p === entry.path ? '' : p)),
951
+ style: base,
952
+ title: entry.path,
953
+ },
954
+ React.createElement('span', { style: { flexShrink: 0, display: 'inline-flex' } }, IconFile()),
955
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, entry.name),
956
+ entry.size != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.55, flexShrink: 0, fontFamily: 'monospace' } }, humanSize(entry.size)) : null,
957
+ )
958
+ }
959
+
960
+ const renderLevel = (dir, depth) => {
961
+ const level = data[dir]
962
+ if (level === undefined || (level.loading && !level.entries)) {
963
+ 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 } }, '加载中…')
964
+ }
965
+ if (level.error !== undefined) {
966
+ 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)
967
+ }
968
+ const items = level.entries || []
969
+ if (!items.length) return null
970
+ return React.createElement('div', { key: dir + ':list' }, items.map((it) => row({ ...it, path: joinRemote(dir, it.name) }, depth)))
867
971
  }
868
972
 
973
+ const rootName = root ? String(root).split(/[\\/]/).filter(Boolean).pop() || root : ''
974
+ const rootLevel = data[root]
975
+
869
976
  return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
870
977
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid rgba(128,128,128,0.25)' } },
871
- 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) } }, '↻'),
978
+ React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: refreshStatus, title: '刷新' }, '↻'),
872
979
  React.createElement('button', {
873
980
  style: { ...buttonS, padding: '3px 8px', fontSize: 12 },
874
- onClick: goUpOneLevel,
875
- disabled: levels.length <= 1,
876
- title: '返回上一级目录',
877
- }, '↑ 上一级'),
878
- React.createElement('div', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, fontFamily: 'monospace' } }, crumbs),
879
- React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => { const last = levels.length ? levels[levels.length - 1] : null; const base = (last && last.path) || '/'; const nm = window.prompt('新建目录名(在 ' + base + ' 下)'); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(base, nm.trim()) }) } }, '新建目录'),
981
+ onClick: () => { const nm = window.prompt('新建目录名(在 ' + (root || '/') + ' 下)'); if (nm && nm.trim()) doFs('mkdir', { path: joinRemote(root, nm.trim()) }, true) },
982
+ }, '新建目录'),
880
983
  React.createElement('button', { style: { ...buttonS, padding: '3px 8px', fontSize: 12 }, onClick: () => setPicker(true) }, '…'),
881
984
  ),
882
- rootHint,
985
+ status && status.workspace
986
+ ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, '远程工作区: ' + root + (status.host ? ' (' + status.username + '@' + status.host + ')' : ''))
987
+ : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, '未设置远程工作区'),
883
988
  picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
884
- open: true, busy: false, onPicked: (localMirror) => {
885
- setPicker(false)
886
- refreshStatus()
887
- fetchRemoteStatus().then((s) => { if (s && s.workspace) { setLevels([{ path: s.workspace, items: null }]); loadDir(s.workspace, 0) } })
888
- }, onCancel: () => setPicker(false),
989
+ open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
889
990
  })) : null,
890
- React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0 } },
891
- loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12, fontSize: 13 } }, '加载中…')
892
- : (allItems.length ? allItems.slice(0, 500).map(row)
893
- : (curPath ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '(空目录)')
894
- : React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区'))),
991
+ React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
992
+ !root
993
+ ? React.createElement('div', { style: { opacity: 0.6, padding: 12, fontSize: 13 } }, '选择或设置一个远程工作区')
994
+ : React.createElement('div', { key: root },
995
+ React.createElement('div', {
996
+ role: 'button', tabIndex: 0,
997
+ onClick: () => setRootOpen((v) => !v),
998
+ onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRootOpen((v) => !v) } },
999
+ onContextMenu: (e) => onCtx(e, root, true, rootName),
1000
+ style: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', paddingLeft: 6, borderRadius: 5, cursor: 'pointer', fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: T.label, fontWeight: 600 },
1001
+ title: root,
1002
+ },
1003
+ React.createElement('span', { style: { flexShrink: 0, color: T.label, display: 'inline-flex' } }, IconFolder(rootOpen)),
1004
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 } }, rootName || root),
1005
+ ),
1006
+ rootLevel && rootOpen ? renderLevel(root, 1) : null,
1007
+ ),
895
1008
  ),
896
1009
  // context menu
897
1010
  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() },
898
- ctxItem(menu.item.type === 'dir' ? '📁 进入目录' : '📄 打开文件', false, () => (menu.item.type === 'dir' ? enterDir(menu.item.name) : openFile(menuFull))),
899
- ctxItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menuFull })),
1011
+ ctxItem(menu.isDir ? '📂 展开/收起' : '📄 打开文件', false, () => { if (menu.isDir) { if (props.onToggleDir) props.onToggleDir(menu.path) } else openFile(menu.path) }),
1012
+ ctxItem('⬇ 下载到本地镜像', false, () => doFs('download', { path: menu.path })),
900
1013
  ctxItem('✏ 重命名', false, () => {
901
- const nm = window.prompt('重命名为(新名字)', menu.item.name)
902
- if (nm && nm.trim() && nm.trim() !== menu.item.name) doFs('rename', { path: menuFull, dest: joinRemote(curPath, nm.trim()) })
1014
+ const nm = window.prompt('重命名为(新名字)', menu.name)
1015
+ if (nm && nm.trim() && nm.trim() !== menu.name) doFs('rename', { path: menu.path, dest: joinRemote(String(menu.path).replace(/[\\/][^\\/]*$/, ''), nm.trim()) }, true)
903
1016
  }),
904
- ctxItem('🗑 删除' + (menu.item.type === 'dir' ? '(含子目录)' : ''), true, () => {
905
- if (window.confirm('确认删除 ' + menuFull + '?')) doFs('remove', { path: menuFull })
1017
+ ctxItem('🗑 删除' + (menu.isDir ? '(含子目录)' : ''), true, () => {
1018
+ if (window.confirm('确认删除 ' + menu.path + '?')) doFs('remove', { path: menu.path }, true)
906
1019
  }),
907
1020
  ) : null,
908
1021
  err ? React.createElement('div', { style: { color: '#e06c75', fontSize: 12, padding: '4px 8px' } }, (busy ? '处理中… ' : '') + err) : null,
@@ -1017,17 +1130,19 @@ window.__ModuleLoader__.load({
1017
1130
  disposers.forEach((d) => { try { d() } catch {} })
1018
1131
  return
1019
1132
  }
1020
- let autoOpened = false
1133
+ let autoOpenedFor = new Set()
1021
1134
  let disposeAuto = null
1022
1135
  const tryAutoOpen = () => {
1023
- if (autoOpened || typeof bs.subscribeState !== 'function') return
1136
+ if (typeof bs.subscribeState !== 'function') return
1024
1137
  const snap = bs.getSnapshot && bs.getSnapshot()
1025
1138
  if (!snap || !snap.sessionId) return
1026
- fetchRemoteStatus().then((s) => {
1027
- if (!s || !s.workspace || autoOpened) return
1028
- autoOpened = true
1139
+ if (autoOpenedFor.has(snap.sessionId)) return
1140
+ const sessionId = snap.sessionId
1141
+ resolveSessionRemote(sessionId).then((remotePath) => {
1142
+ if (!remotePath || autoOpenedFor.has(sessionId)) return
1143
+ autoOpenedFor.add(sessionId)
1029
1144
  try {
1030
- bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: s.workspace })
1145
+ bs.openTab({ type: SIDEBAR_EXPLORER_ID, title: '远程文件', path: remotePath })
1031
1146
  } catch (e2) {
1032
1147
  console.warn('[dsh-remote] better-sidebar auto-open skipped:', e2)
1033
1148
  }
@@ -1045,6 +1160,7 @@ window.__ModuleLoader__.load({
1045
1160
  const slots = ctx.get('slots')
1046
1161
  if (slots === undefined) return
1047
1162
  WORKSPACES = (ctx && ((ctx.get && ctx.get('workspaces')) || ctx.workspaces)) || null
1163
+ SESSIONS = (ctx && ((ctx.get && ctx.get('sessions')) || null)) || null
1048
1164
  slots.inject('settings.section', () =>
1049
1165
  slots.register({ name: 'settings.section', id: 'dsh-remote', priority: 40, label: () => '远程工作区' }, () => React.createElement(RemoteWorkspacePage, null)),
1050
1166
  )