dsh-remote-workspaces 0.2.0 → 0.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-workspaces",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Open folders on remote hosts over SSH as DeepSeek Harness workspaces — a non-invasive DSH bundle.",
6
6
  "license": "MIT",
@@ -14,19 +14,26 @@
14
14
  "main": "./src/index.js",
15
15
  "exports": {
16
16
  ".": "./src/index.js",
17
- "./client": "./src/client.js"
17
+ "./client": "./lib/client.js"
18
18
  },
19
19
  "files": [
20
20
  "src",
21
21
  "cordis.patch.yml"
22
22
  ],
23
23
  "scripts": {
24
- "test": "node test/unit-anchor-path.mjs && node test/unit-secrets-encryption.mjs && node test/unit-fs.mjs && node test/unit-shell.mjs && node test/unit-routing.mjs && node test/unit-remote-policy.mjs && node test/unit-search-registration.mjs && node test/unit-win-remote.mjs && node test/unit-registry-os.mjs",
24
+ "build:client": "node scripts/build-client.mjs",
25
+ "prepack": "node scripts/build-client.mjs",
26
+ "test": "node test/unit-anchor-path.mjs && node test/unit-secrets-encryption.mjs && node test/unit-fs.mjs && node test/unit-shell.mjs && node test/unit-routing.mjs && node test/unit-remote-policy.mjs && node test/unit-search-registration.mjs && node test/unit-win-remote.mjs && node test/unit-registry-os.mjs && node test/unit-local-browse.mjs && node test/unit-shell-sessions.mjs",
25
27
  "test:integration": "node test/integration-remote.mjs"
26
28
  },
27
29
  "dependencies": {
28
30
  "ssh2": "^1.17.0"
29
31
  },
