@lijian-ui/dsh-term 0.1.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/LICENSE +21 -0
- package/README.md +36 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +7824 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +434 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/index.d.ts +26 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/term/TerminalPanel.d.ts +24 -0
- package/lib/types/client/term/TerminalPanel.d.ts.map +1 -0
- package/lib/types/client/term/api.d.ts +34 -0
- package/lib/types/client/term/api.d.ts.map +1 -0
- package/lib/types/client/term/xterm-styles.d.ts +9 -0
- package/lib/types/client/term/xterm-styles.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +76 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/pty-service.d.ts +45 -0
- package/lib/types/host/pty-service.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +17 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +79 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/index.ts +223 -0
- package/src/client/term/TerminalPanel.tsx +259 -0
- package/src/client/term/api.ts +66 -0
- package/src/client/term/term.module.css +153 -0
- package/src/client/term/xterm-styles.ts +182 -0
- package/src/core/types.ts +73 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/pty-service.ts +131 -0
- package/src/host/routes.ts +198 -0
- package/src/index.ts +37 -0
- package/src/mount-once.ts +48 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-term browser half: mounts the terminal as a DOCKED column in the web
|
|
3
|
+
* shell's right side — appended as the 6th grid track of the frame, beside
|
|
4
|
+
* the file-manager panels (preview/explorer) when present. This mirrors how
|
|
5
|
+
* file-manager extends the frame grid, so the two panels sit side by side.
|
|
6
|
+
*
|
|
7
|
+
* The frame is a CSS grid with `grid-auto-flow: row` (the default). A bare
|
|
8
|
+
* `appendChild` of a 6th child would WRAP to a 2nd row (off-screen) — so we
|
|
9
|
+
* must EXTEND `grid-template-columns` explicitly, the same way file-manager
|
|
10
|
+
* does. We append a `[dsh-term]` named 6th track after file-manager's 5
|
|
11
|
+
* tracks; the center column is `minmax(0,1fr)`, so it absorbs the 300px and
|
|
12
|
+
* the grid never overflows on wide screens. file-manager ignores any grid
|
|
13
|
+
* string that is not 3 (shell) or 5 (its own) tracks, so our 6-track write is
|
|
14
|
+
* left alone by it — we just re-assert it whenever file-manager rewrites the
|
|
15
|
+
* grid (drag / resize / collapse).
|
|
16
|
+
*
|
|
17
|
+
* Every DOM/runtime failure is logged, never thrown — the web shell fails the
|
|
18
|
+
* whole boot when a plugin apply throws.
|
|
19
|
+
* @module dsh-term/client
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createElement } from 'react'
|
|
23
|
+
import { createRoot } from 'react-dom/client'
|
|
24
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
25
|
+
// Type-only: pulls the ui-slots SlotMap merge (keeps bundle purity gates calm).
|
|
26
|
+
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
27
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
28
|
+
import { TermApi } from './term/api.ts'
|
|
29
|
+
import { TerminalPanel } from './term/TerminalPanel.tsx'
|
|
30
|
+
|
|
31
|
+
/** Required services: sessions (for the workspace cwd). */
|
|
32
|
+
export const inject = ['sessions']
|
|
33
|
+
|
|
34
|
+
/** Width of the docked terminal column, in px (the 6th grid track). */
|
|
35
|
+
const TERMINAL_WIDTH = 300
|
|
36
|
+
|
|
37
|
+
/** Locate the frame grid (same heuristic file-manager uses). */
|
|
38
|
+
function findFrame(): HTMLElement | null {
|
|
39
|
+
const stamped = document.querySelector<HTMLElement>('[data-dsh-frame]')
|
|
40
|
+
if (stamped !== null) return stamped
|
|
41
|
+
return document.querySelector<HTMLElement>('[class*="sidebarCol"]')?.parentElement ?? null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run `cb` once the frame exists. The shell mounts late (and may rebuild), so
|
|
46
|
+
* wait via a body-level MutationObserver when it is not present yet.
|
|
47
|
+
*/
|
|
48
|
+
function whenFrameReady(cb: (frame: HTMLElement) => void): () => void {
|
|
49
|
+
const existing = findFrame()
|
|
50
|
+
if (existing !== null) {
|
|
51
|
+
cb(existing)
|
|
52
|
+
return () => {}
|
|
53
|
+
}
|
|
54
|
+
const obs = new MutationObserver(() => {
|
|
55
|
+
const frame = findFrame()
|
|
56
|
+
if (frame !== null) {
|
|
57
|
+
obs.disconnect()
|
|
58
|
+
cb(frame)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
obs.observe(document.body, { childList: true, subtree: true })
|
|
62
|
+
return () => obs.disconnect()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Inject the launcher button styles once (the button is a raw DOM node). */
|
|
66
|
+
function adoptLauncherStyles(): void {
|
|
67
|
+
const id = 'dsh-term-launcher-style'
|
|
68
|
+
if (document.getElementById(id) !== null) return
|
|
69
|
+
const tag = document.createElement('style')
|
|
70
|
+
tag.id = id
|
|
71
|
+
tag.textContent = `
|
|
72
|
+
.dsh-term-launcher {
|
|
73
|
+
position: fixed;
|
|
74
|
+
right: 12px;
|
|
75
|
+
bottom: 12px;
|
|
76
|
+
z-index: 40;
|
|
77
|
+
height: 30px;
|
|
78
|
+
padding: 0 14px;
|
|
79
|
+
border-radius: 6px;
|
|
80
|
+
border: 1px solid var(--aion-bg-3, #e5e6eb);
|
|
81
|
+
background: var(--aion-bg-1, #ffffff);
|
|
82
|
+
color: var(--aion-fg-1, #1f2329);
|
|
83
|
+
font-size: 13px;
|
|
84
|
+
font-family: var(--aion-font-sans, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif);
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
87
|
+
}
|
|
88
|
+
.dsh-term-launcher:hover {
|
|
89
|
+
background: var(--aion-bg-2, #f2f3f5);
|
|
90
|
+
}`
|
|
91
|
+
document.head.appendChild(tag)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse a `grid-template-columns` string into raw tokens. Named lines
|
|
96
|
+
* (`[name]`) and sizes are separate tokens — we keep them split so the
|
|
97
|
+
* terminal marker can be stripped reliably. Spaces inside `minmax(...)` /
|
|
98
|
+
* `repeat(...)` are preserved (not split).
|
|
99
|
+
*/
|
|
100
|
+
function tokenizeGrid(input: string): string[] {
|
|
101
|
+
const tokens: string[] = []
|
|
102
|
+
let depth = 0
|
|
103
|
+
let current = ''
|
|
104
|
+
for (const char of input) {
|
|
105
|
+
if (char === '(') depth += 1
|
|
106
|
+
if (char === ')') depth = Math.max(0, depth - 1)
|
|
107
|
+
if (char === ' ' && depth === 0) {
|
|
108
|
+
if (current !== '') {
|
|
109
|
+
tokens.push(current)
|
|
110
|
+
current = ''
|
|
111
|
+
}
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
current += char
|
|
115
|
+
}
|
|
116
|
+
if (current !== '') tokens.push(current)
|
|
117
|
+
return tokens
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Remove the terminal's `[dsh-term]` marker line and its following size token. */
|
|
121
|
+
function stripTerminalTrack(tokens: readonly string[]): string[] {
|
|
122
|
+
const out: string[] = []
|
|
123
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
124
|
+
if (tokens[i] === '[dsh-term]') {
|
|
125
|
+
i += 1 // skip the size that follows the marker
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
out.push(tokens[i])
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Apply the browser half. */
|
|
134
|
+
export function apply(ctx: ClientContext): void {
|
|
135
|
+
ctx.effect(() => {
|
|
136
|
+
let disposeFrame: (() => void) | undefined
|
|
137
|
+
const offWait = whenFrameReady((frame) => {
|
|
138
|
+
// The terminal column: appended as the LAST child of the frame grid, so
|
|
139
|
+
// it docks to the far right, beside file-manager's explorer. It becomes
|
|
140
|
+
// the 6th explicit grid track (sized by our `[dsh-term] 300px` write),
|
|
141
|
+
// not an implicit row — that is what makes it visible on the same row.
|
|
142
|
+
const col = document.createElement('div')
|
|
143
|
+
col.dataset.dshTermCol = ''
|
|
144
|
+
col.style.minWidth = '0'
|
|
145
|
+
col.style.height = '100%'
|
|
146
|
+
frame.appendChild(col)
|
|
147
|
+
|
|
148
|
+
// Keep the terminal the rightmost column even if file-manager appends
|
|
149
|
+
// its preview/explorer columns after this plugin mounts.
|
|
150
|
+
const keepLast = new MutationObserver(() => {
|
|
151
|
+
if (col !== frame.lastElementChild) frame.appendChild(col)
|
|
152
|
+
})
|
|
153
|
+
keepLast.observe(frame, { childList: true })
|
|
154
|
+
|
|
155
|
+
// Re-open button (after a collapse). Hidden while the column is open.
|
|
156
|
+
adoptLauncherStyles()
|
|
157
|
+
const launcher = document.createElement('button')
|
|
158
|
+
launcher.type = 'button'
|
|
159
|
+
launcher.className = 'dsh-term-launcher'
|
|
160
|
+
launcher.textContent = '终端'
|
|
161
|
+
launcher.setAttribute('aria-label', '打开终端')
|
|
162
|
+
launcher.style.display = 'none'
|
|
163
|
+
document.body.appendChild(launcher)
|
|
164
|
+
|
|
165
|
+
// Open/closed state. Closed by default (the column is a docked track).
|
|
166
|
+
let terminalOpen = false
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Re-assert the frame grid: keep file-manager's tracks, append our
|
|
170
|
+
* `[dsh-term] 300px` track when open, strip it when collapsed.
|
|
171
|
+
* file-manager only acts on 3- or 5-track grids, so our 6-track write is
|
|
172
|
+
* never clobbered by it — but file-manager DOES overwrite us on every
|
|
173
|
+
* drag/resize/collapse, so we re-run on any frame style change.
|
|
174
|
+
*/
|
|
175
|
+
const reconcileGrid = (): void => {
|
|
176
|
+
const inline = frame.style.gridTemplateColumns
|
|
177
|
+
if (inline.trim() === '') return
|
|
178
|
+
let tokens = tokenizeGrid(inline)
|
|
179
|
+
tokens = stripTerminalTrack(tokens)
|
|
180
|
+
const hasFilemgr = frame.querySelector('[data-filemgr-explorer-col]') !== null
|
|
181
|
+
const baseLen = hasFilemgr ? 5 : 3
|
|
182
|
+
// Wait while file-manager has not finalized its 5-track grid yet
|
|
183
|
+
// (e.g. the shell's own 3-track write landed before file-manager
|
|
184
|
+
// re-applied). Touching it now would desync file-manager's width math.
|
|
185
|
+
if (tokens.length !== baseLen) return
|
|
186
|
+
if (terminalOpen) tokens.push('[dsh-term]', `${TERMINAL_WIDTH}px`)
|
|
187
|
+
const next = tokens.join(' ')
|
|
188
|
+
if (next !== frame.style.gridTemplateColumns) {
|
|
189
|
+
frame.style.gridTemplateColumns = next
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Re-assert after file-manager rewrites the grid (drag / resize / collapse).
|
|
194
|
+
const styleObserver = new MutationObserver(() => reconcileGrid())
|
|
195
|
+
styleObserver.observe(frame, { attributes: true, attributeFilter: ['style'] })
|
|
196
|
+
// First pass (frame may already carry file-manager's 5-track grid).
|
|
197
|
+
reconcileGrid()
|
|
198
|
+
|
|
199
|
+
const api = new TermApi()
|
|
200
|
+
const root = createRoot(col)
|
|
201
|
+
const setVisible = (open: boolean): void => {
|
|
202
|
+
terminalOpen = open
|
|
203
|
+
col.style.display = open ? 'flex' : 'none'
|
|
204
|
+
launcher.style.display = open ? 'none' : 'flex'
|
|
205
|
+
reconcileGrid()
|
|
206
|
+
}
|
|
207
|
+
launcher.addEventListener('click', () => setVisible(true))
|
|
208
|
+
root.render(createElement(TerminalPanel, { ctx, api, onClose: () => setVisible(false) }))
|
|
209
|
+
|
|
210
|
+
disposeFrame = () => {
|
|
211
|
+
keepLast.disconnect()
|
|
212
|
+
styleObserver.disconnect()
|
|
213
|
+
root.unmount()
|
|
214
|
+
col.remove()
|
|
215
|
+
launcher.remove()
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
return () => {
|
|
219
|
+
offWait()
|
|
220
|
+
disposeFrame?.()
|
|
221
|
+
}
|
|
222
|
+
}, 'dsh-term: panel mount')
|
|
223
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-term panel: a multi-tab local terminal (real PTY via node-pty on the
|
|
3
|
+
* host, bridged over /dsh-term/*). One xterm instance per tab; output lands
|
|
4
|
+
* through a single EventSource routed by session id.
|
|
5
|
+
*
|
|
6
|
+
* Mounting: this component renders a DOCKED column (not a floating overlay).
|
|
7
|
+
* The client entry (`index.ts`) appends that column as the last grid track of
|
|
8
|
+
* the web shell's frame, beside the file-manager panels (preview/explorer)
|
|
9
|
+
* when present. The panel itself only owns its inner content (header + stage).
|
|
10
|
+
*
|
|
11
|
+
* A-version scope: pure user terminal. No agent integration, no SSH targets
|
|
12
|
+
* yet — those are the B-version.
|
|
13
|
+
* @module dsh-term/client/term/TerminalPanel
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
17
|
+
import type { JSX } from 'react'
|
|
18
|
+
import { Terminal } from 'xterm'
|
|
19
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
20
|
+
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
21
|
+
import type { TermEvent, TermSessionInfo } from '../../core/types.ts'
|
|
22
|
+
import type { TermApi } from './api.ts'
|
|
23
|
+
import { XTERM_CSS } from './xterm-styles.ts'
|
|
24
|
+
import css from './term.module.css'
|
|
25
|
+
|
|
26
|
+
/** One open tab: the wire info plus its live xterm handles. */
|
|
27
|
+
interface Tab {
|
|
28
|
+
readonly sessionId: string
|
|
29
|
+
readonly title: string
|
|
30
|
+
readonly term: Terminal
|
|
31
|
+
readonly fit: FitAddon
|
|
32
|
+
readonly wrap: HTMLDivElement
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Inject the xterm core styles once (idempotent). */
|
|
36
|
+
const XTERM_STYLE_ID = 'dsh-term-xterm-style'
|
|
37
|
+
function adoptXtermStyles(): void {
|
|
38
|
+
if (document.getElementById(XTERM_STYLE_ID) !== null) return
|
|
39
|
+
const tag = document.createElement('style')
|
|
40
|
+
tag.id = XTERM_STYLE_ID
|
|
41
|
+
tag.textContent = XTERM_CSS
|
|
42
|
+
document.head.appendChild(tag)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Terminal theme matching the shell's light/dark marker. */
|
|
46
|
+
function themeOf(): Record<string, string> {
|
|
47
|
+
const dark = document.body.dataset.dsDarkTheme !== undefined
|
|
48
|
+
return {
|
|
49
|
+
background: dark ? '#0e0e0e' : '#ffffff',
|
|
50
|
+
foreground: dark ? '#ced3da' : '#1f2329',
|
|
51
|
+
cursor: dark ? '#ced3da' : '#1f2329',
|
|
52
|
+
selectionBackground: dark ? 'rgba(255,255,255,0.2)' : 'rgba(22,93,255,0.25)',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Current workspace cwd from the session list ('' when none). */
|
|
57
|
+
function currentCwd(ctx: ClientContext): string {
|
|
58
|
+
const snapshot = ctx.sessions.list.getSnapshot()
|
|
59
|
+
const sessionId = snapshot.current as SessionId | undefined
|
|
60
|
+
const cwd = sessionId === undefined ? undefined : snapshot.byId[sessionId]?.cwd
|
|
61
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The docked panel: header (title / tabs / new / collapse) + xterm stage. */
|
|
65
|
+
export function TerminalPanel({
|
|
66
|
+
ctx, api, onClose,
|
|
67
|
+
}: { ctx: ClientContext; api: TermApi; onClose: () => void }): JSX.Element {
|
|
68
|
+
const [tabs, setTabs] = useState<readonly Tab[]>([])
|
|
69
|
+
const [active, setActive] = useState<string | null>(null)
|
|
70
|
+
const stageRef = useRef<HTMLDivElement | null>(null)
|
|
71
|
+
const tabsRef = useRef(tabs)
|
|
72
|
+
tabsRef.current = tabs
|
|
73
|
+
|
|
74
|
+
// xterm needs its core stylesheet; inject once when the panel mounts.
|
|
75
|
+
useEffect(() => { adoptXtermStyles() }, [])
|
|
76
|
+
|
|
77
|
+
// Keep every open tab's xterm theme in lockstep with the shell dark marker
|
|
78
|
+
// (the shell toggles body[data-ds-dark-theme] — CSS only can't reach xterm).
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const applyTheme = (): void => {
|
|
81
|
+
const theme = themeOf()
|
|
82
|
+
for (const tab of tabsRef.current) tab.term.options.theme = theme
|
|
83
|
+
}
|
|
84
|
+
applyTheme()
|
|
85
|
+
const obs = new MutationObserver(applyTheme)
|
|
86
|
+
obs.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
|
|
87
|
+
return () => obs.disconnect()
|
|
88
|
+
}, [])
|
|
89
|
+
|
|
90
|
+
// One global stream subscription; route frames to the right tab by id.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const off = api.subscribe((event: TermEvent) => {
|
|
93
|
+
if (event.kind === 'output') {
|
|
94
|
+
const tab = tabsRef.current.find((t) => t.sessionId === event.id)
|
|
95
|
+
tab?.term.write(event.data)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (event.kind === 'exit') {
|
|
99
|
+
const tab = tabsRef.current.find((t) => t.sessionId === event.id)
|
|
100
|
+
if (tab !== undefined) tab.term.write(`\r\n[dsh-term] 进程已退出(code ${event.exitCode})\r\n`)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
return off
|
|
104
|
+
}, [api])
|
|
105
|
+
|
|
106
|
+
/** Create a tab: xterm instance + host spawn, then attach output routing. */
|
|
107
|
+
const openTab = useCallback(async (): Promise<void> => {
|
|
108
|
+
if (stageRef.current === null) return
|
|
109
|
+
const wrap = document.createElement('div')
|
|
110
|
+
wrap.className = css.termWrap
|
|
111
|
+
stageRef.current.appendChild(wrap)
|
|
112
|
+
const term = new Terminal({
|
|
113
|
+
fontSize: 13,
|
|
114
|
+
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
|
115
|
+
cursorBlink: true,
|
|
116
|
+
theme: themeOf(),
|
|
117
|
+
scrollback: 5000,
|
|
118
|
+
})
|
|
119
|
+
const fit = new FitAddon()
|
|
120
|
+
term.loadAddon(fit)
|
|
121
|
+
term.open(wrap)
|
|
122
|
+
try {
|
|
123
|
+
fit.fit()
|
|
124
|
+
} catch {
|
|
125
|
+
// Not yet in the DOM layout; the first resize below will fit it.
|
|
126
|
+
}
|
|
127
|
+
// Route typed input straight to the host.
|
|
128
|
+
term.onData((data) => {
|
|
129
|
+
const tab = tabsRef.current.find((t) => t.term === term)
|
|
130
|
+
if (tab !== undefined) void api.write(tab.sessionId, data)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
let session: TermSessionInfo
|
|
134
|
+
try {
|
|
135
|
+
session = await api.spawn({
|
|
136
|
+
cwd: currentCwd(ctx) || undefined,
|
|
137
|
+
cols: term.cols,
|
|
138
|
+
rows: term.rows,
|
|
139
|
+
})
|
|
140
|
+
} catch (error) {
|
|
141
|
+
term.write(`\r\n[dsh-term] 启动失败: ${String(error)}\r\n`)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const tab: Tab = { sessionId: session.id, title: session.title, term, fit, wrap }
|
|
145
|
+
setTabs((prev) => [...prev, tab])
|
|
146
|
+
setActive(session.id)
|
|
147
|
+
// Sync the first real size back to the PTY (fit() before open may be off).
|
|
148
|
+
void api.resize(session.id, term.cols, term.rows)
|
|
149
|
+
term.focus()
|
|
150
|
+
}, [api, ctx])
|
|
151
|
+
|
|
152
|
+
// First tab on mount.
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (tabs.length === 0) void openTab()
|
|
155
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
156
|
+
}, [])
|
|
157
|
+
|
|
158
|
+
// Refit the active tab whenever the stage gets a size (window resize, tab
|
|
159
|
+
// switch, or the docked column being shown after a collapse). A hidden
|
|
160
|
+
// column reports 0 size, so fit is deferred until it becomes visible.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const stage = stageRef.current
|
|
163
|
+
if (stage === null) return
|
|
164
|
+
const refit = (): void => {
|
|
165
|
+
const tab = tabsRef.current.find((t) => t.sessionId === active)
|
|
166
|
+
if (tab === undefined) return
|
|
167
|
+
try {
|
|
168
|
+
tab.fit.fit()
|
|
169
|
+
void api.resize(tab.sessionId, tab.term.cols, tab.term.rows)
|
|
170
|
+
} catch {
|
|
171
|
+
// Measurement race on first paint; the next resize retry handles it.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const observer = new ResizeObserver(refit)
|
|
175
|
+
observer.observe(stage)
|
|
176
|
+
return () => observer.disconnect()
|
|
177
|
+
}, [active, api])
|
|
178
|
+
|
|
179
|
+
// Apply the active marker on the DOM wrappers (display toggling).
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
for (const tab of tabsRef.current) {
|
|
182
|
+
tab.wrap.classList.toggle(css.termWrapActive, tab.sessionId === active)
|
|
183
|
+
}
|
|
184
|
+
if (active !== null) {
|
|
185
|
+
const tab = tabsRef.current.find((t) => t.sessionId === active)
|
|
186
|
+
if (tab !== undefined) {
|
|
187
|
+
try {
|
|
188
|
+
tab.fit.fit()
|
|
189
|
+
void api.resize(tab.sessionId, tab.term.cols, tab.term.rows)
|
|
190
|
+
} catch {
|
|
191
|
+
// Ignore measurement races on first paint.
|
|
192
|
+
}
|
|
193
|
+
tab.term.focus()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}, [active, api, tabs])
|
|
197
|
+
|
|
198
|
+
const closeTab = (sessionId: string): void => {
|
|
199
|
+
const tab = tabsRef.current.find((t) => t.sessionId === sessionId)
|
|
200
|
+
if (tab !== undefined) {
|
|
201
|
+
void api.close(sessionId)
|
|
202
|
+
tab.term.dispose()
|
|
203
|
+
tab.wrap.remove()
|
|
204
|
+
}
|
|
205
|
+
const next = tabsRef.current.filter((t) => t.sessionId !== sessionId)
|
|
206
|
+
setTabs(next)
|
|
207
|
+
if (active === sessionId) setActive(next[0]?.sessionId ?? null)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Panel teardown: kill every host session and dispose the xterm instances.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const current = tabsRef.current
|
|
213
|
+
return () => {
|
|
214
|
+
for (const tab of current) {
|
|
215
|
+
void api.close(tab.sessionId)
|
|
216
|
+
tab.term.dispose()
|
|
217
|
+
tab.wrap.remove()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
221
|
+
}, [])
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className={css.col} data-dsh-term="">
|
|
225
|
+
<div className={css.toolbar}>
|
|
226
|
+
<span className={css.title}>终端</span>
|
|
227
|
+
<span className={css.tabDivider} />
|
|
228
|
+
{tabs.map((tab) => (
|
|
229
|
+
<button
|
|
230
|
+
key={tab.sessionId}
|
|
231
|
+
type="button"
|
|
232
|
+
className={`${css.tab}${tab.sessionId === active ? ` ${css.tabActive}` : ''}`}
|
|
233
|
+
onClick={() => setActive(tab.sessionId)}
|
|
234
|
+
title={tab.title}
|
|
235
|
+
>
|
|
236
|
+
<span>{tab.title}</span>
|
|
237
|
+
<span
|
|
238
|
+
role="button"
|
|
239
|
+
aria-label={`关闭 ${tab.title}`}
|
|
240
|
+
className={css.tabClose}
|
|
241
|
+
onClick={(event) => {
|
|
242
|
+
event.stopPropagation()
|
|
243
|
+
closeTab(tab.sessionId)
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
×
|
|
247
|
+
</span>
|
|
248
|
+
</button>
|
|
249
|
+
))}
|
|
250
|
+
<button type="button" className={css.addTab} title="新建终端" onClick={() => void openTab()}>+</button>
|
|
251
|
+
<span className={css.spacer} />
|
|
252
|
+
<button type="button" className={css.collapse} title="收起" onClick={onClose}>—</button>
|
|
253
|
+
</div>
|
|
254
|
+
<div className={css.stage} ref={stageRef}>
|
|
255
|
+
{tabs.length === 0 && <div className={css.emptyHint}>点击 + 新建终端</div>}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-half bridge to the /dsh-term/* host routes: fetch envelope calls
|
|
3
|
+
* plus one EventSource for the output/exit stream. Thin and stateless — the
|
|
4
|
+
* panel owns all session state.
|
|
5
|
+
* @module dsh-term/client/term/api
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TermEvent, TermSessionInfo, TermSpawnRequest } from '../../core/types.ts'
|
|
9
|
+
|
|
10
|
+
/** Envelope mirror of the host route layer. */
|
|
11
|
+
type Envelope<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
|
12
|
+
|
|
13
|
+
async function call<T>(path: string, body?: unknown): Promise<T> {
|
|
14
|
+
const response = await fetch(path, {
|
|
15
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
16
|
+
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
|
17
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
18
|
+
})
|
|
19
|
+
const envelope = await response.json() as Envelope<T>
|
|
20
|
+
if (!envelope.ok) throw new Error(envelope.error.message)
|
|
21
|
+
return envelope.value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The stateless client bridge. */
|
|
25
|
+
export class TermApi {
|
|
26
|
+
/** Open a session; returns the wire info. */
|
|
27
|
+
spawn(request: TermSpawnRequest): Promise<TermSessionInfo> {
|
|
28
|
+
return call<TermSessionInfo>('/dsh-term/spawn', request)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Deliver terminal input. */
|
|
32
|
+
write(id: string, data: string): Promise<{ ok: boolean }> {
|
|
33
|
+
return call<{ ok: boolean }>('/dsh-term/write', { id, data })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resize one session. */
|
|
37
|
+
resize(id: string, cols: number, rows: number): Promise<{ ok: boolean }> {
|
|
38
|
+
return call<{ ok: boolean }>('/dsh-term/resize', { id, cols, rows })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Close one session. */
|
|
42
|
+
close(id: string): Promise<{ ok: boolean }> {
|
|
43
|
+
return call<{ ok: boolean }>('/dsh-term/close', { id })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Current session listing (used on reconnect). */
|
|
47
|
+
list(): Promise<{ sessions: readonly TermSessionInfo[] }> {
|
|
48
|
+
return call<{ sessions: readonly TermSessionInfo[] }>('/dsh-term/list')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Subscribe to the output/exit stream. Returns the unsubscribe.
|
|
53
|
+
* @param onEvent - receives every pushed event.
|
|
54
|
+
*/
|
|
55
|
+
subscribe(onEvent: (event: TermEvent) => void): () => void {
|
|
56
|
+
const source = new EventSource('/dsh-term/events')
|
|
57
|
+
source.addEventListener('term', (event) => {
|
|
58
|
+
try {
|
|
59
|
+
onEvent(JSON.parse((event as MessageEvent).data) as TermEvent)
|
|
60
|
+
} catch {
|
|
61
|
+
// Malformed frame: drop it; the stream stays alive.
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
return () => { source.close() }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-term panel styles. The :global block inlines the required xterm.js
|
|
3
|
+
* core styles (MIT, xterm 5.3.0) because the client build only compiles
|
|
4
|
+
* *.module.css; the shell's --aion tokens drive the panel chrome.
|
|
5
|
+
* @module dsh-term/client/term/term.module
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
.col {
|
|
9
|
+
width: 100%;
|
|
10
|
+
height: 100%;
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
border-left: 1px solid var(--aion-border-base, #e5e6eb);
|
|
14
|
+
background: var(--aion-bg-1, #f9fafb);
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
font-family: var(--aion-font-sans, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.title {
|
|
20
|
+
flex: none;
|
|
21
|
+
padding: 0 8px;
|
|
22
|
+
font-size: 12px;
|
|
23
|
+
font-weight: 600;
|
|
24
|
+
color: var(--aion-text-primary, #1f2329);
|
|
25
|
+
user-select: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.tabDivider {
|
|
29
|
+
flex: none;
|
|
30
|
+
width: 1px;
|
|
31
|
+
height: 16px;
|
|
32
|
+
margin: 0 4px;
|
|
33
|
+
background: var(--aion-border-base, #e5e6eb);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.toolbar {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 4px;
|
|
40
|
+
height: 32px;
|
|
41
|
+
padding: 0 6px;
|
|
42
|
+
border-bottom: 1px solid var(--aion-border-base, #e5e6eb);
|
|
43
|
+
background: var(--aion-bg-base, #ffffff);
|
|
44
|
+
flex: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.tab {
|
|
48
|
+
display: inline-flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 6px;
|
|
51
|
+
height: 22px;
|
|
52
|
+
padding: 0 8px;
|
|
53
|
+
border-radius: 5px;
|
|
54
|
+
border: none;
|
|
55
|
+
background: transparent;
|
|
56
|
+
color: var(--aion-text-secondary, #454d5f);
|
|
57
|
+
font-size: 12px;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
max-width: 200px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.tabActive {
|
|
63
|
+
background: var(--aion-bg-2, #f2f3f5);
|
|
64
|
+
color: var(--aion-text-primary, #000000);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.tabClose {
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
width: 14px;
|
|
72
|
+
height: 14px;
|
|
73
|
+
border: none;
|
|
74
|
+
border-radius: 3px;
|
|
75
|
+
background: transparent;
|
|
76
|
+
color: var(--aion-text-tertiary, #86909c);
|
|
77
|
+
font-size: 11px;
|
|
78
|
+
line-height: 1;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.tabClose:hover {
|
|
83
|
+
background: var(--aion-bg-3, #e5e6eb);
|
|
84
|
+
color: var(--aion-text-primary, #000000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.addTab {
|
|
88
|
+
display: inline-flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
justify-content: center;
|
|
91
|
+
width: 22px;
|
|
92
|
+
height: 22px;
|
|
93
|
+
border: none;
|
|
94
|
+
border-radius: 5px;
|
|
95
|
+
background: transparent;
|
|
96
|
+
color: var(--aion-text-secondary, #454d5f);
|
|
97
|
+
font-size: 14px;
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.addTab:hover {
|
|
102
|
+
background: var(--aion-bg-2, #f2f3f5);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.spacer {
|
|
106
|
+
flex: 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.collapse {
|
|
110
|
+
display: inline-flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
justify-content: center;
|
|
113
|
+
width: 22px;
|
|
114
|
+
height: 22px;
|
|
115
|
+
border: none;
|
|
116
|
+
border-radius: 5px;
|
|
117
|
+
background: transparent;
|
|
118
|
+
color: var(--aion-text-secondary, #454d5f);
|
|
119
|
+
font-size: 13px;
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.collapse:hover {
|
|
124
|
+
background: var(--aion-bg-2, #f2f3f5);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.stage {
|
|
128
|
+
position: relative;
|
|
129
|
+
flex: 1;
|
|
130
|
+
min-height: 0;
|
|
131
|
+
background: var(--aion-bg-base, #ffffff);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.termWrap {
|
|
135
|
+
position: absolute;
|
|
136
|
+
inset: 0;
|
|
137
|
+
padding: 4px;
|
|
138
|
+
display: none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.termWrapActive {
|
|
142
|
+
display: block;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.emptyHint {
|
|
146
|
+
position: absolute;
|
|
147
|
+
inset: 0;
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
justify-content: center;
|
|
151
|
+
color: var(--aion-text-tertiary, #86909c);
|
|
152
|
+
font-size: 13px;
|
|
153
|
+
}
|