wraith/src/composables/useTerminal.ts
Vantz Stockwell 1b74527a62
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m53s
feat: tab notifications, session persistence, Docker panel, drag reorder sidebar
Tab activity notifications:
- Background tabs pulse blue when new output arrives
- Clears when you switch to the tab
- useTerminal marks activity on every data event

Session persistence:
- Workspace saved to DB on window close (connection IDs + positions)
- Restored on launch — auto-reconnects saved sessions in order
- workspace_commands: save_workspace, load_workspace

Docker Manager (Tools → Docker Manager):
- Containers tab: list all, start/stop/restart/remove/logs
- Images tab: list all, remove
- Volumes tab: list all, remove
- One-click Builder Prune and System Prune buttons
- All operations via SSH exec channels — no Docker socket exposure

Sidebar drag-and-drop:
- Drag groups to reorder
- Drag connections between groups
- Drag connections within a group to reorder
- Blue border indicator on drop targets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:50:49 -04:00

246 lines
7.7 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,
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<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).
unlistenPromise = listen<string>(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 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
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 };
}