32
+ "devDependencies": {
33
+ "@xterm/addon-fit": "^0.11.0",
34
+ "@xterm/xterm": "^5.5.0",
35
+ "esbuild": "^0.25.12"
36
+ },
30
37
  "dsh": {
31
38
  "bundle": {
32
39
  "patch": "./cordis.patch.yml"
package/src/client.js CHANGED
@@ -3,20 +3,29 @@
3
3
  *
4
4
  * 1. A "远程工作区" settings section — a multi-machine SSH registry (add /
5
5
  * edit / delete / test) with `~/.ssh/config` as a one-click form-fill
6
- * convenience. NO remote browsing here.
6
+ * convenience.
7
7
  *
8
8
  * 2. A composed directory-flow picker registered into the harness's two
9
9
  * workspace-add holes (`conversation.hero.workspace.directoryFlow` and
10
10
  * `sidebar.workspaces.directoryFlow`) at a lower priority so it shadows the
11
- * native chooser and offers BOTH "本地文件夹" (delegates to the host
12
- * chooser) and "远程目录" (pick a machine → browse the remote → open it as
13
- * a remote workspace → hand the anchor path back through `onPicked`).
11
+ * native chooser and offers BOTH "本地文件夹" and "远程目录".
14
12
  *
15
- * Served verbatim as a classic script, so it self-registers through
16
- * `window.__ModuleLoader__` in factory form (no ESM import/export); `react` is
17
- * a platform seed word resolved via `require("react")`.
13
+ * 3. A "Shell" tab type (kind `shell`, guide entry on the 开始/Start page)
14
+ * whose body renders a real xterm terminal backed by a local PTY session.
15
+ *
16
+ * Built by `scripts/build-client.mjs` (esbuild) into `lib/client.js`: the
17
+ * bundle registers `window.__ModuleLoader__.load({id, factory})`, keeps `react`
18
+ * as a platform seed word (resolved by the factory's injected `require`), and
19
+ * INLINES `@xterm/xterm` + its CSS. `exports["./client"]` points at the built
20
+ * artifact, so no deepseek-harness source is modified.
18
21
  */
19
22
 
23
+ import { Terminal } from '@xterm/xterm'
24
+ import { FitAddon } from '@xterm/addon-fit'
25
+ import xtermCss from '@xterm/xterm/css/xterm.css'
26
+
27
+ var React = require('react')
28
+
20
29
  // ---------------------------------------------------------------------------
21
30
  // Remote contract (must match src/index.js). Parameters carry strict codecs
22
31
  // with a pass-through `parse` (the client `$mount` face rejects `src-json`).
@@ -56,7 +65,16 @@ var INVOCATIONS = [
56
65
  invocation('sshAliasDetail', [jsonParameter('alias')]),
57
66
  invocation('testConnection', [jsonParameter('machine')]),
58
67
  invocation('listRemoteDir', [jsonParameter('machine'), jsonParameter('path')]),
68
+ invocation('listLocalDir', [jsonParameter('path')]),
59
69
  invocation('openRemoteWorkspace', [jsonParameter('machine'), jsonParameter('path')]),
70
+ invocation('openShellLocal', [jsonParameter('opts')]),
71
+ invocation('openShellRemote', [jsonParameter('machine'), jsonParameter('opts')]),
72
+ invocation('openShellAt', [jsonParameter('cwd'), jsonParameter('opts')]),
73
+ invocation('shellWrite', [jsonParameter('id'), jsonParameter('data')]),
74
+ invocation('shellRead', [jsonParameter('id')]),
75
+ invocation('shellResize', [jsonParameter('id'), jsonParameter('rows'), jsonParameter('cols')]),
76
+ invocation('shellClose', [jsonParameter('id')]),
77
+ invocation('shellList'),
60
78
  ]
61
79
 
62
80
  function unwrapRemote(res) {
@@ -68,13 +86,6 @@ function unwrapRemote(res) {
68
86
  return res.value || { ok: false, error: '空结果' }
69
87
  }
70
88
 
71
- window.__ModuleLoader__.load({
72
- id: PACKAGE,
73
- factory: (require) => {
74
- var module = { exports: {} }
75
- var exports = module.exports
76
- var React = require('react')
77
-
78
89
  var sectionStyle = { padding: 16, fontSize: 14, lineHeight: 1.6, maxWidth: 820, color: 'inherit' }
79
90
  var labelStyle = { color: '#8b8f98', margin: 0, fontSize: 12.5 }
80
91
  var monoStyle = { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 12.5 }
@@ -541,6 +552,156 @@ window.__ModuleLoader__.load({
541
552
  return idx <= 0 ? '/' : s.slice(0, idx)
542
553
  }
543
554
 
555
+ // -----------------------------------------------------------------------
556
+ // Remote-access discrimination for the LOCAL folder tab (pure origin rule,
557
+ // plugin-agnostic — any remote proxy/portal serves the page from its own
558
+ // non-loopback host, so only a page direct from the host's own loopback is
559
+ // treated as local). Mirrors dsh's own isLoopbackHostname semantics.
560
+ // -----------------------------------------------------------------------
561
+ function isLoopbackHostname(hostname) {
562
+ var h = String(hostname || '').toLowerCase()
563
+ if (h === 'localhost' || h === '[::1]' || h === '::1') return true
564
+ var parts = h.split('.')
565
+ return parts.length === 4 && parts[0] === '127'
566
+ && parts.every(function (part) { return /^\d{1,3}$/.test(part) && Number(part) <= 255 })
567
+ }
568
+
569
+ // True when the page is NOT served from the host's own loopback: remote
570
+ // access (e.g. via the dsh gateway portal) — the OS chooser would open on
571
+ // an unattended desktop, so the local tab must browse in-app instead.
572
+ function pageIsRemote() {
573
+ try {
574
+ if (typeof location === 'undefined' || location === null || !location.hostname) return true
575
+ return !isLoopbackHostname(location.hostname)
576
+ } catch (e) { return true }
577
+ }
578
+
579
+ // Local browse keeps paths in '/' canonical form (the host resolves either
580
+ // separator); '..' requests walk up through the host's platform resolver.
581
+ function canonicalLocal(p) { return String(p || '').replace(/\\/g, '/') }
582
+ function localUpPath(p) { return canonicalLocal(p).replace(/\/+$/, '') + '/..' }
583
+ function localIsDriveRoot(p) { return /^[A-Za-z]:\/$/.test(canonicalLocal(p)) }
584
+
585
+ // Virtual Windows drive-selection level (keep in sync with src/local-browse.js).
586
+ var LOCAL_DRIVES_REQUEST = '::drives::'
587
+
588
+ // In-app browser for the LOCAL filesystem (shown when the page is NOT
589
+ // served from the host's own loopback — remote access cannot see an OS
590
+ // chooser that opens on the host desktop). Same interaction as the remote
591
+ // pane: path bar + one-level listing + 上一级/跳转, open adopts the path.
592
+ function LocalDirectoryBrowse(props) {
593
+ var getRemote = props.getRemote
594
+ var onPicked = props.onPicked
595
+ var busy = props.busy
596
+ var isMobile = props.isMobile
597
+ var browseState = React.useState(null)
598
+ var browse = browseState[0]
599
+ var setBrowse = browseState[1]
600
+ var pathInputState = React.useState('')
601
+ var pathInput = pathInputState[0]
602
+ var setPathInput = pathInputState[1]
603
+
604
+ function load(path) {
605
+ var ns = getRemote()
606
+ if (!ns) {
607
+ setBrowse({ loading: false, error: 'Remote 命名空间未就绪', path: '', entries: [], truncated: false })
608
+ return
609
+ }
610
+ setBrowse({ loading: true, error: null, path: typeof path === 'string' && path !== '' ? canonicalLocal(path) : '', entries: [], truncated: false })
611
+ // Always pass exactly one argument (Typert counts arguments): '' = home.
612
+ ns.listLocalDir(typeof path === 'string' && path !== '' ? path : '').then(
613
+ function (res) {
614
+ var b = unwrapRemote(res)
615
+ setBrowse(function (prev) {
616
+ if (!prev || !prev.loading) return prev
617
+ if (b.ok) return { loading: false, error: null, path: canonicalLocal(b.path), virtual: !b.path, entries: b.entries || [], truncated: !!b.truncated }
618
+ return { loading: false, error: b.error || '列出目录失败', path: '', entries: [], truncated: false }
619
+ })
620
+ },
621
+ function (err) {
622
+ setBrowse(function (prev) {
623
+ if (!prev || !prev.loading) return prev
624
+ return { loading: false, error: err && err.message ? err.message : String(err), path: '', entries: [], truncated: false }
625
+ })
626
+ },
627
+ )
628
+ }
629
+
630
+ // Fresh listing of the host home directory on open ('' = home).
631
+ React.useEffect(function () { load('') }, [])
632
+
633
+ // Keep the path input in sync with the listed directory.
634
+ React.useEffect(function () {
635
+ if (browse && browse.path !== undefined && browse.path !== null) setPathInput(browse.path)
636
+ else setPathInput('')
637
+ }, [browse && browse.path])
638
+
639
+ function navigate(name) {
640
+ if (!browse) return
641
+ // Virtual drive level: its rows ARE drive roots ('C:' → 'C:/').
642
+ if (browse.virtual) { load(name + '/'); return }
643
+ if (!browse.path) return
644
+ load(browse.path.replace(/\/+$/, '') + '/' + name)
645
+ }
646
+ function goUp() {
647
+ if (!browse || !browse.path || browse.virtual) return
648
+ // At a Windows drive root there is no real '..' (it resolves back to the
649
+ // same root): step up to the virtual drive-selection level instead.
650
+ if (localIsDriveRoot(browse.path)) { load(LOCAL_DRIVES_REQUEST); return }
651
+ load(localUpPath(browse.path))
652
+ }
653
+ function jumpTo() {
654
+ var v = pathInput.trim()
655
+ if (v !== '') load(v)
656
+ }
657
+ function confirmOpen() {
658
+ if (browse && browse.path && !browse.loading && !busy) onPicked(browse.path)
659
+ }
660
+
661
+ var disabled = !browse || browse.loading
662
+ var atFilesystemTop = !browse || browse.virtual || !browse.path || canonicalLocal(browse.path) === '/'
663
+ var rows = (browse ? browse.entries : []) || []
664
+ return React.createElement('div', { style: { border: '1px solid ' + borderColor, borderRadius: 6, padding: 10 } },
665
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' } },
666
+ React.createElement('input', {
667
+ type: 'text',
668
+ value: pathInput,
669
+ onChange: function (e) { setPathInput(e.target.value) },
670
+ onKeyDown: function (e) { if (e.key === 'Enter') jumpTo() },
671
+ placeholder: '输入绝对路径,或 ~ 回到主目录',
672
+ spellCheck: false,
673
+ style: Object.assign({}, monoStyle, isMobile
674
+ ? { flex: '1 1 100%', minWidth: 0, padding: '9px 8px', fontSize: 16, boxSizing: 'border-box' }
675
+ : { flex: 1, minWidth: 0, padding: '5px 8px', fontSize: 13, boxSizing: 'border-box' }),
676
+ }),
677
+ React.createElement('button', { type: 'button', onClick: jumpTo, disabled: disabled, style: Object.assign({}, btnStyle, isMobile ? { flex: 1 } : {}, isMobile ? mobileBtnStyle : {}) }, '跳转'),
678
+ React.createElement('button', { type: 'button', onClick: goUp, disabled: disabled || atFilesystemTop, style: Object.assign({}, btnStyle, isMobile ? { flex: 1 } : {}, isMobile ? mobileBtnStyle : {}) }, '上一级'),
679
+ ),
680
+ browse && browse.loading
681
+ ? React.createElement('div', { style: labelStyle }, '加载中…')
682
+ : browse && browse.error
683
+ ? React.createElement('div', { style: { color: dangerColor, margin: 0 } }, browse.error)
684
+ : browse
685
+ ? React.createElement('div', { style: { maxHeight: 220, overflow: 'auto', border: '1px solid ' + borderColor, borderRadius: 6 } },
686
+ rows.slice().sort(function (a, b) {
687
+ if (a.dir !== b.dir) return a.dir ? -1 : 1
688
+ var an = a.name.toLowerCase()
689
+ var bn = b.name.toLowerCase()
690
+ return an < bn ? -1 : an > bn ? 1 : 0
691
+ }).map(function (e) {
692
+ return React.createElement(EntryRow, { key: (e.dir ? 'd:' : 'f:') + e.name, entry: e, onOpen: navigate })
693
+ }),
694
+ )
695
+ : null,
696
+ browse && browse.truncated
697
+ ? React.createElement('div', { style: Object.assign({}, labelStyle, { marginTop: 6 }) }, '目录项过多,仅显示前 1000 条')
698
+ : null,
699
+ React.createElement('div', { style: { marginTop: 10 } },
700
+ React.createElement('button', { type: 'button', onClick: confirmOpen, disabled: disabled || busy || !browse || !browse.path, style: Object.assign({}, btnPrim, isMobile ? { width: '100%' } : {}, isMobile ? btnPrimMob : {}) }, '打开此目录'),
701
+ ),
702
+ )
703
+ }
704
+
544
705
  // One remote directory/file row: directories render first with a folder
545
706
  // icon and are clickable; files render below with a muted style.
546
707
  function EntryRow(props) {
@@ -610,6 +771,9 @@ window.__ModuleLoader__.load({
610
771
  var pathInput = pathInputState[0]
611
772
  var setPathInput = pathInputState[1]
612
773
  var isMobile = useIsMobile()
774
+ // Not served from the host's own loopback (remote access): the OS folder
775
+ // chooser would open on an unattended desktop — use the in-app browser.
776
+ var remoteAccess = pageIsRemote()
613
777
 
614
778
  // Reset per open edge, then load machines.
615
779
  React.useEffect(function () {
@@ -730,10 +894,12 @@ window.__ModuleLoader__.load({
730
894
  React.createElement('button', { type: 'button', onClick: function () { setMode('remote') }, style: Object.assign({}, btnStyle, isMobile ? { flex: 1 } : {}, isMobile ? mobileBtnStyle : {}, mode === 'remote' ? { background: '#6e56cf', color: '#fff', border: '1px solid transparent' } : {}) }, '远程目录'),
731
895
  ),
732
896
  mode === 'local'
733
- ? React.createElement('div', {},
734
- React.createElement('p', { style: { margin: '0 0 12px' } }, '在本机打开系统文件夹选择器,选取一个本地目录作为工作区。'),
735
- React.createElement('button', { type: 'button', onClick: doLocal, disabled: localPicking || busy, style: Object.assign({}, btnStyle, isMobile ? { width: '100%' } : {}, isMobile ? mobileBtnStyle : {}) }, localPicking ? '等待选择…' : '选择本地文件夹'),
736
- )
897
+ ? remoteAccess
898
+ ? React.createElement(LocalDirectoryBrowse, { getRemote: getRemote, onPicked: onPicked, busy: busy, isMobile: isMobile })
899
+ : React.createElement('div', {},
900
+ React.createElement('p', { style: { margin: '0 0 12px' } }, '在本机打开系统文件夹选择器,选取一个本地目录作为工作区。'),
901
+ React.createElement('button', { type: 'button', onClick: doLocal, disabled: localPicking || busy, style: Object.assign({}, btnStyle, isMobile ? { width: '100%' } : {}, isMobile ? mobileBtnStyle : {}) }, localPicking ? '等待选择…' : '选择本地文件夹'),
902
+ )
737
903
  : React.createElement('div', {},
738
904
  React.createElement('div', { style: { marginBottom: 8 } }, '选择 SSH 主机(在「设置 → 远程工作区」中配置):'),
739
905
  machinesLoaded && machines.length === 0
@@ -796,7 +962,309 @@ window.__ModuleLoader__.load({
796
962
  )
797
963
  }
798
964
 
799
- exports.inject = ['slots', 'remote', 'uiWorkspace']
965
+ // =========================================================================
966
+ // Shell tab: a real xterm terminal opened directly AT the current workspace
967
+ // cwd — no local/remote chooser. The host resolves the cwd to a local PTY
968
+ // or to a remote shell channel on the matching anchor machine.
969
+ //
970
+ // A shell session is KEPT ALIVE across session switches: the tab body
971
+ // unmounts when its session leaves the screen but its occurrence signal is
972
+ // NOT aborted, so the host session persists and the next mount re-attaches
973
+ // to it (no reconnect, no "连接中").
974
+ // =========================================================================
975
+ var shellSessionCache = {}
976
+
977
+ function ShellBody(props) {
978
+ var getRemote = props.getRemote
979
+ var getCwd = props.getCwd
980
+ var getSessionId = props.getSessionId
981
+ var useTabInfo = props.useTabInfo
982
+ return React.createElement(TerminalPane, { getRemote: getRemote, getCwd: getCwd, getSessionId: getSessionId, useTabInfo: useTabInfo })
983
+ }
984
+
985
+ function TerminalPane(props) {
986
+ var getRemote = props.getRemote
987
+ var getCwd = props.getCwd
988
+ var getSessionId = props.getSessionId
989
+ var useTabInfo = props.useTabInfo
990
+ var info = useTabInfo ? useTabInfo() : null
991
+ var tabSignal = info ? info.tab.signal : null
992
+ var tabId = info && info.tab ? info.tab.id : 'shell'
993
+ var containerRef = React.useRef(null)
994
+ var termRef = React.useRef(null)
995
+ var sessionRef = React.useRef(null)
996
+ var kindRef = React.useRef(null)
997
+ var timerRef = React.useRef(null)
998
+ var statusState = React.useState('connecting')
999
+ var status = statusState[0]
1000
+ var setStatus = statusState[1]
1001
+ var errState = React.useState(null)
1002
+ var err = errState[0]
1003
+ var setErr = errState[1]
1004
+ var labelState = React.useState('终端')
1005
+ var label = labelState[0]
1006
+ var setLabel = labelState[1]
1007
+
1008
+ React.useEffect(function () {
1009
+ var disposed = false
1010
+ var el = containerRef.current
1011
+ if (!el) return undefined
1012
+
1013
+ var fg = resolveShellFg()
1014
+
1015
+ var term = new Terminal({
1016
+ cursorBlink: true,
1017
+ fontSize: 13,
1018
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1019
+ scrollback: 2000,
1020
+ convertEol: false,
1021
+ allowTransparency: true,
1022
+ theme: { background: 'transparent', foreground: fg, cursor: fg, cursorAccent: 'transparent' },
1023
+ })
1024
+ var fitAddon = new FitAddon()
1025
+ term.loadAddon(fitAddon)
1026
+ termRef.current = term
1027
+ term.open(el)
1028
+ term.focus()
1029
+
1030
+ // Follow theme switches (dark <-> light): the pane background is
1031
+ // transparent, so the foreground must re-resolve or light-mode text
1032
+ // turns white-on-white.
1033
+ var themeObserver = null
1034
+ if (typeof MutationObserver !== 'undefined' && document.body) {
1035
+ themeObserver = new MutationObserver(function () {
1036
+ if (disposed) return
1037
+ var next = resolveShellFg()
1038
+ try { term.options.theme = { background: 'transparent', foreground: next, cursor: next, cursorAccent: 'transparent' } } catch (e2) { /* noop */ }
1039
+ })
1040
+ themeObserver.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
1041
+ }
1042
+
1043
+ var ns = getRemote()
1044
+ if (!ns) {
1045
+ setStatus('error')
1046
+ setErr('Remote 命名空间未就绪')
1047
+ term.writeln('\x1b[31m[shell] Remote 命名空间未就绪\x1b[0m')
1048
+ return undefined
1049
+ }
1050
+
1051
+ // Stable per-(session, tab) key derived from the session id + the dock
1052
+ // tab id. Both survive a page refresh (the session is resumed and the
1053
+ // layout restores the tab id), so a re-mount — or a re-load — re-attaches
1054
+ // to the same host session instead of reconnecting or leaking a new one.
1055
+ var key = (getSessionId ? getSessionId() : 's') + ':' + tabId
1056
+
1057
+ var opened = false
1058
+ var ro = null
1059
+ var rafId = 0
1060
+ var resizePending = false
1061
+
1062
+ function openWithFittedSize() {
1063
+ if (disposed || opened) return
1064
+ opened = true
1065
+ if (rafId) { cancelAnimationFrame(rafId); rafId = 0 }
1066
+ try { fitAddon.fit() } catch (e) { /* keep default rows/cols */ }
1067
+ var cached = shellSessionCache[key]
1068
+ if (cached && cached.id) {
1069
+ // Re-attaching to a kept-alive session: show connected at once,
1070
+ // and the full scrollback replays when the attach resolves below.
1071
+ setLabel(cached.label || '本机')
1072
+ setStatus('open')
1073
+ }
1074
+ var opts = { rows: term.rows, cols: term.cols, key: key }
1075
+ var cwd = getCwd ? getCwd() : undefined
1076
+ ns.openShellAt(cwd, opts).then(
1077
+ function (res) {
1078
+ if (disposed) return
1079
+ var b = unwrapRemote(res)
1080
+ if (!b.ok) {
1081
+ setStatus('error')
1082
+ setErr(b.error || '打开失败')
1083
+ term.writeln('\x1b[31m[shell] ' + (b.error || '打开失败') + '\x1b[0m')
1084
+ return
1085
+ }
1086
+ // Attach replays the capped scrollback so the terminal is not blank.
1087
+ if (b.attached && b.history) term.write(b.history)
1088
+ sessionRef.current = b.id
1089
+ kindRef.current = b.kind || null
1090
+ setLabel(b.label || (b.kind === 'remote' ? '远程' : '本机'))
1091
+ setStatus('open')
1092
+ shellSessionCache[key] = { id: b.id, kind: b.kind || null, label: b.label || (b.kind === 'remote' ? '远程' : '本机') }
1093
+ timerRef.current = setTimeout(tick, 60)
1094
+ },
1095
+ function (e) {
1096
+ if (disposed) return
1097
+ setStatus('error')
1098
+ setErr(e && e.message ? e.message : String(e))
1099
+ term.writeln('\x1b[31m[shell] ' + (e && e.message ? e.message : String(e)) + '\x1b[0m')
1100
+ },
1101
+ )
1102
+ }
1103
+
1104
+ function tick() {
1105
+ if (disposed) return
1106
+ var id = sessionRef.current
1107
+ if (!id) return
1108
+ ns.shellRead(id).then(
1109
+ function (r) {
1110
+ if (disposed) return
1111
+ var out = unwrapRemote(r)
1112
+ if (out.ok) {
1113
+ if (out.text) term.write(out.text)
1114
+ if (out.eof) { endSession('会话已结束'); return }
1115
+ } else if (/session not found/.test(out.error || '')) {
1116
+ endSession('会话已结束')
1117
+ return
1118
+ }
1119
+ timerRef.current = setTimeout(tick, 60)
1120
+ },
1121
+ function () {
1122
+ if (disposed) return
1123
+ timerRef.current = setTimeout(tick, 60)
1124
+ },
1125
+ )
1126
+ }
1127
+
1128
+ function endSession(msg) {
1129
+ if (disposed || sessionRef.current === null) return
1130
+ var id = sessionRef.current
1131
+ sessionRef.current = null
1132
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
1133
+ term.writeln('\r\n\x1b[2m[shell] ' + msg + '\x1b[0m')
1134
+ setStatus('ended')
1135
+ delete shellSessionCache[key]
1136
+ if (id) ns.shellClose(id)
1137
+ }
1138
+
1139
+ function scheduleFit() {
1140
+ if (disposed || !opened || sessionRef.current === null) return
1141
+ if (resizePending) return
1142
+ resizePending = true
1143
+ requestAnimationFrame(function () {
1144
+ resizePending = false
1145
+ if (disposed || !opened || sessionRef.current === null) return
1146
+ var beforeR = term.rows
1147
+ var beforeC = term.cols
1148
+ try { fitAddon.fit() } catch (e) { /* noop */ }
1149
+ if (term.rows !== beforeR || term.cols !== beforeC) {
1150
+ var id = sessionRef.current
1151
+ if (kindRef.current === 'remote') ns.shellResize(id, term.rows, term.cols)
1152
+ }
1153
+ })
1154
+ }
1155
+
1156
+ // Fit once when the flex chain gives real dimensions, then re-fit live
1157
+ // on resize. Remote sessions push the new rows/cols to the PTY via
1158
+ // setWindow; local sessions only re-fit the view (no seam resize — S0).
1159
+ if (typeof ResizeObserver !== 'undefined') {
1160
+ ro = new ResizeObserver(function (entries) {
1161
+ var r = entries[0] && entries[0].contentRect
1162
+ if (!r || r.height <= 0 || r.width <= 0) return
1163
+ if (opened) scheduleFit()
1164
+ else openWithFittedSize()
1165
+ })
1166
+ ro.observe(el)
1167
+ }
1168
+ var attempt = function () {
1169
+ if (disposed || opened) return
1170
+ if (el.clientHeight > 0 && el.clientWidth > 0) { openWithFittedSize(); return }
1171
+ rafId = requestAnimationFrame(attempt)
1172
+ }
1173
+ rafId = requestAnimationFrame(attempt)
1174
+
1175
+ var dataDisposable = term.onData(function (data) {
1176
+ var id = sessionRef.current
1177
+ if (id && ns) ns.shellWrite(id, data)
1178
+ })
1179
+
1180
+ return function () {
1181
+ disposed = true
1182
+ if (ro !== null) ro.disconnect()
1183
+ if (themeObserver !== null) themeObserver.disconnect()
1184
+ if (rafId) cancelAnimationFrame(rafId)
1185
+ if (timerRef.current) clearTimeout(timerRef.current)
1186
+ if (dataDisposable && dataDisposable.dispose) dataDisposable.dispose()
1187
+ var id = sessionRef.current
1188
+ // Close the host session only when the tab is genuinely removed
1189
+ // (occurrence signal aborted). A session switch unmounts the body
1190
+ // WITHOUT aborting, so the shell stays alive for the next re-attach.
1191
+ var removed = tabSignal ? tabSignal.aborted : true
1192
+ if (id && ns && removed) {
1193
+ delete shellSessionCache[key]
1194
+ ns.shellClose(id)
1195
+ }
1196
+ if (termRef.current) { try { termRef.current.dispose() } catch (e2) { /* noop */ } }
1197
+ termRef.current = null
1198
+ sessionRef.current = null
1199
+ }
1200
+ }, [])
1201
+
1202
+ return React.createElement(
1203
+ 'div',
1204
+ { className: 'dsh-rw-shell', style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, overflow: 'hidden' } },
1205
+ React.createElement('div', { style: { flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: 8, padding: '4px 10px', borderBottom: '1px solid ' + borderColor, fontSize: 12, color: '#8b8f98' } },
1206
+ React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
1207
+ status === 'open' ? label + ' · 已连接' : status === 'ended' ? label + ' · 已结束' : status === 'error' ? label + ' · 出错' : '连接中…'),
1208
+ React.createElement('button', {
1209
+ type: 'button',
1210
+ title: '新建 Shell',
1211
+ onClick: function () {
1212
+ try { if (info && info.tab && info.tab.actions) info.tab.actions.openTab(info.tab.kind, { revealIfOpened: false }) } catch (e2) { /* noop */ }
1213
+ },
1214
+ style: { padding: '2px 8px', fontSize: 11.5, cursor: 'pointer', background: 'rgba(127,127,127,0.12)', color: 'inherit', border: '1px solid rgba(127,127,127,0.3)', borderRadius: 4, whiteSpace: 'nowrap' },
1215
+ }, '+ 新建'),
1216
+ ),
1217
+ err !== null
1218
+ ? React.createElement('div', { style: { flex: '0 0 auto', padding: '6px 10px', color: '#e5484d', fontFamily: 'ui-monospace, monospace', fontSize: 12, whiteSpace: 'pre-wrap' } }, err)
1219
+ : null,
1220
+ React.createElement('div', { ref: containerRef, style: { flex: 1, minHeight: 0, width: '100%' } }),
1221
+ )
1222
+ }
1223
+
1224
+ // Terminal glyph for the 开始 page entry box (drawn at 16px; color rides currentColor).
1225
+ function ShellIcon(props) {
1226
+ var size = props && props.size ? props.size : 16
1227
+ return React.createElement('svg', {
1228
+ width: size,
1229
+ height: size,
1230
+ viewBox: '0 0 16 16',
1231
+ fill: 'none',
1232
+ stroke: 'currentColor',
1233
+ strokeWidth: 1.5,
1234
+ strokeLinecap: 'round',
1235
+ strokeLinejoin: 'round',
1236
+ 'aria-hidden': 'true',
1237
+ xmlns: 'http://www.w3.org/2000/svg',
1238
+ },
1239
+ React.createElement('rect', { x: 1.5, y: 2.5, width: 13, height: 11, rx: 2 }),
1240
+ React.createElement('path', { d: 'M4.5 5.5 L7 8 L4.5 10.5' }),
1241
+ React.createElement('line', { x1: 8, y1: 10.5, x2: 11.5, y2: 10.5 }),
1242
+ )
1243
+ }
1244
+
1245
+ // Foreground for the xterm: the theme's primary label color, with a
1246
+ // dark/light fallback. `--dsw-alias-label-primary` is dark in light mode
1247
+ // and light in dark mode; resolving it through a real DOM probe follows the
1248
+ // active theme exactly, while the attribute fallback keeps the shell
1249
+ // readable if the probe ever fails (or the token is unavailable).
1250
+ function resolveShellFg() {
1251
+ var dark = false
1252
+ try { dark = document.body !== null && document.body.hasAttribute('data-ds-dark-theme') } catch (e) { /* default light */ }
1253
+ var fallback = dark ? '#e6edf3' : '#0f1115'
1254
+ try {
1255
+ if (typeof document !== 'undefined' && typeof getComputedStyle === 'function' && document.body) {
1256
+ var probe = document.createElement('div')
1257
+ probe.style.color = 'var(--dsw-alias-label-primary)'
1258
+ document.body.appendChild(probe)
1259
+ var resolved = getComputedStyle(probe).color
1260
+ document.body.removeChild(probe)
1261
+ if (resolved && resolved !== 'rgba(0, 0, 0, 0)' && resolved !== 'transparent') return resolved
1262
+ }
1263
+ } catch (e) { /* use fallback */ }
1264
+ return fallback
1265
+ }
1266
+
1267
+ exports.inject = ['slots', 'remote', 'uiWorkspace', 'sidebarRightTabs', 'sessions']
800
1268
 
801
1269
  exports.apply = function apply(ctx) {
802
1270
  // Inject a small set of theme-aware control styles (hover / focus /
@@ -821,8 +1289,37 @@ window.__ModuleLoader__.load({
821
1289
  document.head.appendChild(styleEl)
822
1290
  }
823
1291
 
1292
+ // Inject xterm's stylesheet (inlined as text by the esbuild build).
1293
+ if (typeof document !== 'undefined' && document.head && xtermCss && !document.getElementById('dsh-rw-xterm')) {
1294
+ var xtermStyle = document.createElement('style')
1295
+ xtermStyle.id = 'dsh-rw-xterm'
1296
+ xtermStyle.textContent = xtermCss
1297
+ + '\n.dsh-rw-shell .xterm{height:100%}'
1298
+ + '\n.dsh-rw-shell .xterm-viewport{background-color:transparent!important}'
1299
+ + '\n.dsh-rw-shell .xterm-screen{background-color:transparent!important}'
1300
+ document.head.appendChild(xtermStyle)
1301
+ }
1302
+
824
1303
  var mount = ctx.remote.$mount({ package: PACKAGE, descriptors: INVOCATIONS })
825
1304
  var getRemote = function () { return ctx.get('remote.' + NAMESPACE) }
1305
+ var getCwd = function () {
1306
+ try {
1307
+ var sessions = ctx.sessions
1308
+ if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return undefined
1309
+ var snap = sessions.list.getSnapshot()
1310
+ var id = snap && snap.current
1311
+ var row = id && snap.byId ? snap.byId[id] : undefined
1312
+ return row ? row.cwd : undefined
1313
+ } catch (e) { return undefined }
1314
+ }
1315
+ var getSessionId = function () {
1316
+ try {
1317
+ var sessions = ctx.sessions
1318
+ if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return undefined
1319
+ var snap = sessions.list.getSnapshot()
1320
+ return snap && snap.current ? String(snap.current) : undefined
1321
+ } catch (e) { return undefined }
1322
+ }
826
1323
 
827
1324
  // Settings section: machines + open remote workspaces (grouped by host).
828
1325
  ctx.slots.inject('settings.section', function () {
@@ -837,6 +1334,30 @@ window.__ModuleLoader__.load({
837
1334
  )
838
1335
  })
839
1336
 
1337
+ // Shell tab type + body (real xterm terminal; S0 = local shell only).
1338
+ var SHELL_KIND = 'shell'
1339
+ var SHELL_ID = 'dsh-remote-workspaces/shell'
1340
+ ctx.effect(function () {
1341
+ return ctx.sidebarRightTabs.register({
1342
+ id: SHELL_ID,
1343
+ kind: SHELL_KIND,
1344
+ priority: 'extension',
1345
+ title: function () { return 'Shell' },
1346
+ guide: [{
1347
+ order: 20,
1348
+ title: function () { return 'Shell' },
1349
+ description: function () { return '打开当前工作区的交互终端' },
1350
+ icon: ShellIcon,
1351
+ }],
1352
+ })
1353
+ })
1354
+ ctx.slots.inject('sidebar.right.pane.tab', function () {
1355
+ return ctx.slots.register(
1356
+ { name: 'sidebar.right.pane.tab', key: SHELL_ID, inject: function () { return { getRemote: getRemote, getCwd: getCwd, getSessionId: getSessionId } } },
1357
+ ShellBody,
1358
+ )
1359
+ })
1360
+
840
1361
  // Composed workspace-add picker (shadows the native chooser at a lower priority).
841
1362
  var flowInjected = function () {
842
1363
  return {
@@ -857,7 +1378,3 @@ window.__ModuleLoader__.load({
857
1378
  })
858
1379
  })
859
1380
  }
860
-
861
- return module.exports
862
- },
863
- })