dsh-single-terminal 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.
Files changed (72) hide show
  1. package/README.md +144 -0
  2. package/README.zh.md +129 -0
  3. package/cordis.patch.yml +11 -0
  4. package/lib/client.js +11438 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +588 -0
  7. package/lib/types/client/controller.d.ts +86 -0
  8. package/lib/types/client/controller.d.ts.map +1 -0
  9. package/lib/types/client/controller.js +235 -0
  10. package/lib/types/client/drawer.d.ts +10 -0
  11. package/lib/types/client/drawer.d.ts.map +1 -0
  12. package/lib/types/client/drawer.js +84 -0
  13. package/lib/types/client/i18n.d.ts +8 -0
  14. package/lib/types/client/i18n.d.ts.map +1 -0
  15. package/lib/types/client/i18n.js +51 -0
  16. package/lib/types/client/index.d.ts +13 -0
  17. package/lib/types/client/index.d.ts.map +1 -0
  18. package/lib/types/client/index.js +13 -0
  19. package/lib/types/client/plugin.d.ts +23 -0
  20. package/lib/types/client/plugin.d.ts.map +1 -0
  21. package/lib/types/client/plugin.js +60 -0
  22. package/lib/types/client/protocol.d.ts +81 -0
  23. package/lib/types/client/protocol.d.ts.map +1 -0
  24. package/lib/types/client/protocol.js +5 -0
  25. package/lib/types/client/storage.d.ts +6 -0
  26. package/lib/types/client/storage.d.ts.map +1 -0
  27. package/lib/types/client/storage.js +21 -0
  28. package/lib/types/client/styles.d.ts +5 -0
  29. package/lib/types/client/styles.d.ts.map +1 -0
  30. package/lib/types/client/styles.js +103 -0
  31. package/lib/types/client/term.d.ts +15 -0
  32. package/lib/types/client/term.d.ts.map +1 -0
  33. package/lib/types/client/term.js +86 -0
  34. package/lib/types/client/toggle.d.ts +9 -0
  35. package/lib/types/client/toggle.d.ts.map +1 -0
  36. package/lib/types/client/toggle.js +19 -0
  37. package/lib/types/client/ws.d.ts +25 -0
  38. package/lib/types/client/ws.d.ts.map +1 -0
  39. package/lib/types/client/ws.js +79 -0
  40. package/lib/types/client/xterm-css.d.ts +7 -0
  41. package/lib/types/client/xterm-css.d.ts.map +1 -0
  42. package/lib/types/client/xterm-css.js +224 -0
  43. package/lib/types/host/hub.d.ts +39 -0
  44. package/lib/types/host/hub.d.ts.map +1 -0
  45. package/lib/types/host/hub.js +291 -0
  46. package/lib/types/host/index.d.ts +17 -0
  47. package/lib/types/host/index.d.ts.map +1 -0
  48. package/lib/types/host/index.js +66 -0
  49. package/lib/types/host/shells.d.ts +30 -0
  50. package/lib/types/host/shells.d.ts.map +1 -0
  51. package/lib/types/host/shells.js +202 -0
  52. package/lib/types/host/types.d.ts +109 -0
  53. package/lib/types/host/types.d.ts.map +1 -0
  54. package/lib/types/host/types.js +4 -0
  55. package/package.json +102 -0
  56. package/src/client/controller.ts +293 -0
  57. package/src/client/drawer.tsx +182 -0
  58. package/src/client/i18n.ts +58 -0
  59. package/src/client/index.ts +17 -0
  60. package/src/client/plugin.tsx +92 -0
  61. package/src/client/protocol.ts +40 -0
  62. package/src/client/storage.ts +21 -0
  63. package/src/client/styles.ts +106 -0
  64. package/src/client/term.tsx +95 -0
  65. package/src/client/toggle.tsx +43 -0
  66. package/src/client/ws.ts +81 -0
  67. package/src/client/xterm-css.ts +224 -0
  68. package/src/host/cordis-augment.d.ts +22 -0
  69. package/src/host/hub.ts +302 -0
  70. package/src/host/index.ts +78 -0
  71. package/src/host/shells.ts +216 -0
  72. package/src/host/types.ts +79 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * dsh-single-terminal —— 共享类型:插件配置、WS 帧协议、宿主服务最小视图。
