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,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
+ }
@@ -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
+ }