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.
- package/README.md +144 -0
- package/README.zh.md +129 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +11438 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +588 -0
- package/lib/types/client/controller.d.ts +86 -0
- package/lib/types/client/controller.d.ts.map +1 -0
- package/lib/types/client/controller.js +235 -0
- package/lib/types/client/drawer.d.ts +10 -0
- package/lib/types/client/drawer.d.ts.map +1 -0
- package/lib/types/client/drawer.js +84 -0
- package/lib/types/client/i18n.d.ts +8 -0
- package/lib/types/client/i18n.d.ts.map +1 -0
- package/lib/types/client/i18n.js +51 -0
- package/lib/types/client/index.d.ts +13 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +13 -0
- package/lib/types/client/plugin.d.ts +23 -0
- package/lib/types/client/plugin.d.ts.map +1 -0
- package/lib/types/client/plugin.js +60 -0
- package/lib/types/client/protocol.d.ts +81 -0
- package/lib/types/client/protocol.d.ts.map +1 -0
- package/lib/types/client/protocol.js +5 -0
- package/lib/types/client/storage.d.ts +6 -0
- package/lib/types/client/storage.d.ts.map +1 -0
- package/lib/types/client/storage.js +21 -0
- package/lib/types/client/styles.d.ts +5 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/client/styles.js +103 -0
- package/lib/types/client/term.d.ts +15 -0
- package/lib/types/client/term.d.ts.map +1 -0
- package/lib/types/client/term.js +86 -0
- package/lib/types/client/toggle.d.ts +9 -0
- package/lib/types/client/toggle.d.ts.map +1 -0
- package/lib/types/client/toggle.js +19 -0
- package/lib/types/client/ws.d.ts +25 -0
- package/lib/types/client/ws.d.ts.map +1 -0
- package/lib/types/client/ws.js +79 -0
- package/lib/types/client/xterm-css.d.ts +7 -0
- package/lib/types/client/xterm-css.d.ts.map +1 -0
- package/lib/types/client/xterm-css.js +224 -0
- package/lib/types/host/hub.d.ts +39 -0
- package/lib/types/host/hub.d.ts.map +1 -0
- package/lib/types/host/hub.js +291 -0
- package/lib/types/host/index.d.ts +17 -0
- package/lib/types/host/index.d.ts.map +1 -0
- package/lib/types/host/index.js +66 -0
- package/lib/types/host/shells.d.ts +30 -0
- package/lib/types/host/shells.d.ts.map +1 -0
- package/lib/types/host/shells.js +202 -0
- package/lib/types/host/types.d.ts +109 -0
- package/lib/types/host/types.d.ts.map +1 -0
- package/lib/types/host/types.js +4 -0
- package/package.json +102 -0
- package/src/client/controller.ts +293 -0
- package/src/client/drawer.tsx +182 -0
- package/src/client/i18n.ts +58 -0
- package/src/client/index.ts +17 -0
- package/src/client/plugin.tsx +92 -0
- package/src/client/protocol.ts +40 -0
- package/src/client/storage.ts +21 -0
- package/src/client/styles.ts +106 -0
- package/src/client/term.tsx +95 -0
- package/src/client/toggle.tsx +43 -0
- package/src/client/ws.ts +81 -0
- package/src/client/xterm-css.ts +224 -0
- package/src/host/cordis-augment.d.ts +22 -0
- package/src/host/hub.ts +302 -0
- package/src/host/index.ts +78 -0
- package/src/host/shells.ts +216 -0
- 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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"}
|
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
|
+
}
|