3
+ */
4
+ export interface CustomShellConfig {
5
+ id: string;
6
+ name: string;
7
+ command: string;
8
+ args: string[];
9
+ }
10
+ export interface TerminalPluginConfig {
11
+ defaultShell: string;
12
+ defaultCwd: string;
13
+ scrollbackLimit: number;
14
+ fontSize: number;
15
+ fontFamily: string;
16
+ customShells: CustomShellConfig[];
17
+ }
18
+ export interface ShellInfo {
19
+ id: string;
20
+ name: string;
21
+ available: boolean;
22
+ }
23
+ export interface SessionInfo {
24
+ id: string;
25
+ shellId: string;
26
+ label: string;
27
+ cwd: string;
28
+ alive: boolean;
29
+ pid: number;
30
+ exitCode: number | null;
31
+ signal: number | null;
32
+ }
33
+ /** client → host */
34
+ export type ClientFrame = {
35
+ type: 'ping';
36
+ } | {
37
+ type: 'list';
38
+ } | {
39
+ type: 'open';
40
+ shellId: string;
41
+ cols: number;
42
+ rows: number;
43
+ cwd?: string;
44
+ } | {
45
+ type: 'input';
46
+ id: string;
47
+ data: string;
48
+ } | {
49
+ type: 'resize';
50
+ id: string;
51
+ cols: number;
52
+ rows: number;
53
+ } | {
54
+ type: 'attach';
55
+ id: string;
56
+ } | {
57
+ type: 'close';
58
+ id: string;
59
+ };
60
+ /** host → client */
61
+ export type HostFrame = {
62
+ type: 'pong';
63
+ } | {
64
+ type: 'hello';
65
+ platform: string;
66
+ defaultShell: string;
67
+ fontSize: number;
68
+ fontFamily: string;
69
+ } | {
70
+ type: 'shells';
71
+ defaultShell: string;
72
+ shells: ShellInfo[];
73
+ sessions: SessionInfo[];
74
+ } | {
75
+ type: 'opened';
76
+ session: SessionInfo;
77
+ } | {
78
+ type: 'data';
79
+ id: string;
80
+ data: string;
81
+ } | {
82
+ type: 'replay';
83
+ id: string;
84
+ data: string;
85
+ } | {
86
+ type: 'exit';
87
+ id: string;
88
+ exitCode: number | null;
89
+ signal: number | null;
90
+ closed?: boolean;
91
+ } | {
92
+ type: 'error';
93
+ message: string;
94
+ id?: string;
95
+ };
96
+ import type { IncomingMessage } from 'node:http';
97
+ import type { Duplex } from 'node:stream';
98
+ /** 宿主 connection 服务最小视图(@deepseek-ai/dsh-client-connection)。 */
99
+ export interface ConnectionService {
100
+ requestRejection(request: IncomingMessage): number | undefined;
101
+ }
102
+ /** 宿主 webServer 服务最小视图(@deepseek-ai/dsh-host-webserver)。 */
103
+ export interface WebServerService {
104
+ registerUpgrade(route: {
105
+ path: string;
106
+ handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>;
107
+ }): () => void;
108
+ }
109
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/host/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ /**
2
+ * dsh-single-terminal —— 共享类型:插件配置、WS 帧协议、宿主服务最小视图。
3
+ */
4
+ export {};
package/package.json ADDED
@@ -0,0 +1,102 @@
1
+ {
2
+ "name": "dsh-single-terminal",
3
+ "version": "0.1.0",
4
+ "author": "jsoncode",
5
+ "license": "MIT",
6
+ "description": "Interactive terminal drawer plugin (dual-face): bottom drawer with real PTY terminals (xterm.js), multi-tab, Windows shell picker (PowerShell default / pwsh / CMD / Git Bash / WSL + custom), docked (occupies page height) or overlay mode, keep-alive sessions with reconnect replay. Bilingual UI (zh/en)",
7
+ "type": "module",
8
+ "packageManager": "pnpm@10.33.0",
9
+ "keywords": [
10
+ "dsh-plugin",
11
+ "dsh-single-terminal",
12
+ "terminal",
13
+ "xterm",
14
+ "pty"
15
+ ],
16
+ "publishConfig": {
17
+ "registry": "https://registry.npmjs.org/",
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "check": "tsc -b --pretty false",
22
+ "build": "node -e \"require('fs').rmSync('lib',{recursive:true,force:true})\" && tsc -b && tsdown",
23
+ "watch": "tsdown --watch",
24
+ "verify": "node scripts/verify-client.mjs",
25
+ "release": "npm run check && npm run build && npm run verify && npm version patch -m \"release: v%s\" && git push --follow-tags",
26
+ "publish:npm": "npm run check && npm run build && npm run verify && npm publish --access public"
27
+ },
28
+ "main": "lib/index.js",
29
+ "types": "lib/types/host/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./lib/types/host/index.d.ts",
33
+ "default": "./lib/index.js"
34
+ },
35
+ "./client": {
36
+ "types": "./lib/types/client/index.d.ts",
37
+ "default": "./lib/client.js"
38
+ },
39
+ "./src/*": "./src/*",
40
+ "./package.json": "./package.json"
41
+ },
42
+ "files": [
43
+ "lib/**/*.js",
44
+ "lib/**/*.js.map",
45
+ "lib/**/*.d.ts",
46
+ "lib/**/*.d.ts.map",
47
+ "src",
48
+ "cordis.patch.yml",
49
+ "assets/logo.svg",
50
+ "README.md",
51
+ "README.zh.md"
52
+ ],
53
+ "dsh": {
54
+ "bundle": {
55
+ "patch": "./cordis.patch.yml"
56
+ },
57
+ "client": {
58
+ "inject": [
59
+ "@deepseek-ai/dsh-client-runtime",
60
+ "@deepseek-ai/dsh-client-ui-slots",
61
+ "@deepseek-ai/dsh-api-remotes",
62
+ "@deepseek-ai/dsh-cordis-client-runner"
63
+ ],
64
+ "platform": "web"
65
+ }
66
+ },
67
+ "dependencies": {
68
+ "node-pty": "1.2.0-beta.15",
69
+ "ws": "^8.21.0"
70
+ },
71
+ "devDependencies": {
72
+ "@deepseek-ai/cordis": "^4.0.1",
73
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
74
+ "@deepseek-ai/schemastery": "^3.18.1",
75
+ "@types/node": "^26.2.0",
76
+ "@types/react": "^18.2.0",
77
+ "@types/react-dom": "18.3.0",
78
+ "@types/ws": "^8.5.10",
79
+ "@xterm/addon-fit": "^0.10.0",
80
+ "@xterm/addon-webgl": "^0.18.0",
81
+ "@xterm/xterm": "^5.5.0",
82
+ "react-dom": "18.3.1",
83
+ "tsdown": "^0.22.14",
84
+ "tsx": "^4.19.0",
85
+ "typescript": "^5.9.0"
86
+ },
87
+ "peerDependencies": {
88
+ "@deepseek-ai/cordis": "^4.0.1",
89
+ "@deepseek-ai/schemastery": "^3.18.1",
90
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6",
91
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
92
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
93
+ "@deepseek-ai/dsh-cordis-client-runner": "^0.1.0-rc.6",
94
+ "react": "^18.2.0"
95
+ },
96
+ "pnpm": {
97
+ "onlyBuiltDependencies": [
98
+ "esbuild",
99
+ "node-pty"
100
+ ]
101
+ }
102
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * dsh-single-terminal —— 客户端控制器(模块级单例):
3
+ * WebSocket 生命周期、帧路由、sink 注册(xterm 实例)、UI 状态 store。
4
+ *
5
+ * 会话输出在 sink 注册前先积压到 pending(React 挂载/重连时序下不丢字节);
6
+ * 抽屉关闭不卸载组件(display:none),xterm 缓冲与会话保持。
7
+ */
8
+
9
+ import { useSyncExternalStore } from 'react'
10
+ import type { HostFrame, ShellInfo, SessionInfo } from './protocol.ts'
11
+ import { loadJson, saveJson } from './storage.ts'
12
+ import { TerminalSocket, type ConnectionState } from './ws.ts'
13
+
14
+ export interface TabState {
15
+ id: string
16
+ shellId: string
17
+ label: string
18
+ cwd: string
19
+ alive: boolean
20
+ exitCode: number | null
21
+ }
22
+
23
+ export type DrawerMode = 'docked' | 'overlay'
24
+
25
+ export interface TerminalSink {
26
+ write(data: string): void
27
+ }
28
+
29
+ interface DrawerState {
30
+ open: boolean
31
+ mode: DrawerMode
32
+ height: number
33
+ conn: ConnectionState
34
+ tabs: TabState[]
35
+ activeId: string | null
36
+ shells: ShellInfo[]
37
+ defaultShell: string
38
+ fontSize: number
39
+ fontFamily: string
40
+ notice: string | null
41
+ }
42
+
43
+ class DrawerStore {
44
+ private state: DrawerState = {
45
+ open: false,
46
+ mode: loadJson<DrawerMode>('mode', 'overlay'),
47
+ height: loadJson<number>('height', 360),
48
+ conn: 'connecting',
49
+ tabs: [],
50
+ activeId: null,
51
+ shells: [],
52
+ defaultShell: 'powershell',
53
+ fontSize: 13,
54
+ fontFamily: 'Consolas, "Cascadia Mono", "Courier New", monospace',
55
+ notice: null,
56
+ }
57
+
58
+ private readonly listeners = new Set<() => void>()
59
+
60
+ subscribe = (listener: () => void): (() => void) => {
61
+ this.listeners.add(listener)
62
+ return () => { this.listeners.delete(listener) }
63
+ }
64
+
65
+ getSnapshot = (): DrawerState => this.state
66
+
67
+ private set(patch: Partial<DrawerState>): void {
68
+ this.state = { ...this.state, ...patch }
69
+ for (const listener of this.listeners) listener()
70
+ }
71
+
72
+ setOpen(open: boolean): void {
73
+ this.set({ open })
74
+ }
75
+
76
+ setMode(mode: DrawerMode): void {
77
+ saveJson('mode', mode)
78
+ this.set({ mode })
79
+ }
80
+
81
+ setHeight(height: number): void {
82
+ saveJson('height', height)
83
+ this.set({ height })
84
+ }
85
+
86
+ setConn(conn: ConnectionState): void {
87
+ this.set({ conn })
88
+ }
89
+
90
+ setNotice(notice: string | null): void {
91
+ this.set({ notice })
92
+ }
93
+
94
+ applyHello(frame: { defaultShell: string; fontSize: number; fontFamily: string }): void {
95
+ this.set({ defaultShell: frame.defaultShell, fontSize: frame.fontSize, fontFamily: frame.fontFamily })
96
+ }
97
+
98
+ applyInventory(shells: ShellInfo[], sessions: SessionInfo[]): { adopted: SessionInfo[] } {
99
+ const known = new Set(this.state.tabs.map((tab) => tab.id))
100
+ const adopted = sessions.filter((session) => !known.has(session.id))
101
+ if (adopted.length > 0) {
102
+ const tabs = [...this.state.tabs, ...adopted.map(toTab)]
103
+ const activeId = this.state.activeId ?? tabs[tabs.length - 1]?.id ?? null
104
+ this.set({ shells, tabs, activeId })
105
+ } else {
106
+ this.set({ shells })
107
+ }
108
+ return { adopted }
109
+ }
110
+
111
+ addSession(session: SessionInfo): void {
112
+ if (this.state.tabs.some((tab) => tab.id === session.id)) return
113
+ this.set({ tabs: [...this.state.tabs, toTab(session)], activeId: session.id })
114
+ }
115
+
116
+ setActive(id: string): void {
117
+ this.set({ activeId: id })
118
+ }
119
+
120
+ markDead(id: string, exitCode: number | null): void {
121
+ this.set({
122
+ tabs: this.state.tabs.map((tab) => (tab.id === id ? { ...tab, alive: false, exitCode } : tab)),
123
+ })
124
+ }
125
+
126
+ removeTab(id: string): void {
127
+ const tabs = this.state.tabs.filter((tab) => tab.id !== id)
128
+ const activeId = this.state.activeId === id ? tabs[tabs.length - 1]?.id ?? null : this.state.activeId
129
+ this.set({ tabs, activeId })
130
+ }
131
+ }
132
+
133
+ function toTab(session: SessionInfo): TabState {
134
+ return { id: session.id, shellId: session.shellId, label: session.label, cwd: session.cwd, alive: session.alive, exitCode: session.exitCode }
135
+ }
136
+
137
+ class TerminalController {
138
+ readonly store = new DrawerStore()
139
+
140
+ private socket: TerminalSocket | null = null
141
+ private readonly sinks = new Map<string, TerminalSink>()
142
+ private readonly pending = new Map<string, string>()
143
+ private noticeTimer: ReturnType<typeof setTimeout> | null = null
144
+
145
+ toggle(): void {
146
+ this.setOpen(!this.store.getSnapshot().open)
147
+ }
148
+
149
+ setOpen(open: boolean): void {
150
+ this.store.setOpen(open)
151
+ if (open) {
152
+ this.ensureStarted()
153
+ this.maybeAutoOpen()
154
+ } else {
155
+ this.autoOpening = false
156
+ }
157
+ }
158
+
159
+ ensureStarted(): void {
160
+ if (this.socket !== null) return
161
+ this.socket = new TerminalSocket({
162
+ onFrame: (frame) => { this.onFrame(frame) },
163
+ onState: (state) => {
164
+ this.store.setConn(state)
165
+ // 每次连上(含重连)都拉取会话清单:adopt 存活会话 + attach 回放,
166
+ // 完成页面刷新后的标签恢复。
167
+ if (state === 'connected') this.socket?.send({ type: 'list' })
168
+ },
169
+ })
170
+ this.socket.connect()
171
+ }
172
+
173
+ // 打开抽屉且没有任何终端时自动新建默认 shell;清单未就绪时由 shells 帧兜底。
174
+ private autoOpening = false
175
+
176
+ private maybeAutoOpen(): void {
177
+ const state = this.store.getSnapshot()
178
+ if (!state.open || state.conn !== 'connected' || state.shells.length === 0) return
179
+ if (state.tabs.length > 0 || this.autoOpening) return
180
+ this.autoOpening = true
181
+ this.newTerminal(state.defaultShell)
182
+ }
183
+
184
+ newTerminal(shellId: string): void {
185
+ this.ensureStarted()
186
+ this.socket?.send({ type: 'open', shellId, cols: 80, rows: 24 })
187
+ }
188
+
189
+ closeTab(id: string): void {
190
+ this.sinks.delete(id)
191
+ this.pending.delete(id)
192
+ this.store.removeTab(id)
193
+ this.socket?.send({ type: 'close', id })
194
+ }
195
+
196
+ setActive(id: string): void {
197
+ this.store.setActive(id)
198
+ }
199
+
200
+ setMode(mode: DrawerMode): void {
201
+ this.store.setMode(mode)
202
+ }
203
+
204
+ setHeight(height: number): void {
205
+ this.store.setHeight(height)
206
+ }
207
+
208
+ showNotice(message: string): void {
209
+ this.store.setNotice(message)
210
+ if (this.noticeTimer !== null) clearTimeout(this.noticeTimer)
211
+ this.noticeTimer = setTimeout(() => {
212
+ this.noticeTimer = null
213
+ this.store.setNotice(null)
214
+ }, 6000)
215
+ }
216
+
217
+ sendInput(id: string, data: string): void {
218
+ this.socket?.send({ type: 'input', id, data })
219
+ }
220
+
221
+ sendResize(id: string, cols: number, rows: number): void {
222
+ this.socket?.send({ type: 'resize', id, cols, rows })
223
+ }
224
+
225
+ registerSink(id: string, sink: TerminalSink): () => void {
226
+ this.sinks.set(id, sink)
227
+ const buffered = this.pending.get(id)
228
+ if (buffered !== undefined) {
229
+ this.pending.delete(id)
230
+ sink.write(buffered)
231
+ }
232
+ return () => {
233
+ if (this.sinks.get(id) === sink) this.sinks.delete(id)
234
+ }
235
+ }
236
+
237
+ dispose(): void {
238
+ this.socket?.dispose()
239
+ this.socket = null
240
+ }
241
+
242
+ private writeData(id: string, data: string): void {
243
+ const sink = this.sinks.get(id)
244
+ if (sink !== undefined) {
245
+ sink.write(data)
246
+ return
247
+ }
248
+ this.pending.set(id, (this.pending.get(id) ?? '') + data)
249
+ }
250
+
251
+ private onFrame(frame: HostFrame): void {
252
+ switch (frame.type) {
253
+ case 'hello':
254
+ this.store.applyHello(frame)
255
+ break
256
+ case 'shells': {
257
+ const { adopted } = this.store.applyInventory(frame.shells, frame.sessions)
258
+ for (const session of adopted) {
259
+ this.socket?.send({ type: 'attach', id: session.id })
260
+ }
261
+ this.maybeAutoOpen()
262
+ break
263
+ }
264
+ case 'opened':
265
+ this.autoOpening = false
266
+ this.store.addSession(frame.session)
267
+ break
268
+ case 'data':
269
+ case 'replay':
270
+ this.writeData(frame.id, frame.data)
271
+ break
272
+ case 'exit':
273
+ if (frame.closed === true) {
274
+ this.sinks.delete(frame.id)
275
+ this.pending.delete(frame.id)
276
+ this.store.removeTab(frame.id)
277
+ } else {
278
+ this.store.markDead(frame.id, frame.exitCode)
279
+ }
280
+ break
281
+ case 'error':
282
+ this.autoOpening = false
283
+ this.showNotice(frame.message)
284
+ break
285
+ }
286
+ }
287
+ }
288
+
289
+ export const terminal = new TerminalController()
290
+
291
+ export function useDrawerState(): DrawerState {
292
+ return useSyncExternalStore(terminal.store.subscribe, terminal.store.getSnapshot)
293
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * dsh-single-terminal —— 底部抽屉(shell.overlay 插槽)。
3
+ *
4
+ * - 两种模式:docked(占高度:对 AppFrame 根元素设 inline padding-bottom,把
5
+ * 主内容顶起;找不到 [data-shell-overlay] 锚点时静默降级为浮层)/ overlay(浮层);
6
+ * - 顶部拖拽条调高度(pointer capture + 帧节流),localStorage 持久化 mode/height;
7
+ * - 组件常挂载(关闭时 display:none),保证 xterm 缓冲与会话不因抽屉开合丢失。
8
+ */
9
+
10
+ import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react'
11
+ import { terminal, useDrawerState } from './controller.ts'
12
+ import { t } from './i18n.ts'
13
+ import { TerminalView } from './term.tsx'
14
+
15
+ const HEIGHT_MIN = 140
16
+
17
+ function findFrameElement(): HTMLElement | null {
18
+ const overlay = document.querySelector('[data-shell-overlay]')
19
+ const frame = overlay?.parentElement
20
+ return frame instanceof HTMLElement ? frame : null
21
+ }
22
+
23
+ export function TerminalDrawer() {
24
+ const state = useDrawerState()
25
+ const [menuOpen, setMenuOpen] = useState(false)
26
+ const menuRef = useRef<HTMLDivElement | null>(null)
27
+
28
+ // 首次挂载即建立 WS 连接(空闲连接开销极小;页面刷新后由 list 帧恢复标签)。
29
+ useEffect(() => { terminal.ensureStarted() }, [])
30
+
31
+ // 占高度模式:把 AppFrame 根元素顶起(border-box + padding-bottom)。
32
+ useEffect(() => {
33
+ if (!state.open || state.mode !== 'docked') return
34
+ const frame = findFrameElement()
35
+ if (frame === null) return
36
+ frame.style.boxSizing = 'border-box'
37
+ frame.style.paddingBottom = `${state.height}px`
38
+ return () => {
39
+ frame.style.paddingBottom = ''
40
+ frame.style.boxSizing = ''
41
+ }
42
+ }, [state.open, state.mode, state.height])
43
+
44
+ // 菜单外点关闭。
45
+ useEffect(() => {
46
+ if (!menuOpen) return
47
+ const onPointerDown = (event: PointerEvent) => {
48
+ if (menuRef.current !== null && !menuRef.current.contains(event.target as Node)) {
49
+ setMenuOpen(false)
50
+ }
51
+ }
52
+ document.addEventListener('pointerdown', onPointerDown)
53
+ return () => { document.removeEventListener('pointerdown', onPointerDown) }
54
+ }, [menuOpen])
55
+
56
+ const availableShells = state.shells.filter((shell) => shell.available)
57
+
58
+ const startDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {
59
+ event.preventDefault()
60
+ const handle = event.currentTarget
61
+ const startY = event.clientY
62
+ const startHeight = state.height
63
+ try {
64
+ handle.setPointerCapture(event.pointerId)
65
+ } catch { /* 指针已失效(如合成事件):仅失去拖出把手的跟随能力 */ }
66
+ let lastAt = 0
67
+ const onMove = (move: PointerEvent) => {
68
+ if (move.timeStamp - lastAt < 16) return
69
+ lastAt = move.timeStamp
70
+ const next = Math.min(window.innerHeight - 100, Math.max(HEIGHT_MIN, startHeight + (startY - move.clientY)))
71
+ terminal.setHeight(next)
72
+ }
73
+ const onUp = () => {
74
+ handle.removeEventListener('pointermove', onMove)
75
+ }
76
+ handle.addEventListener('pointermove', onMove)
77
+ handle.addEventListener('pointerup', onUp, { once: true })
78
+ handle.addEventListener('pointercancel', onUp, { once: true })
79
+ }
80
+
81
+ return (
82
+ <div
83
+ className="dst-drawer"
84
+ style={{ display: state.open ? 'flex' : 'none', '--dst-height': `${state.height}px` } as CSSProperties}
85
+ >
86
+ <div className="dst-drag" onPointerDown={startDrag} title={t('terminal.mode.toggle')} />
87
+ <div className="dst-head">
88
+ <div className="dst-tabs">
89
+ {state.tabs.map((tab) => (
90
+ <button
91
+ key={tab.id}
92
+ className={`dst-tab${tab.id === state.activeId ? ' active' : ''}`}
93
+ onClick={() => { terminal.setActive(tab.id) }}
94
+ title={tab.cwd}
95
+ >
96
+ <span className={`dst-dot${tab.alive ? '' : ' dead'}`} />
97
+ <span>{tab.label}</span>
98
+ {!tab.alive && <span>{t('terminal.exited')}</span>}
99
+ <span
100
+ className="dst-tabclose"
101
+ role="button"
102
+ title={t('terminal.closeTab')}
103
+ onClick={(event) => {
104
+ event.stopPropagation()
105
+ terminal.closeTab(tab.id)
106
+ }}
107
+ >
108
+
109
+ </span>
110
+ </button>
111
+ ))}
112
+ </div>
113
+ <span className={`dst-conn ${state.conn}`} title={t(`terminal.status.${state.conn}`)} />
114
+ <div className="dst-actions">
115
+ <button
116
+ className="dst-modebtn"
117
+ onClick={() => { terminal.setMode(state.mode === 'docked' ? 'overlay' : 'docked') }}
118
+ title={t('terminal.mode.toggle')}
119
+ >
120
+ {state.mode === 'docked' ? `⬓ ${t('terminal.mode.dock')}` : `◫ ${t('terminal.mode.overlay')}`}
121
+ </button>
122
+ <div style={{ position: 'relative' }} ref={menuRef}>
123
+ <button
124
+ className="dst-iconbtn"
125
+ onClick={() => { terminal.newTerminal(state.defaultShell) }}
126
+ title={`${t('terminal.new')}(${availableShells.find((shell) => shell.id === state.defaultShell)?.name ?? state.defaultShell})`}
127
+ >
128
+
129
+ </button>
130
+ <button
131
+ className="dst-iconbtn"
132
+ style={{ width: 18 }}
133
+ onClick={() => { setMenuOpen((open) => !open) }}
134
+ title={t('terminal.new')}
135
+ >
136
+
137
+ </button>
138
+ {menuOpen && (
139
+ <div className="dst-menu">
140
+ {availableShells.length === 0 && (
141
+ <div className="dst-menuitem" style={{ cursor: 'default', opacity: 0.6 }}>—</div>
142
+ )}
143
+ {availableShells.map((shell) => (
144
+ <button
145
+ key={shell.id}
146
+ className="dst-menuitem"
147
+ onClick={() => {
148
+ setMenuOpen(false)
149
+ terminal.newTerminal(shell.id)
150
+ }}
151
+ >
152
+ {shell.name}
153
+ </button>
154
+ ))}
155
+ </div>
156
+ )}
157
+ </div>
158
+ <button
159
+ className="dst-iconbtn"
160
+ onClick={() => { terminal.setOpen(false) }}
161
+ title={t('terminal.closeDrawer')}
162
+ >
163
+
164
+ </button>
165
+ </div>
166
+ </div>
167
+ <div className="dst-body">
168
+ {state.tabs.map((tab) => (
169
+ <TerminalView
170
+ key={tab.id}
171
+ tab={tab}
172
+ active={tab.id === state.activeId}
173
+ fontSize={state.fontSize}
174
+ fontFamily={state.fontFamily}
175
+ />
176
+ ))}
177
+ {state.tabs.length === 0 && <div className="dst-empty">{t('terminal.empty')}</div>}
178
+ {state.notice !== null && <div className="dst-notice">{state.notice}</div>}
179
+ </div>
180
+ </div>
181
+ )
182
+ }