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 +10 -3
- package/src/client.js +540 -23
- package/src/index.js +136 -3
- package/src/local-browse.js +168 -0
- package/src/registry.js +20 -11
- package/src/routing-fs.js +54 -7
- package/src/shell-exec.js +13 -1
- package/src/shell-sessions.js +180 -0
- package/src/transport.js +124 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-workspaces",
|
|
3
|
-
"version": "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": "./
|
|
17
|
+
"./client": "./lib/client.js"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"cordis.patch.yml"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"
|
|
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.
|
|
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 "本地文件夹"
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
?
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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
|
-
})
|