Files
wakey/ui/src/pages/terminal/useXterm.ts
T
lda Verified 5d77b9aeb2
release / build (push) Successful in 4m16s
refactor terminal page into renderer, catalog, and session hooks
also bump beta cuz why not
2026-10-01 14:26:50 +07:00

364 lines
11 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Terminal as XTerm } from "@xterm/xterm";
import { toast } from "sonner";
import {
MAX_TERMINAL_FONT_SIZE,
MIN_TERMINAL_FONT_SIZE,
} from "@/pages/terminal/TerminalAccessoryBar";
import { visibleTerminalText } from "@/pages/terminal/sessionUtils";
import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
const DEFAULT_TERMINAL_FONT_SIZE = 14;
// Stable behavioral surface shared with session transport and page controls.
export type TerminalPort = {
clear: () => void;
copy: () => Promise<void>;
dimensions: () => { rows: number; cols: number } | null;
forgetTitle: (terminalId: string) => void;
fit: () => void;
focus: () => void;
blurInput: () => void;
onInput: (handler: (data: string) => void) => { dispose: () => void };
onResize: (handler: () => void) => { dispose: () => void };
paste: (write: (data: string) => void) => Promise<void>;
reset: () => void;
scrollPages: (pages: number) => void;
sendResize: (send: (message: string) => void, force?: boolean) => void;
changeFontSize: (delta: number) => void;
setActiveTerminalId: (terminalId: string | undefined) => void;
write: (data: Uint8Array) => void;
writeLine: (line: string) => void;
ready: boolean;
};
type Options = {
pageRef: RefObject<HTMLElement | null>;
hostRef: RefObject<HTMLDivElement | null>;
};
export function useXterm({ pageRef, hostRef }: Options) {
const terminalRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const activeTerminalIdRef = useRef<string | undefined>(undefined);
const lastSizeRef = useRef({ rows: 0, cols: 0 });
const resizeListenersRef = useRef(new Set<() => void>());
const [fontFamily, setFontFamily] = useState<string>();
const [ready, setReady] = useState(false);
const [fontSize, setFontSize] = useState(DEFAULT_TERMINAL_FONT_SIZE);
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
{},
);
const setActiveTerminalId = useCallback((terminalId: string | undefined) => {
activeTerminalIdRef.current = terminalId;
}, []);
useEffect(() => {
let cancelled = false;
void loadTerminalFontFamily().then((loadedFontFamily) => {
if (!cancelled) setFontFamily(loadedFontFamily);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const viewport = window.visualViewport;
const updateViewportHeight = () => {
const height = Math.round(viewport?.height ?? window.innerHeight);
pageRef.current?.style.setProperty(
"--terminal-visual-viewport-height",
`${height}px`,
);
};
updateViewportHeight();
viewport?.addEventListener("resize", updateViewportHeight);
viewport?.addEventListener("scroll", updateViewportHeight);
window.addEventListener("resize", updateViewportHeight);
return () => {
viewport?.removeEventListener("resize", updateViewportHeight);
viewport?.removeEventListener("scroll", updateViewportHeight);
window.removeEventListener("resize", updateViewportHeight);
};
}, [pageRef]);
const sendResize = useCallback(
(send: (message: string) => void, force = false) => {
const terminal = terminalRef.current;
if (!terminal) return;
if (
!force &&
lastSizeRef.current.rows === terminal.rows &&
lastSizeRef.current.cols === terminal.cols
) {
return;
}
lastSizeRef.current = { rows: terminal.rows, cols: terminal.cols };
send(
JSON.stringify({
type: "resize",
rows: terminal.rows,
cols: terminal.cols,
}),
);
},
[],
);
const fit = useCallback(() => fitRef.current?.fit(), []);
const focus = useCallback(() => terminalRef.current?.focus(), []);
const blurInput = useCallback(
() => terminalRef.current?.textarea?.blur(),
[],
);
const reset = useCallback(() => terminalRef.current?.reset(), []);
const clear = useCallback(() => terminalRef.current?.clear(), []);
const write = useCallback(
(data: Uint8Array) => terminalRef.current?.write(data),
[],
);
const writeLine = useCallback(
(line: string) => terminalRef.current?.writeln(line),
[],
);
const dimensions = useCallback(() => {
const terminal = terminalRef.current;
return terminal ? { rows: terminal.rows, cols: terminal.cols } : null;
}, []);
const forgetTitle = useCallback((terminalId: string) => {
setSessionTitles((current) => {
if (!(terminalId in current)) return current;
const next = { ...current };
delete next[terminalId];
return next;
});
}, []);
const onInput = useCallback(
(handler: (data: string) => void) =>
terminalRef.current?.onData(handler) ?? { dispose: () => {} },
[],
);
const onResize = useCallback((handler: () => void) => {
resizeListenersRef.current.add(handler);
return { dispose: () => resizeListenersRef.current.delete(handler) };
}, []);
const scrollPages = useCallback(
(pages: number) => terminalRef.current?.scrollPages(pages),
[],
);
const copy = useCallback(async () => {
const terminal = terminalRef.current;
if (!terminal) return;
const text = terminal.hasSelection()
? terminal.getSelection()
: visibleTerminalText(terminal);
if (!text) {
toast.info("Nothing to copy");
return;
}
try {
await navigator.clipboard.writeText(text);
toast.success(
terminal.hasSelection() ? "Selection copied" : "Visible screen copied",
);
} catch (error) {
toast.error("Clipboard access was denied", {
description: String(error),
});
}
}, []);
const paste = useCallback(async (writeData: (data: string) => void) => {
try {
const text = await navigator.clipboard.readText();
if (text) writeData(text);
terminalRef.current?.focus();
} catch (error) {
toast.error("Clipboard access was denied", {
description: String(error),
});
}
}, []);
const changeFontSize = useCallback((delta: number) => {
const terminal = terminalRef.current;
if (!terminal) return;
const current = terminal.options.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE;
const next = Math.min(
MAX_TERMINAL_FONT_SIZE,
Math.max(MIN_TERMINAL_FONT_SIZE, current + delta),
);
if (next === current) return;
terminal.options.fontSize = next;
setFontSize(next);
window.requestAnimationFrame(() => {
fitRef.current?.fit();
resizeListenersRef.current.forEach((handler) => handler());
});
}, []);
// Wait for the font before creating xterm; this effect owns the instance and its observers.
useEffect(() => {
if (!hostRef.current || !fontFamily) return;
const terminal = new XTerm({
cursorBlink: true,
convertEol: false,
fontFamily,
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
lineHeight: 1.1,
scrollbar: {
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
},
scrollback: 10_000,
theme: {
background: "#0b1117",
foreground: "#e5e7eb",
cursor: "#6ee7a8",
scrollbarSliderBackground: "#52617099",
scrollbarSliderHoverBackground: "#6b7c8dcc",
scrollbarSliderActiveBackground: "#8294a6",
},
allowProposedApi: true,
});
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = "11";
terminal.loadAddon(new UnicodeGraphemesAddon());
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(new ImageAddon({}));
const fitAddon = new FitAddon();
terminal.loadAddon(new ClipboardAddon());
terminal.loadAddon(fitAddon);
terminal.open(hostRef.current);
terminal.attachCustomKeyEventHandler((event) => {
const copiesTerminalSelection =
event.type === "keydown" &&
event.ctrlKey &&
event.shiftKey &&
event.code === "KeyC";
if (!copiesTerminalSelection) return true;
event.preventDefault();
event.stopPropagation();
if (terminal.hasSelection()) {
if (!navigator.clipboard) {
toast.error("Clipboard access requires HTTPS or localhost");
} else {
void navigator.clipboard
.writeText(terminal.getSelection())
.catch((error) =>
toast.error("Clipboard access was denied", {
description: String(error),
}),
);
}
}
return false;
});
const titleChange = terminal.onTitleChange((title) => {
const terminalId = activeTerminalIdRef.current;
if (!terminalId) return;
const normalized = title.trim();
setSessionTitles((current) => {
if (current[terminalId] === normalized) return current;
if (!normalized) {
const next = { ...current };
delete next[terminalId];
return next;
}
return { ...current, [terminalId]: normalized };
});
});
fitAddon.fit();
// Consumers subscribe only after this transition; the port methods stay stable.
terminalRef.current = terminal;
fitRef.current = fitAddon;
setReady(true);
let resizeFrame = 0;
let lastHostWidth = 0;
let lastHostHeight = 0;
const resizeObserver = new ResizeObserver(([entry]) => {
const width = Math.round(entry.contentRect.width);
const height = Math.round(entry.contentRect.height);
if (width === lastHostWidth && height === lastHostHeight) return;
lastHostWidth = width;
lastHostHeight = height;
window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(() => {
fitAddon.fit();
resizeListenersRef.current.forEach((handler) => handler());
});
});
resizeObserver.observe(hostRef.current);
return () => {
titleChange.dispose();
resizeObserver.disconnect();
resizeListenersRef.current.clear();
window.cancelAnimationFrame(resizeFrame);
terminal.dispose();
terminalRef.current = null;
fitRef.current = null;
setReady(false);
};
}, [fontFamily, hostRef]);
const terminal = useMemo<TerminalPort>(
() => ({
clear,
copy,
dimensions,
forgetTitle,
fit,
focus,
blurInput,
onInput,
onResize,
paste,
reset,
scrollPages,
sendResize,
changeFontSize,
setActiveTerminalId,
write,
writeLine,
ready,
}),
[
clear,
copy,
dimensions,
forgetTitle,
fit,
focus,
blurInput,
onInput,
onResize,
paste,
reset,
scrollPages,
sendResize,
changeFontSize,
setActiveTerminalId,
write,
writeLine,
ready,
],
);
return { terminal, fontSize, sessionTitles };
}