All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m6s
Events.On callback gets a CustomEvent with .data property, not the base64 string directly. Added multi-format extraction with debug logging for unexpected shapes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
158 lines
4.4 KiB
TypeScript
158 lines
4.4 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 { Call, Events } from "@wailsio/runtime";
|
||
import "@xterm/xterm/css/xterm.css";
|
||
|
||
const SSH = "github.com/vstockwell/wraith/internal/ssh.SSHService";
|
||
|
||
/** 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;
|
||
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 → SSHService.Write (via Wails Call)
|
||
* - SSH stdout → xterm.js (via Wails Events, base64 encoded)
|
||
* - Terminal resize → SSHService.Resize (via Wails Call)
|
||
*/
|
||
export function useTerminal(sessionId: string): UseTerminalReturn {
|
||
const fitAddon = new FitAddon();
|
||
const searchAddon = new SearchAddon();
|
||
const webLinksAddon = new WebLinksAddon();
|
||
|
||
const terminal = new Terminal({
|
||
theme: defaultTheme,
|
||
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, monospace",
|
||
fontSize: 14,
|
||
lineHeight: 1.2,
|
||
cursorBlink: true,
|
||
cursorStyle: "block",
|
||
scrollback: 10000,
|
||
allowProposedApi: true,
|
||
convertEol: true,
|
||
});
|
||
|
||
terminal.loadAddon(fitAddon);
|
||
terminal.loadAddon(searchAddon);
|
||
terminal.loadAddon(webLinksAddon);
|
||
|
||
// Forward typed data to the SSH backend
|
||
terminal.onData((data: string) => {
|
||
Call.ByName(`${SSH}.Write`, sessionId, data).catch((err: unknown) => {
|
||
console.error("SSH write error:", err);
|
||
});
|
||
});
|
||
|
||
// Forward resize events to the SSH backend
|
||
terminal.onResize((size: { cols: number; rows: number }) => {
|
||
Call.ByName(`${SSH}.Resize`, sessionId, size.cols, size.rows).catch((err: unknown) => {
|
||
console.error("SSH resize error:", err);
|
||
});
|
||
});
|
||
|
||
// Listen for SSH output events from the Go backend (base64 encoded)
|
||
let cleanupEvent: (() => void) | null = null;
|
||
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
|
||
function mount(container: HTMLElement): void {
|
||
terminal.open(container);
|
||
fitAddon.fit();
|
||
|
||
// Subscribe to SSH output events for this session
|
||
// Wails v3 Events.On callback receives a CustomEvent object with .data property
|
||
cleanupEvent = Events.On(`ssh:data:${sessionId}`, (event: any) => {
|
||
// Extract the base64 string from the event
|
||
// event.data is the first argument passed to app.Event.Emit()
|
||
let b64data: string;
|
||
if (typeof event === "string") {
|
||
b64data = event;
|
||
} else if (event?.data && typeof event.data === "string") {
|
||
b64data = event.data;
|
||
} else if (Array.isArray(event?.data)) {
|
||
b64data = String(event.data[0] ?? "");
|
||
} else {
|
||
// Debug: log the actual shape so we can fix it
|
||
console.warn("ssh:data unexpected shape:", JSON.stringify(event)?.slice(0, 200));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const decoded = atob(b64data);
|
||
terminal.write(decoded);
|
||
} catch {
|
||
// Fallback: write raw if not valid base64
|
||
terminal.write(b64data);
|
||
}
|
||
});
|
||
|
||
// Auto-fit when the container resizes
|
||
resizeObserver = new ResizeObserver(() => {
|
||
fitAddon.fit();
|
||
});
|
||
resizeObserver.observe(container);
|
||
}
|
||
|
||
function destroy(): void {
|
||
if (cleanupEvent) {
|
||
cleanupEvent();
|
||
cleanupEvent = 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, mount, destroy, write, fit };
|
||
}
|