wraith/src/composables/useTerminal.ts
Vantz Stockwell 2307fbe65f
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m8s
fix: terminal resize on tab switch + flickering from activity marking
Resize fix:
- ResizeObserver now checks contentRect dimensions before fitting
- Ignores resize events when container width/height < 50px (hidden tab)
- Prevents xterm.js from calculating 8-char columns on zero-width containers

Flickering fix:
- markActivity throttled to once per second per session
- Was firing on every single data event (hundreds per second during
  active output), triggering Vue reactivity updates on the sessions
  array, causing tab bar and session container re-renders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:48:43 -04:00

258 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 Classicinspired 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,
// SSH always needs EOL conversion. PTY needs it on Windows (ConPTY sends bare \n)
// but not on Unix (PTY driver handles LF→CRLF). navigator.platform is the simplest check.
convertEol: backend === 'ssh' || navigator.platform.startsWith('Win'),
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<UnlistenFn> — store both the promise and
// the resolved unlisten function so we can clean up properly.
let unlistenFn: UnlistenFn | null = null;
let unlistenPromise: Promise<UnlistenFn> | 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).
// Throttle activity marking to avoid Vue reactivity storms
let lastActivityMark = 0;
unlistenPromise = listen<string>(dataEvent, (event) => {
// Mark tab activity at most once per second
const now = Date.now();
if (now - lastActivityMark > 1000) {
lastActivityMark = now;
try { useSessionStore().markActivity(sessionId); } catch {}
}
const b64data = event.payload;
try {
// atob() returns Latin-1 — each byte becomes a char code 0x000xFF.
// 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 — but only if visible
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 50 && entry.contentRect.height > 50) {
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 };
}