All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m8s
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>
258 lines
8.2 KiB
TypeScript
258 lines
8.2 KiB
TypeScript
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,
|
||
// 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 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 — 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 };
|
||
}
|