import { onBeforeUnmount } from "vue"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { useSessionStore } from "@/stores/session.store"; import "@xterm/xterm/css/xterm.css"; /** MobaXTerm Classic–inspired terminal theme colors. */ const defaultTheme = { background: "#0d1117", foreground: "#e0e0e0", cursor: "#58a6ff", cursorAccent: "#0d1117", selectionBackground: "rgba(88, 166, 255, 0.3)", selectionForeground: "#ffffff", black: "#0d1117", red: "#f85149", green: "#3fb950", yellow: "#e3b341", blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#e0e0e0", brightBlack: "#484f58", brightRed: "#ff7b72", brightGreen: "#56d364", brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff", brightCyan: "#56d4dd", brightWhite: "#f0f6fc", }; export interface UseTerminalReturn { terminal: Terminal; fitAddon: FitAddon; searchAddon: SearchAddon; mount: (container: HTMLElement) => void; destroy: () => void; write: (data: string) => void; fit: () => void; } /** * Composable that manages an xterm.js Terminal lifecycle. * * Wires bidirectional I/O: * - User keystrokes → ssh_write (via Tauri invoke) * - SSH stdout → xterm.js (via Tauri listen, base64 encoded) * - Terminal resize → ssh_resize (via Tauri invoke) */ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'): UseTerminalReturn { const writeCmd = backend === 'ssh' ? 'ssh_write' : 'pty_write'; const resizeCmd = backend === 'ssh' ? 'ssh_resize' : 'pty_resize'; const dataEvent = backend === 'ssh' ? `ssh:data:${sessionId}` : `pty:data:${sessionId}`; const fitAddon = new FitAddon(); const searchAddon = new SearchAddon(); const webLinksAddon = new WebLinksAddon(); const terminal = new Terminal({ theme: defaultTheme, fontFamily: "'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'Fira Code', Menlo, Monaco, 'Courier New', monospace", fontSize: 14, lineHeight: 1.2, cursorBlink: true, cursorStyle: "block", scrollback: 10000, allowProposedApi: true, convertEol: backend === 'ssh', rightClickSelectsWord: false, }); terminal.loadAddon(fitAddon); terminal.loadAddon(searchAddon); terminal.loadAddon(webLinksAddon); // Forward typed data to the backend terminal.onData((data: string) => { invoke(writeCmd, { sessionId, data }).catch((err: unknown) => { console.error("Write error:", err); }); }); // Forward resize events to the backend terminal.onResize((size: { cols: number; rows: number }) => { invoke(resizeCmd, { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => { console.error("Resize error:", err); }); }); // MobaXTerm-style clipboard: highlight to copy, right-click to paste const selectionDisposable = terminal.onSelectionChange(() => { const sel = terminal.getSelection(); if (sel) { navigator.clipboard.writeText(sel).catch(() => {}); } }); function handleRightClickPaste(e: MouseEvent): void { e.preventDefault(); e.stopPropagation(); navigator.clipboard.readText().then((text) => { if (text) { invoke(writeCmd, { sessionId, data: text }).catch(() => {}); } }).catch(() => {}); } // Listen for SSH output events from the Rust backend (base64 encoded) // Tauri listen() returns a Promise — store both the promise and // the resolved unlisten function so we can clean up properly. let unlistenFn: UnlistenFn | null = null; let unlistenPromise: Promise | null = null; let resizeObserver: ResizeObserver | null = null; // Streaming TextDecoder persists across events so split multi-byte UTF-8 // sequences at chunk boundaries are decoded correctly (e.g. a 3-byte em-dash // split across two Rust read() calls). const utf8Decoder = new TextDecoder("utf-8", { fatal: false }); // Write batching — accumulate chunks and flush once per animation frame. // Without this, every tiny SSH read (sometimes single characters) triggers // a separate terminal.write(), producing a laggy typewriter effect. let pendingData = ""; let rafId: number | null = null; function flushPendingData(): void { rafId = null; if (pendingData) { terminal.write(pendingData); pendingData = ""; } } function queueWrite(data: string): void { pendingData += data; if (rafId === null) { rafId = requestAnimationFrame(flushPendingData); } } function mount(container: HTMLElement): void { terminal.open(container); // Wait for fonts to load before measuring cell dimensions. // If the font (Cascadia Mono etc.) isn't loaded when fitAddon.fit() // runs, canvas.measureText() uses a fallback font and gets wrong // cell widths — producing tiny dashes and 200+ column terminals. document.fonts.ready.then(() => { fitAddon.fit(); }); // Right-click paste on the terminal's DOM element terminal.element?.addEventListener("contextmenu", handleRightClickPaste); // Subscribe to SSH output events for this session. // Tauri v2 listen() callback receives { payload: T } — the base64 string // is in event.payload (not event.data as in Wails). unlistenPromise = listen(dataEvent, (event) => { // Mark tab activity for background sessions try { useSessionStore().markActivity(sessionId); } catch {} const b64data = event.payload; try { // atob() returns Latin-1 — each byte becomes a char code 0x00–0xFF. // Reconstruct raw bytes, then decode with the streaming TextDecoder // which buffers incomplete multi-byte sequences between calls. const binaryStr = atob(b64data); const bytes = new Uint8Array(binaryStr.length); for (let i = 0; i < binaryStr.length; i++) { bytes[i] = binaryStr.charCodeAt(i); } const decoded = utf8Decoder.decode(bytes, { stream: true }); if (decoded) { queueWrite(decoded); } } catch { // Fallback: write raw if not valid base64 queueWrite(b64data); } }); // Capture the resolved unlisten function for synchronous cleanup unlistenPromise.then((fn) => { unlistenFn = fn; }); // Auto-fit when the container resizes resizeObserver = new ResizeObserver(() => { fitAddon.fit(); }); resizeObserver.observe(container); } function destroy(): void { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } // Flush any remaining buffered data before teardown if (pendingData) { terminal.write(pendingData); pendingData = ""; } terminal.element?.removeEventListener("contextmenu", handleRightClickPaste); selectionDisposable.dispose(); // Clean up the Tauri event listener. // If the promise already resolved, call unlisten directly. // If it's still pending, wait for resolution then call it. if (unlistenFn) { unlistenFn(); unlistenFn = null; } else if (unlistenPromise) { unlistenPromise.then((fn) => fn()); } unlistenPromise = null; if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } terminal.dispose(); } function write(data: string): void { terminal.write(data); } function fit(): void { fitAddon.fit(); } onBeforeUnmount(() => { destroy(); }); return { terminal, fitAddon, searchAddon, mount, destroy, write, fit }; }