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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 双语字典(zh/en),语言由宿主 locale 服务驱动。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type Lang = 'zh' | 'en'
|
|
6
|
+
|
|
7
|
+
let lang: Lang = 'en'
|
|
8
|
+
|
|
9
|
+
export function setLang(next: Lang): void {
|
|
10
|
+
lang = next
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getLang(): Lang {
|
|
14
|
+
return lang
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DICT: Record<Lang, Record<string, string>> = {
|
|
18
|
+
zh: {
|
|
19
|
+
'terminal.title': '终端',
|
|
20
|
+
'terminal.toggleTip': '打开终端面板 Alt+C',
|
|
21
|
+
'terminal.new': '新建终端',
|
|
22
|
+
'terminal.closeTab': '关闭终端',
|
|
23
|
+
'terminal.closeDrawer': '收起抽屉',
|
|
24
|
+
'terminal.mode.dock': '占高度',
|
|
25
|
+
'terminal.mode.overlay': '浮层',
|
|
26
|
+
'terminal.mode.toggle': '切换抽屉模式',
|
|
27
|
+
'terminal.status.connected': '已连接',
|
|
28
|
+
'terminal.status.connecting': '连接中…',
|
|
29
|
+
'terminal.status.reconnecting': '重连中…',
|
|
30
|
+
'terminal.status.disconnected': '已断开',
|
|
31
|
+
'terminal.exited': '已退出',
|
|
32
|
+
'terminal.empty': '暂无终端会话,点击右上角 + 新建',
|
|
33
|
+
'terminal.error.limit': '终端数量已达上限',
|
|
34
|
+
'terminal.error.spawn': '终端启动失败',
|
|
35
|
+
},
|
|
36
|
+
en: {
|
|
37
|
+
'terminal.title': 'Terminal',
|
|
38
|
+
'terminal.toggleTip': 'Open terminal panel Alt+C',
|
|
39
|
+
'terminal.new': 'New terminal',
|
|
40
|
+
'terminal.closeTab': 'Close terminal',
|
|
41
|
+
'terminal.closeDrawer': 'Collapse drawer',
|
|
42
|
+
'terminal.mode.dock': 'Docked',
|
|
43
|
+
'terminal.mode.overlay': 'Overlay',
|
|
44
|
+
'terminal.mode.toggle': 'Toggle drawer mode',
|
|
45
|
+
'terminal.status.connected': 'Connected',
|
|
46
|
+
'terminal.status.connecting': 'Connecting…',
|
|
47
|
+
'terminal.status.reconnecting': 'Reconnecting…',
|
|
48
|
+
'terminal.status.disconnected': 'Disconnected',
|
|
49
|
+
'terminal.exited': 'Exited',
|
|
50
|
+
'terminal.empty': 'No terminal sessions yet — click + to create one',
|
|
51
|
+
'terminal.error.limit': 'Terminal session limit reached',
|
|
52
|
+
'terminal.error.spawn': 'Failed to spawn terminal',
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function t(key: string): string {
|
|
57
|
+
return DICT[lang][key] ?? DICT.en[key] ?? key
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 浏览器半边入口(tsdown 打包为 __ModuleLoader__ 工厂)。
|
|
3
|
+
*
|
|
4
|
+
* 本文件为纯 ESM 模块,直接导出插件形状 { name, inject, apply };
|
|
5
|
+
* window.__ModuleLoader__.load 工厂包装由 tsdown 的 banner/footer 在构建时生成
|
|
6
|
+
* (见 tsdown.config.ts)。外部依赖(react / react/jsx-runtime)构建时保持
|
|
7
|
+
* external,运行时经 factory 的 require 解析宿主模块表(seed)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createPlugin } from './plugin.tsx'
|
|
11
|
+
|
|
12
|
+
const plugin = createPlugin()
|
|
13
|
+
|
|
14
|
+
export const name = plugin.name
|
|
15
|
+
export const inject = plugin.inject
|
|
16
|
+
export const apply = plugin.apply
|
|
17
|
+
export type { ClientCtx, ClientPluginModule } from './plugin.tsx'
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 浏览器半边插件主体(slots 注册)。
|
|
3
|
+
*
|
|
4
|
+
* - shell.overlay:底部终端抽屉;
|
|
5
|
+
* - conversation.session.header.utilities:会话头部开关按钮(宿主 Tooltip 气泡);
|
|
6
|
+
* - Alt+C 快捷键开关抽屉(ctx.effect 挂窗级监听,卸载自动清理;焦点在终端
|
|
7
|
+
* 输入区时放行,让 shell 收到 ESC c 而不误触);
|
|
8
|
+
* - 语言跟随宿主 locale 服务(软依赖,缺失时回退 en)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { terminal } from './controller.ts'
|
|
12
|
+
import { TerminalDrawer } from './drawer.tsx'
|
|
13
|
+
import { setLang } from './i18n.ts'
|
|
14
|
+
import { injectStyles } from './styles.ts'
|
|
15
|
+
import { TerminalToggle } from './toggle.tsx'
|
|
16
|
+
|
|
17
|
+
/** 宿主 slots 服务最小视图。 */
|
|
18
|
+
interface SlotsService {
|
|
19
|
+
inject(name: string, fn: () => unknown): unknown
|
|
20
|
+
register(def: Record<string, unknown>, component: unknown): () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LocaleFace {
|
|
24
|
+
getSnapshot(): { active: string }
|
|
25
|
+
subscribe(fn: () => void): () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 浏览器侧插件上下文(宿主注入)。 */
|
|
29
|
+
export interface ClientCtx {
|
|
30
|
+
get<T = unknown>(name: string): T | undefined
|
|
31
|
+
/** 注册随插件卸载自动清理的副作用(回调返回 disposer)。 */
|
|
32
|
+
effect(fn: () => (() => void) | void): unknown
|
|
33
|
+
on?(event: string, listener: (payload: unknown) => void): unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ClientPluginModule {
|
|
37
|
+
name: string
|
|
38
|
+
inject: string[]
|
|
39
|
+
apply(ctx: ClientCtx): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createPlugin(): ClientPluginModule {
|
|
43
|
+
return {
|
|
44
|
+
name: 'dsh-single-terminal',
|
|
45
|
+
inject: ['slots', 'locale'],
|
|
46
|
+
|
|
47
|
+
apply(ctx: ClientCtx): void {
|
|
48
|
+
const toLang = (active: string): 'zh' | 'en' => (/^zh/i.test(active) ? 'zh' : 'en')
|
|
49
|
+
const locale = ctx.get<LocaleFace>('locale')
|
|
50
|
+
if (locale !== undefined) {
|
|
51
|
+
const syncLang = (): void => { setLang(toLang(locale.getSnapshot().active)) }
|
|
52
|
+
syncLang()
|
|
53
|
+
locale.subscribe(syncLang)
|
|
54
|
+
} else if (typeof ctx.on === 'function') {
|
|
55
|
+
ctx.on('locale/change', (snapshot: unknown) => {
|
|
56
|
+
const active = (snapshot as { active?: string } | undefined)?.active
|
|
57
|
+
if (typeof active === 'string') setLang(toLang(active))
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const slots = ctx.get<SlotsService>('slots')
|
|
62
|
+
if (slots === undefined) return
|
|
63
|
+
injectStyles()
|
|
64
|
+
|
|
65
|
+
// 底部终端抽屉(frame 级浮层,additive list 插槽)。
|
|
66
|
+
slots.inject('shell.overlay', () => slots.register(
|
|
67
|
+
{ name: 'shell.overlay', id: 'dsh-single-terminal', order: 50 },
|
|
68
|
+
TerminalDrawer,
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
// 会话头部开关按钮(utilities 区,会话标题行右侧)。
|
|
72
|
+
slots.inject('conversation.session.header.utilities', () => slots.register(
|
|
73
|
+
{ name: 'conversation.session.header.utilities', id: 'dsh-single-terminal-toggle', order: 100 },
|
|
74
|
+
TerminalToggle,
|
|
75
|
+
))
|
|
76
|
+
|
|
77
|
+
// Alt+C 开关抽屉;焦点在抽屉内(终端输入)时放行,Alt+C 作为 ESC c 发给 shell。
|
|
78
|
+
ctx.effect(() => {
|
|
79
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
80
|
+
if (!event.altKey || event.ctrlKey || event.metaKey || event.code !== 'KeyC') return
|
|
81
|
+
if (event.repeat) return
|
|
82
|
+
const target = event.target
|
|
83
|
+
if (target instanceof Element && target.closest('.dst-drawer') !== null) return
|
|
84
|
+
event.preventDefault()
|
|
85
|
+
terminal.toggle()
|
|
86
|
+
}
|
|
87
|
+
window.addEventListener('keydown', onKeyDown)
|
|
88
|
+
return () => { window.removeEventListener('keydown', onKeyDown) }
|
|
89
|
+
})
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 客户端侧 WS 帧协议视图(与 src/host/types.ts 保持一致;
|
|
3
|
+
* 双面插件不共享运行时代码,这里独立声明)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ShellInfo {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
available: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SessionInfo {
|
|
13
|
+
id: string
|
|
14
|
+
shellId: string
|
|
15
|
+
label: string
|
|
16
|
+
cwd: string
|
|
17
|
+
alive: boolean
|
|
18
|
+
pid: number
|
|
19
|
+
exitCode: number | null
|
|
20
|
+
signal: number | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ClientFrame =
|
|
24
|
+
| { type: 'ping' }
|
|
25
|
+
| { type: 'list' }
|
|
26
|
+
| { type: 'open'; shellId: string; cols: number; rows: number; cwd?: string }
|
|
27
|
+
| { type: 'input'; id: string; data: string }
|
|
28
|
+
| { type: 'resize'; id: string; cols: number; rows: number }
|
|
29
|
+
| { type: 'attach'; id: string }
|
|
30
|
+
| { type: 'close'; id: string }
|
|
31
|
+
|
|
32
|
+
export type HostFrame =
|
|
33
|
+
| { type: 'pong' }
|
|
34
|
+
| { type: 'hello'; platform: string; defaultShell: string; fontSize: number; fontFamily: string }
|
|
35
|
+
| { type: 'shells'; defaultShell: string; shells: ShellInfo[]; sessions: SessionInfo[] }
|
|
36
|
+
| { type: 'opened'; session: SessionInfo }
|
|
37
|
+
| { type: 'data'; id: string; data: string }
|
|
38
|
+
| { type: 'replay'; id: string; data: string }
|
|
39
|
+
| { type: 'exit'; id: string; exitCode: number | null; signal: number | null; closed?: boolean }
|
|
40
|
+
| { type: 'error'; message: string; id?: string }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 浏览器侧 UI 状态持久化(localStorage,JSON 容错)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const PREFIX = 'dsh-single-terminal.'
|
|
6
|
+
|
|
7
|
+
export function loadJson<T>(key: string, fallback: T): T {
|
|
8
|
+
try {
|
|
9
|
+
const raw = localStorage.getItem(PREFIX + key)
|
|
10
|
+
if (raw === null) return fallback
|
|
11
|
+
return JSON.parse(raw) as T
|
|
12
|
+
} catch {
|
|
13
|
+
return fallback
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveJson(key: string, value: unknown): void {
|
|
18
|
+
try {
|
|
19
|
+
localStorage.setItem(PREFIX + key, JSON.stringify(value))
|
|
20
|
+
} catch { /* storage unavailable */ }
|
|
21
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 抽屉全局样式 + 内嵌 xterm.css(构建期生成注入)。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { XTERM_CSS } from './xterm-css.ts'
|
|
6
|
+
|
|
7
|
+
const DRAWER_CSS = `
|
|
8
|
+
.dst-drawer {
|
|
9
|
+
position: absolute;
|
|
10
|
+
left: 0; right: 0; bottom: 0;
|
|
11
|
+
height: var(--dst-height, 360px);
|
|
12
|
+
min-height: 140px;
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
background: #1f2126;
|
|
16
|
+
border-top: 1px solid #3a3d44;
|
|
17
|
+
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.25);
|
|
18
|
+
z-index: 5;
|
|
19
|
+
}
|
|
20
|
+
.dst-drag { height: 5px; flex: none; cursor: row-resize; touch-action: none; }
|
|
21
|
+
.dst-drag:hover { background: #4a4d55; }
|
|
22
|
+
.dst-head {
|
|
23
|
+
display: flex; align-items: center; gap: 4px;
|
|
24
|
+
height: 36px; flex: none; padding: 0 8px;
|
|
25
|
+
border-bottom: 1px solid #3a3d44;
|
|
26
|
+
color: #c9cdd4;
|
|
27
|
+
}
|
|
28
|
+
.dst-tabs { display: flex; align-items: center; gap: 2px; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
|
29
|
+
.dst-tabs::-webkit-scrollbar { display: none; }
|
|
30
|
+
.dst-tab {
|
|
31
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
32
|
+
padding: 4px 8px; border: none; border-radius: 6px;
|
|
33
|
+
background: transparent; color: #9aa0aa; font-size: 12px;
|
|
34
|
+
cursor: pointer; white-space: nowrap;
|
|
35
|
+
}
|
|
36
|
+
.dst-tab:hover { background: rgba(255, 255, 255, 0.06); }
|
|
37
|
+
.dst-tab.active { background: rgba(255, 255, 255, 0.1); color: #eceef1; }
|
|
38
|
+
.dst-dot { width: 6px; height: 6px; border-radius: 50%; background: #3fb950; flex: none; }
|
|
39
|
+
.dst-dot.dead { background: #6e7278; }
|
|
40
|
+
.dst-tabclose {
|
|
41
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
42
|
+
width: 16px; height: 16px; border: none; border-radius: 4px;
|
|
43
|
+
background: transparent; color: inherit; font-size: 12px; line-height: 1;
|
|
44
|
+
cursor: pointer; padding: 0;
|
|
45
|
+
}
|
|
46
|
+
.dst-tabclose:hover { background: rgba(255, 255, 255, 0.14); }
|
|
47
|
+
.dst-actions { display: flex; align-items: center; gap: 2px; flex: none; }
|
|
48
|
+
.dst-conn { width: 8px; height: 8px; border-radius: 50%; flex: none; margin: 0 4px; }
|
|
49
|
+
.dst-conn.connected { background: #3fb950; }
|
|
50
|
+
.dst-conn.connecting, .dst-conn.reconnecting { background: #d29922; }
|
|
51
|
+
.dst-iconbtn {
|
|
52
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
53
|
+
width: 26px; height: 26px; border: none; border-radius: 6px;
|
|
54
|
+
background: transparent; color: inherit; cursor: pointer; padding: 0;
|
|
55
|
+
}
|
|
56
|
+
.dst-iconbtn:hover { background: rgba(255, 255, 255, 0.1); }
|
|
57
|
+
.dst-modebtn {
|
|
58
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
59
|
+
height: 26px; padding: 0 8px; border: none; border-radius: 6px;
|
|
60
|
+
background: transparent; color: #9aa0aa; font-size: 12px; cursor: pointer;
|
|
61
|
+
}
|
|
62
|
+
.dst-modebtn:hover { background: rgba(255, 255, 255, 0.1); color: #eceef1; }
|
|
63
|
+
.dst-menu {
|
|
64
|
+
position: absolute; right: 8px; bottom: 44px; min-width: 170px;
|
|
65
|
+
background: #26282e; border: 1px solid #3a3d44; border-radius: 8px;
|
|
66
|
+
padding: 4px; z-index: 10; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
|
67
|
+
}
|
|
68
|
+
.dst-menuitem {
|
|
69
|
+
display: flex; width: 100%; align-items: center; gap: 8px;
|
|
70
|
+
padding: 6px 10px; border: none; border-radius: 6px;
|
|
71
|
+
background: transparent; color: #d5d8dd; font-size: 13px;
|
|
72
|
+
cursor: pointer; text-align: left;
|
|
73
|
+
}
|
|
74
|
+
.dst-menuitem:hover { background: rgba(255, 255, 255, 0.08); }
|
|
75
|
+
.dst-body { flex: 1; min-height: 0; position: relative; padding: 2px 6px 6px; background: #1a1c20; }
|
|
76
|
+
.dst-term { width: 100%; height: 100%; }
|
|
77
|
+
.dst-empty {
|
|
78
|
+
position: absolute; inset: 0; display: flex;
|
|
79
|
+
align-items: center; justify-content: center;
|
|
80
|
+
color: #6e7278; font-size: 13px;
|
|
81
|
+
}
|
|
82
|
+
.dst-notice {
|
|
83
|
+
position: absolute; left: 50%; transform: translateX(-50%); bottom: 12px;
|
|
84
|
+
max-width: 70%; padding: 6px 12px; border-radius: 8px; z-index: 8;
|
|
85
|
+
background: #4a2226; color: #ffb4b4; font-size: 12px;
|
|
86
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
87
|
+
}
|
|
88
|
+
.dst-headbtn {
|
|
89
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
90
|
+
width: 26px; height: 26px; padding: 0;
|
|
91
|
+
border: none; border-radius: 6px; background: transparent;
|
|
92
|
+
color: inherit; cursor: pointer;
|
|
93
|
+
}
|
|
94
|
+
.dst-headbtn:hover, .dst-headbtn.active { background: rgba(128, 128, 128, 0.18); }
|
|
95
|
+
`
|
|
96
|
+
|
|
97
|
+
let injected = false
|
|
98
|
+
|
|
99
|
+
export function injectStyles(): void {
|
|
100
|
+
if (injected) return
|
|
101
|
+
injected = true
|
|
102
|
+
const style = document.createElement('style')
|
|
103
|
+
style.setAttribute('data-plugin', 'dsh-single-terminal')
|
|
104
|
+
style.textContent = XTERM_CSS + DRAWER_CSS
|
|
105
|
+
document.head.appendChild(style)
|
|
106
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 单标签终端视图(xterm.js)。
|
|
3
|
+
*
|
|
4
|
+
* - Webgl 渲染器加载失败时自动回退到默认渲染;
|
|
5
|
+
* - ResizeObserver → FitAddon.fit() → onResize 帧上报宿主 resize PTY;
|
|
6
|
+
* - 输出经 controller sink 通道写入(挂载前的字节积压在 controller.pending)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef } from 'react'
|
|
10
|
+
import { Terminal } from '@xterm/xterm'
|
|
11
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
12
|
+
import { WebglAddon } from '@xterm/addon-webgl'
|
|
13
|
+
import { terminal, type TabState } from './controller.ts'
|
|
14
|
+
|
|
15
|
+
const THEME = {
|
|
16
|
+
background: '#1a1c20',
|
|
17
|
+
foreground: '#d4d4d4',
|
|
18
|
+
cursor: '#aeafad',
|
|
19
|
+
cursorAccent: '#1a1c20',
|
|
20
|
+
selectionBackground: '#264f78',
|
|
21
|
+
black: '#000000',
|
|
22
|
+
red: '#cd3131',
|
|
23
|
+
green: '#0dbc79',
|
|
24
|
+
yellow: '#e5e510',
|
|
25
|
+
blue: '#2472c8',
|
|
26
|
+
magenta: '#bc3fbc',
|
|
27
|
+
cyan: '#11a8cd',
|
|
28
|
+
white: '#e5e5e5',
|
|
29
|
+
brightBlack: '#666666',
|
|
30
|
+
brightRed: '#f14c4c',
|
|
31
|
+
brightGreen: '#23d18b',
|
|
32
|
+
brightYellow: '#f5f543',
|
|
33
|
+
brightBlue: '#3b8eea',
|
|
34
|
+
brightMagenta: '#d670d6',
|
|
35
|
+
brightCyan: '#29b8db',
|
|
36
|
+
brightWhite: '#ffffff',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function TerminalView(props: {
|
|
40
|
+
tab: TabState
|
|
41
|
+
active: boolean
|
|
42
|
+
fontSize: number
|
|
43
|
+
fontFamily: string
|
|
44
|
+
}) {
|
|
45
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const container = containerRef.current
|
|
49
|
+
if (container === null) return
|
|
50
|
+
const id = props.tab.id
|
|
51
|
+
|
|
52
|
+
const instance = new Terminal({
|
|
53
|
+
fontSize: props.fontSize,
|
|
54
|
+
fontFamily: props.fontFamily,
|
|
55
|
+
cursorBlink: true,
|
|
56
|
+
scrollback: 2000,
|
|
57
|
+
theme: THEME,
|
|
58
|
+
})
|
|
59
|
+
const fit = new FitAddon()
|
|
60
|
+
instance.loadAddon(fit)
|
|
61
|
+
try {
|
|
62
|
+
instance.loadAddon(new WebglAddon())
|
|
63
|
+
} catch { /* no webgl: default canvas renderer */ }
|
|
64
|
+
instance.open(container)
|
|
65
|
+
instance.onData((data) => { terminal.sendInput(id, data) })
|
|
66
|
+
instance.onResize(({ cols, rows }) => { terminal.sendResize(id, cols, rows) })
|
|
67
|
+
|
|
68
|
+
const unregister = terminal.registerSink(id, { write: (data) => { instance.write(data) } })
|
|
69
|
+
|
|
70
|
+
const observer = new ResizeObserver(() => {
|
|
71
|
+
if (container.clientWidth < 10 || container.clientHeight < 10) return
|
|
72
|
+
try { fit.fit() } catch { /* not visible */ }
|
|
73
|
+
})
|
|
74
|
+
observer.observe(container)
|
|
75
|
+
requestAnimationFrame(() => {
|
|
76
|
+
if (container.clientWidth < 10 || container.clientHeight < 10) return
|
|
77
|
+
try { fit.fit() } catch { /* not ready */ }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
observer.disconnect()
|
|
82
|
+
unregister()
|
|
83
|
+
instance.dispose()
|
|
84
|
+
}
|
|
85
|
+
// fontSize/fontFamily 变化不重建(配置改动后刷新页面生效)
|
|
86
|
+
}, [props.tab.id])
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
ref={containerRef}
|
|
91
|
+
className="dst-term"
|
|
92
|
+
style={{ display: props.active ? 'block' : 'none' }}
|
|
93
|
+
/>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 会话头部开关按钮(conversation.session.header.utilities 插槽)。
|
|
3
|
+
*
|
|
4
|
+
* 图标为 panel-bottom(面板底部条填充)样式,几何取自宿主 IconPanelLeftOutline16
|
|
5
|
+
* 旋转 -90°,风格与宿主图标一致;hover 气泡用宿主 Tooltip(label 传 thunk,
|
|
6
|
+
* 气泡显示时求值,语言切换即生效)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
10
|
+
import { terminal, useDrawerState } from './controller.ts'
|
|
11
|
+
import { t } from './i18n.ts'
|
|
12
|
+
|
|
13
|
+
const PANEL_LEFT_PATH = 'M9.67272 0.522841C10.8339 0.522841 11.76 0.522714 12.4963 0.602493C13.2453 0.683657 13.8789 0.854248 14.4264 1.25197C14.7504 1.48739 15.0355 1.77247 15.2709 2.0965C15.6686 2.64394 15.8392 3.27758 15.9204 4.02655C16.0002 4.7629 16 5.68895 16 6.85014V9.14986C16 10.3111 16.0002 11.2371 15.9204 11.9735C15.8392 12.7224 15.6686 13.3561 15.2709 13.9035C15.0355 14.2275 14.7504 14.5126 14.4264 14.748C13.8789 15.1458 13.2453 15.3163 12.4963 15.3975C11.76 15.4773 10.8339 15.4772 9.67272 15.4772H6.3273C5.16611 15.4772 4.24006 15.4773 3.50371 15.3975C2.75474 15.3163 2.1211 15.1458 1.57366 14.748C1.24963 14.5126 0.964549 14.2275 0.729131 13.9035C0.331407 13.3561 0.160817 12.7224 0.0796529 11.9735C-0.000126137 11.2371 1.25338e-09 10.3111 1.25338e-09 9.14986V6.85014C1.25329e-09 5.68895 -0.000126137 4.7629 0.0796529 4.02655C0.160817 3.27758 0.331407 2.64394 0.729131 2.0965C0.964549 1.77247 1.24963 1.48739 1.57366 1.25197C2.1211 0.854248 2.75474 0.683657 3.50371 0.602493C4.24006 0.522714 5.16611 0.522841 6.3273 0.522841H9.67272ZM5.54303 1.88715V14.1118C5.78636 14.1128 6.04709 14.1169 6.3273 14.1169H9.67272C10.8639 14.1169 11.7032 14.1164 12.3493 14.0465C12.9824 13.9779 13.3497 13.8494 13.6268 13.6482C13.8354 13.4966 14.0195 13.3125 14.1711 13.1039C14.3723 12.8268 14.5007 12.4595 14.5693 11.8264C14.6393 11.1803 14.6398 10.341 14.6398 9.14986V6.85014C14.6398 5.65896 14.6393 4.81967 14.5693 4.1736C14.5007 3.54048 14.3723 3.17318 14.1711 2.89609C14.0195 2.68747 13.8354 2.50337 13.6268 2.35179C13.3497 2.1506 12.9824 2.02212 12.3493 1.95353C11.7032 1.88358 10.8639 1.88307 9.67272 1.88307H6.3273C6.04709 1.88307 5.78636 1.8862 5.54303 1.88715ZM4.1828 1.91166C3.99125 1.9216 3.8148 1.93577 3.65076 1.95353C3.01764 2.02212 2.65034 2.1506 2.37325 2.35179C2.16463 2.50337 1.98052 2.68747 1.82895 2.89609C1.62776 3.17318 1.49928 3.54048 1.43069 4.1736C1.36074 4.81967 1.36023 5.65896 1.36023 6.85014V9.14986C1.36023 10.341 1.36074 11.1803 1.43069 11.8264C1.49928 12.4595 1.62776 12.8268 1.82895 13.1039C1.98052 13.3125 2.16463 13.4966 2.37325 13.6482C2.65034 13.8494 3.01764 13.9779 3.65076 14.0465C3.81478 14.0642 3.99127 14.0774 4.1828 14.0873V1.91166Z'
|
|
14
|
+
|
|
15
|
+
function PanelBottomIcon(props: { size?: number }) {
|
|
16
|
+
return (
|
|
17
|
+
<svg width={props.size ?? 16} height={props.size ?? 16} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
18
|
+
<path
|
|
19
|
+
d={PANEL_LEFT_PATH}
|
|
20
|
+
transform="rotate(-90 8 8)"
|
|
21
|
+
fillRule="evenodd"
|
|
22
|
+
clipRule="evenodd"
|
|
23
|
+
fill="currentColor"
|
|
24
|
+
/>
|
|
25
|
+
</svg>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function TerminalToggle() {
|
|
30
|
+
const state = useDrawerState()
|
|
31
|
+
return (
|
|
32
|
+
<Tooltip label={() => t('terminal.toggleTip')} side="bottom">
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
className={`dst-headbtn${state.open ? ' active' : ''}`}
|
|
36
|
+
aria-label={t('terminal.toggleTip')}
|
|
37
|
+
onClick={() => { terminal.toggle() }}
|
|
38
|
+
>
|
|
39
|
+
<PanelBottomIcon size={16} />
|
|
40
|
+
</button>
|
|
41
|
+
</Tooltip>
|
|
42
|
+
)
|
|
43
|
+
}
|
package/src/client/ws.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-single-terminal —— 同源 WebSocket 客户端(自动重连,指数退避)。
|
|
3
|
+
*
|
|
4
|
+
* URL 由 location.origin 推导(http→ws / https→wss),浏览器自动携带
|
|
5
|
+
* dsh-auth cookie(与宿主 /api/remote.mux 相同的鉴权路径)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ClientFrame, HostFrame } from './protocol.ts'
|
|
9
|
+
|
|
10
|
+
export type ConnectionState = 'connecting' | 'connected' | 'reconnecting'
|
|
11
|
+
|
|
12
|
+
export interface TerminalSocketHandlers {
|
|
13
|
+
onFrame(frame: HostFrame): void
|
|
14
|
+
onState(state: ConnectionState): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class TerminalSocket {
|
|
18
|
+
private socket: WebSocket | null = null
|
|
19
|
+
private retry = 0
|
|
20
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
21
|
+
private disposed = false
|
|
22
|
+
|
|
23
|
+
constructor(private readonly handlers: TerminalSocketHandlers) {}
|
|
24
|
+
|
|
25
|
+
connect(): void {
|
|
26
|
+
if (this.disposed || this.socket !== null) return
|
|
27
|
+
this.handlers.onState(this.retry === 0 ? 'connecting' : 'reconnecting')
|
|
28
|
+
const origin = globalThis.location?.origin
|
|
29
|
+
const base = origin !== undefined && origin !== 'null' ? origin : 'http://localhost:3000'
|
|
30
|
+
const url = base.replace(/^http/, 'ws') + '/api/dsh-single-terminal.ws'
|
|
31
|
+
const socket = new WebSocket(url)
|
|
32
|
+
this.socket = socket
|
|
33
|
+
socket.onopen = () => {
|
|
34
|
+
this.retry = 0
|
|
35
|
+
this.handlers.onState('connected')
|
|
36
|
+
}
|
|
37
|
+
socket.onmessage = (event: MessageEvent) => {
|
|
38
|
+
let frame: HostFrame
|
|
39
|
+
try {
|
|
40
|
+
frame = JSON.parse(String(event.data)) as HostFrame
|
|
41
|
+
} catch {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
this.handlers.onFrame(frame)
|
|
45
|
+
}
|
|
46
|
+
socket.onclose = () => {
|
|
47
|
+
this.socket = null
|
|
48
|
+
if (this.disposed) return
|
|
49
|
+
this.scheduleReconnect()
|
|
50
|
+
}
|
|
51
|
+
socket.onerror = () => {
|
|
52
|
+
try { socket.close() } catch { /* closing */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
send(frame: ClientFrame): void {
|
|
57
|
+
if (this.socket !== null && this.socket.readyState === WebSocket.OPEN) {
|
|
58
|
+
this.socket.send(JSON.stringify(frame))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
dispose(): void {
|
|
63
|
+
this.disposed = true
|
|
64
|
+
if (this.timer !== null) clearTimeout(this.timer)
|
|
65
|
+
this.timer = null
|
|
66
|
+
if (this.socket !== null) {
|
|
67
|
+
try { this.socket.close() } catch { /* closing */ }
|
|
68
|
+
this.socket = null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private scheduleReconnect(): void {
|
|
73
|
+
this.retry += 1
|
|
74
|
+
const delay = Math.min(1000 * 2 ** Math.min(this.retry - 1, 4), 15_000)
|
|
75
|
+
this.handlers.onState('reconnecting')
|
|
76
|
+
this.timer = setTimeout(() => {
|
|
77
|
+
this.timer = null
|
|
78
|
+
this.connect()
|
|
79
|
+
}, delay)
|
|
80
|
+
}
|
|
81
|
+
}
|