feat(v2): add xterm.js terminal pane with Canvas addon
- Install @xterm/xterm, @xterm/addon-canvas, @xterm/addon-fit, @tauri-apps/api - TerminalPane: xterm.js with Catppuccin Mocha theme, auto-fit, resize observer - PTY bridge: Tauri invoke wrappers + event listeners for data/exit - Bidirectional streaming: keyboard input -> PTY write, PTY output -> xterm write - 100ms debounced resize propagation to PTY backend
This commit is contained in:
parent
f15e60be60
commit
bb0e9283fc
4 changed files with 207 additions and 19 deletions
48
v2/package-lock.json
generated
48
v2/package-lock.json
generated
|
|
@ -1,12 +1,18 @@
|
||||||
{
|
{
|
||||||
"name": "v2",
|
"name": "bterminal-v2",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "v2",
|
"name": "bterminal-v2",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@tsconfig/svelte": "^5.0.6",
|
"@tsconfig/svelte": "^5.0.6",
|
||||||
|
|
@ -908,6 +914,16 @@
|
||||||
"vite": "^6.3.0 || ^7.0.0"
|
"vite": "^6.3.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/api": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tsconfig/svelte": {
|
"node_modules/@tsconfig/svelte": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
|
||||||
|
|
@ -939,6 +955,30 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-canvas": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,11 @@
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "^4.3.4",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,38 @@
|
||||||
// PTY Bridge — IPC wrapper for Rust PTY backend
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
// Phase 2: terminal spawn, resize, input/output streaming
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
export interface PtyOptions {
|
export interface PtyOptions {
|
||||||
shell?: string;
|
shell?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
env?: Record<string, string>;
|
args?: string[];
|
||||||
cols?: number;
|
cols?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function spawnPty(options: PtyOptions): Promise<string> {
|
||||||
* Spawn a new PTY session via Tauri IPC.
|
return invoke<string>('pty_spawn', { options });
|
||||||
* Phase 2: implement with @tauri-apps/api invoke
|
|
||||||
*/
|
|
||||||
export async function spawnPty(_options: PtyOptions): Promise<string> {
|
|
||||||
throw new Error('Not implemented — Phase 2');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writePty(_id: string, _data: string): Promise<void> {
|
export async function writePty(id: string, data: string): Promise<void> {
|
||||||
throw new Error('Not implemented — Phase 2');
|
return invoke('pty_write', { id, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resizePty(_id: string, _cols: number, _rows: number): Promise<void> {
|
export async function resizePty(id: string, cols: number, rows: number): Promise<void> {
|
||||||
throw new Error('Not implemented — Phase 2');
|
return invoke('pty_resize', { id, cols, rows });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function killPty(_id: string): Promise<void> {
|
export async function killPty(id: string): Promise<void> {
|
||||||
throw new Error('Not implemented — Phase 2');
|
return invoke('pty_kill', { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onPtyData(id: string, callback: (data: string) => void): Promise<UnlistenFn> {
|
||||||
|
return listen<string>(`pty-data-${id}`, (event) => {
|
||||||
|
callback(event.payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onPtyExit(id: string, callback: () => void): Promise<UnlistenFn> {
|
||||||
|
return listen(`pty-exit-${id}`, () => {
|
||||||
|
callback();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
134
v2/src/lib/components/Terminal/TerminalPane.svelte
Normal file
134
v2/src/lib/components/Terminal/TerminalPane.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
import { CanvasAddon } from '@xterm/addon-canvas';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
import { spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit } from '../../adapters/pty-bridge';
|
||||||
|
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shell?: string;
|
||||||
|
cwd?: string;
|
||||||
|
args?: string[];
|
||||||
|
onExit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { shell, cwd, args, onExit }: Props = $props();
|
||||||
|
|
||||||
|
let terminalEl: HTMLDivElement;
|
||||||
|
let term: Terminal;
|
||||||
|
let fitAddon: FitAddon;
|
||||||
|
let ptyId: string | null = null;
|
||||||
|
let unlistenData: UnlistenFn | null = null;
|
||||||
|
let unlistenExit: UnlistenFn | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
// Catppuccin Mocha xterm theme
|
||||||
|
const catppuccinTheme = {
|
||||||
|
background: '#1e1e2e',
|
||||||
|
foreground: '#cdd6f4',
|
||||||
|
cursor: '#f5e0dc',
|
||||||
|
cursorAccent: '#1e1e2e',
|
||||||
|
selectionBackground: '#45475a',
|
||||||
|
selectionForeground: '#cdd6f4',
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
term = new Terminal({
|
||||||
|
theme: catppuccinTheme,
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'block',
|
||||||
|
scrollback: 10000,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
fitAddon = new FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.loadAddon(new CanvasAddon());
|
||||||
|
term.open(terminalEl);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
const { cols, rows } = term;
|
||||||
|
|
||||||
|
// Spawn PTY
|
||||||
|
try {
|
||||||
|
ptyId = await spawnPty({ shell, cwd, args, cols, rows });
|
||||||
|
|
||||||
|
// Listen for PTY output
|
||||||
|
unlistenData = await onPtyData(ptyId, (data) => {
|
||||||
|
term.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
unlistenExit = await onPtyExit(ptyId, () => {
|
||||||
|
term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
|
||||||
|
onExit?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward keyboard input to PTY
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ptyId) writePty(ptyId, data);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
term.write(`\x1b[31mFailed to spawn terminal: ${e}\x1b[0m\r\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize handling with debounce
|
||||||
|
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
if (ptyId) {
|
||||||
|
const { cols, rows } = term;
|
||||||
|
resizePty(ptyId, cols, rows);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
resizeObserver.observe(terminalEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(async () => {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
unlistenData?.();
|
||||||
|
unlistenExit?.();
|
||||||
|
if (ptyId) {
|
||||||
|
try { await killPty(ptyId); } catch { /* already dead */ }
|
||||||
|
}
|
||||||
|
term?.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="terminal-container" bind:this={terminalEl}></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.terminal-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :global(.xterm) {
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue