feat(libs): add xterm-terminal IIFE library (xterm.js 6.0)
New pnpm workspace package @yggdrasil/xterm-terminal, output to public/xterm/. Output-only terminal (disableStdin) for streaming container stdout/stderr via SSE. - @xterm/xterm@^6.0.0 + @xterm/addon-fit@^0.11.0 (released same day, 2025-12-22; 0.10.0's peerDependency is ^5.0.0 and incompatible) - convertEol: containers emit \n, xterm needs \r\n - stderr wrapped in ANSI red for visual distinction - Catppuccin Latte/Mocha themes matching project highlight.css - Mirrors codemirror-editor IIFE pattern: object literal default export, XtermOptions as class (survives TS erasure for wasm-bindgen) - css.d.ts declares *.css module for tsc side-effect import
This commit is contained in:
parent
26f7465943
commit
246d8a6d67
19
libs/pnpm-lock.yaml
generated
19
libs/pnpm-lock.yaml
generated
@ -107,6 +107,15 @@ importers:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
|
||||
xterm-terminal:
|
||||
dependencies:
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
|
||||
yggdrasil-core: {}
|
||||
|
||||
packages:
|
||||
@ -589,6 +598,12 @@ packages:
|
||||
'@vitest/utils@4.1.10':
|
||||
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/xterm@6.0.0':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@ -1462,6 +1477,10 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
'@xterm/xterm@6.0.0': {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
buffer-image-size@0.6.4:
|
||||
|
||||
17
libs/xterm-terminal/package.json
Normal file
17
libs/xterm-terminal/package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@yggdrasil/xterm-terminal",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0"
|
||||
}
|
||||
}
|
||||
4
libs/xterm-terminal/src/css.d.ts
vendored
Normal file
4
libs/xterm-terminal/src/css.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
// 声明 CSS side-effect import,让 tsc --noEmit 不报 TS2882。
|
||||
// xterm.js 的样式通过 `import '@xterm/xterm/css/xterm.css'` 引入,
|
||||
// vite cssCodeSplit:false 会把它内联进 IIFE 产物。
|
||||
declare module '*.css';
|
||||
28
libs/xterm-terminal/src/index.ts
Normal file
28
libs/xterm-terminal/src/index.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { TerminalInstance, XtermOptions } from './terminal';
|
||||
|
||||
/**
|
||||
* 模块入口:暴露对象字面量 { create } 作为默认导出。
|
||||
* IIFE 产物挂在 window.XtermTerminal 上,由 Rust 侧用 Reflect::get 取
|
||||
* (对象字面量,不能用 wasm-bindgen 的 extern fn——那会被编成函数调用而失败)。
|
||||
*/
|
||||
const XtermTerminal = {
|
||||
_instances: new Map<string, TerminalInstance>(),
|
||||
|
||||
create(containerId: string, options: XtermOptions = new XtermOptions()): TerminalInstance | null {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return null;
|
||||
|
||||
// 销毁同 id 的旧实例
|
||||
const existing = this._instances.get(containerId);
|
||||
if (existing) {
|
||||
existing.destroy();
|
||||
this._instances.delete(containerId);
|
||||
}
|
||||
|
||||
const instance = new TerminalInstance(container, options);
|
||||
this._instances.set(containerId, instance);
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
export default XtermTerminal;
|
||||
108
libs/xterm-terminal/src/terminal.ts
Normal file
108
libs/xterm-terminal/src/terminal.ts
Normal file
@ -0,0 +1,108 @@
|
||||
// xterm.js 终端实例:输出专用(无 stdin),配合 SSE 流式渲染容器 stdout/stderr。
|
||||
//
|
||||
// 设计约束:
|
||||
// - disableStdin: true —— 读者侧不可交互输入,纯输出展示。
|
||||
// - convertEol: true —— 容器输出的 \n 自动转 \r\n(终端换行语义)。
|
||||
// - stderr 用 ANSI 红色前缀包裹,与 stdout 视觉区分。
|
||||
//
|
||||
// 镜像 codemirror-editor/src/editor.ts 的范式:Options 用 class(非 interface)
|
||||
// 以便 TS 擦除后存活,wasm 侧能用 new XtermOptions() 构造。
|
||||
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { DARK_THEME, LIGHT_THEME, type ThemeName } from './themes';
|
||||
|
||||
/** 传给 XtermTerminal.create 的配置。
|
||||
* 必须是 class(非 interface),以便 TS 擦除后存活,
|
||||
* wasm 侧能用 `new XtermOptions()` 构造,并通过 setter 填充字段。
|
||||
*/
|
||||
export class XtermOptions {
|
||||
theme?: ThemeName;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
/** ANSI 红色 + reset 包裹 stderr 文本。 */
|
||||
function red(text: string): string {
|
||||
return `\x1b[31m${text}\x1b[0m`;
|
||||
}
|
||||
|
||||
/** xterm.js 终端实例封装。输出专用,提供 stdout/stderr 流式写入与整段写入两种模式。 */
|
||||
export class TerminalInstance {
|
||||
private term: Terminal;
|
||||
private fitAddon: FitAddon;
|
||||
|
||||
constructor(container: HTMLElement, options: XtermOptions) {
|
||||
this.term = new Terminal({
|
||||
// 容器输出是 \n,终端需要 \r\n 才能回车换行;convertEol 自动转换。
|
||||
convertEol: true,
|
||||
// 输出专用:不接收键盘输入,禁用光标闪烁。
|
||||
disableStdin: true,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'bar',
|
||||
fontFamily: options.fontFamily ?? 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
||||
fontSize: options.fontSize ?? 13,
|
||||
lineHeight: 1.3,
|
||||
scrollback: 5000,
|
||||
theme: options.theme === 'dark' ? DARK_THEME : LIGHT_THEME,
|
||||
});
|
||||
|
||||
this.fitAddon = new FitAddon();
|
||||
this.term.loadAddon(this.fitAddon);
|
||||
this.term.open(container);
|
||||
// 初始 fit,让列宽适配容器宽度。
|
||||
this.fitAddon.fit();
|
||||
|
||||
options.onReady?.();
|
||||
}
|
||||
|
||||
/** 流式写入 stdout 块(SSE stdout 事件)。 */
|
||||
writeStdout(data: string): void {
|
||||
this.term.write(data);
|
||||
}
|
||||
|
||||
/** 流式写入 stderr 块(SSE stderr 事件),红色显示。 */
|
||||
writeStderr(data: string): void {
|
||||
this.term.write(red(data));
|
||||
}
|
||||
|
||||
/** 整段写入(轮询兜底路径拿到完整结果时):清屏后重写 stdout + stderr。 */
|
||||
writeAll(stdout: string, stderr: string): void {
|
||||
this.term.reset();
|
||||
this.term.write(stdout);
|
||||
if (stderr) {
|
||||
this.term.write(red(stderr));
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换主题(热切换,无需重建实例)。 */
|
||||
setTheme(theme: ThemeName): void {
|
||||
this.term.options.theme = theme === 'dark' ? DARK_THEME : LIGHT_THEME;
|
||||
}
|
||||
|
||||
/** 重新计算列宽以适配容器(如父容器尺寸变化时调用)。 */
|
||||
fit(): void {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
|
||||
/** 清屏(新一轮运行前调用)。 */
|
||||
clear(): void {
|
||||
this.term.reset();
|
||||
}
|
||||
|
||||
/** 销毁实例,释放 DOM 与事件监听。EditorHandle::drop → destroy。 */
|
||||
destroy(): void {
|
||||
this.term.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露 XtermOptions 到 window,供 wasm-bindgen 用 new XtermOptions()。
|
||||
// IIFE 的 name 只能挂一个全局(XtermTerminal),故手动 hoist XtermOptions。
|
||||
declare global {
|
||||
interface Window {
|
||||
XtermOptions: typeof XtermOptions;
|
||||
}
|
||||
}
|
||||
window.XtermOptions = XtermOptions;
|
||||
54
libs/xterm-terminal/src/themes.ts
Normal file
54
libs/xterm-terminal/src/themes.ts
Normal file
@ -0,0 +1,54 @@
|
||||
// 主题名:与 codemirror-editor / 项目 themes/ 下 Catppuccin Latte/Mocha 对齐。
|
||||
export type ThemeName = 'light' | 'dark';
|
||||
|
||||
// Catppuccin Latte(浅色)终端配色,与 highlight.css 浅色主题视觉一致。
|
||||
// 色值取自 https://catppuccin.com/palette/ Latte 调色板。
|
||||
export const LIGHT_THEME = {
|
||||
background: '#eff1f5',
|
||||
foreground: '#4c4f69',
|
||||
cursor: '#dc8a78',
|
||||
cursorAccent: '#eff1f5',
|
||||
selectionBackground: '#acb0be',
|
||||
black: '#5c5f77',
|
||||
red: '#d20f39',
|
||||
green: '#40a02b',
|
||||
yellow: '#df8e1d',
|
||||
blue: '#1e66f5',
|
||||
magenta: '#ea76cb',
|
||||
cyan: '#179299',
|
||||
white: '#acb0be',
|
||||
brightBlack: '#6c6f85',
|
||||
brightRed: '#d20f39',
|
||||
brightGreen: '#40a02b',
|
||||
brightYellow: '#df8e1d',
|
||||
brightBlue: '#1e66f5',
|
||||
brightMagenta: '#ea76cb',
|
||||
brightCyan: '#179299',
|
||||
brightWhite: '#bcc0cc',
|
||||
};
|
||||
|
||||
// Catppuccin Mocha(深色)终端配色,与 highlight.css 深色主题视觉一致。
|
||||
// 色值取自 https://catppuccin.com/palette/ Mocha 调色板。
|
||||
export const DARK_THEME = {
|
||||
background: '#1e1e2e',
|
||||
foreground: '#cdd6f4',
|
||||
cursor: '#f5e0dc',
|
||||
cursorAccent: '#1e1e2e',
|
||||
selectionBackground: '#585b70',
|
||||
black: '#45475a',
|
||||
red: '#f38ba8',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
blue: '#89b4fa',
|
||||
magenta: '#f5c2e7',
|
||||
cyan: '#94e2d5',
|
||||
white: '#bac2de',
|
||||
brightBlack: '#585b70',
|
||||
brightRed: '#f38ba8',
|
||||
brightGreen: '#a6e3a1',
|
||||
brightYellow: '#f9e2af',
|
||||
brightBlue: '#89b4fa',
|
||||
brightMagenta: '#f5c2e7',
|
||||
brightCyan: '#94e2d5',
|
||||
brightWhite: '#a6adc8',
|
||||
};
|
||||
4
libs/xterm-terminal/tsconfig.json
Normal file
4
libs/xterm-terminal/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
27
libs/xterm-terminal/vite.config.ts
Normal file
27
libs/xterm-terminal/vite.config.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
// 输出直写 public/xterm/,Dioxus 直接托管,无需拷贝步骤。
|
||||
outDir: resolve(__dirname, '../../public/xterm'),
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
// IIFE 产物挂在 window.XtermTerminal 上,Rust 侧用 Reflect::get 取。
|
||||
name: 'XtermTerminal',
|
||||
fileName: () => 'terminal.js',
|
||||
formats: ['iife'],
|
||||
},
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
// 默认导出(对象字面量 { create() })成为 window.XtermTerminal。
|
||||
exports: 'default',
|
||||
assetFileNames: 'terminal.[ext]',
|
||||
},
|
||||
},
|
||||
cssCodeSplit: false,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
8
libs/xterm-terminal/vitest.config.ts
Normal file
8
libs/xterm-terminal/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
2
public/xterm/terminal.css
Normal file
2
public/xterm/terminal.css
Normal file
@ -0,0 +1,2 @@
|
||||
.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre;font-family:monospace}.xterm .xterm-accessibility-tree>div{transform-origin:0;width:fit-content}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;z-index:11;background:0 0;transition:opacity .1s linear}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{display:none;position:absolute}.xterm .xterm-scrollable-element>.shadow.top{width:100%;height:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;display:block;top:0;left:3px}.xterm .xterm-scrollable-element>.shadow.left{width:3px;height:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;display:block;top:3px;left:0}.xterm .xterm-scrollable-element>.shadow.top-left-corner{width:3px;height:3px;display:block;top:0;left:0}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset}
|
||||
/*$vite$:1*/
|
||||
36
public/xterm/terminal.js
Normal file
36
public/xterm/terminal.js
Normal file
File diff suppressed because one or more lines are too long
1
public/xterm/terminal.js.map
Normal file
1
public/xterm/terminal.js.map
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user