import { useCallback, useEffect, useRef, useState } from "react"; import { FitAddon } from "@xterm/addon-fit"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { useXTerm } from "react-xtermjs"; import { toast } from "sonner"; import { getMacLineNavigationSequence } from "../lib/mac-line-navigation"; import { handleTerminalClipboardKeyEvent, createTerminalContextMenuHandler, } from "../terminal/terminal-clipboard"; import { useAppTheme as useTheme, resolveTermixThemeColors, DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS, ensureTerminalFontsLoaded, RobustClipboardProvider, copyToClipboard, readFromClipboard, } from "@termix/plugin-sdk/ui"; import { useTranslation } from "@termix/plugin-sdk/frontend"; export function LocalTerminal({ instanceId, isVisible, }: { instanceId: string; isVisible: boolean; }) { const { t } = useTranslation(); // Read via a ref inside the session-lifecycle effect below so a language // change (which gives react-i18next a new `t` identity) doesn't tear down // the running local shell and spawn a new session. const tRef = useRef(t); tRef.current = t; const { theme: appTheme } = useTheme(); const { instance: terminal, ref: xtermRef } = useXTerm(); const fitAddonRef = useRef(null); const sessionIdRef = useRef(null); const [isWindows, setIsWindows] = useState(false); const [shell, setShell] = useState<"default" | "wsl">("default"); useEffect(() => { window.electronAPI?.getPlatform().then((platform) => { setIsWindows(platform === "win32"); }); }, []); const fit = useCallback(() => { const fitAddon = fitAddonRef.current; const sessionId = sessionIdRef.current; if (!terminal || !fitAddon) return; fitAddon.fit(); if (sessionId) { window.electronAPI.resizeLocalTerminal( sessionId, terminal.cols, terminal.rows, ); } }, [terminal]); useEffect(() => { if (!terminal) return; const colors = resolveTermixThemeColors("termix", appTheme); const font = TERMINAL_FONTS.find( (item) => item.value === DEFAULT_TERMINAL_CONFIG.fontFamily, ); ensureTerminalFontsLoaded(font?.value ?? TERMINAL_FONTS[0].value); terminal.options.theme = colors; terminal.options.fontFamily = font?.fallback ?? TERMINAL_FONTS[0].fallback; terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize; }, [appTheme, terminal]); useEffect(() => { if (!terminal || !window.electronAPI?.isElectron) return; const fitAddon = new FitAddon(); const clipboardProvider = new RobustClipboardProvider(); const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider); fitAddonRef.current = fitAddon; terminal.loadAddon(fitAddon); terminal.loadAddon(clipboardAddon); fitAddon.fit(); async function writeTextToClipboard(text: string): Promise { const ok = await copyToClipboard(text); if (!ok) toast.error(tRef.current("terminal.clipboardWriteFailed")); return ok; } async function readTextFromClipboard(): Promise { const text = await readFromClipboard(); if (!text) toast.error(tRef.current("terminal.clipboardReadFailed")); return text; } const clipboardActions = { writeTextToClipboard, readTextFromClipboard }; terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => { if (e.type !== "keydown") return true; const sequence = getMacLineNavigationSequence(e); if (sequence) { e.preventDefault(); e.stopPropagation(); terminal.input(sequence, true); return false; } // No native "paste" event listener here (unlike the SSH terminal), so // plain Ctrl/Cmd+V reads the clipboard explicitly rather than relying // on the browser's own paste event. return handleTerminalClipboardKeyEvent(e, terminal, clipboardActions, { plainPasteMode: "explicit", }); }); const handleContextMenu = createTerminalContextMenuHandler( terminal, clipboardActions, ); const element = xtermRef.current; element?.addEventListener("contextmenu", handleContextMenu); let disposed = false; let removeData = () => {}; let removeExit = () => {}; const input = terminal.onData((data) => { const sessionId = sessionIdRef.current; if (sessionId) window.electronAPI.writeLocalTerminal(sessionId, data); }); window.electronAPI .startLocalTerminal({ cols: terminal.cols, rows: terminal.rows, shell }) .then(({ sessionId }) => { if (disposed) { window.electronAPI.closeLocalTerminal(sessionId); return; } sessionIdRef.current = sessionId; removeData = window.electronAPI.onLocalTerminalData(sessionId, (data) => terminal.write(data), ); removeExit = window.electronAPI.onLocalTerminalExit( sessionId, (exitCode) => { sessionIdRef.current = null; terminal.write( `\r\n\x1b[33mProcess exited (${exitCode})\x1b[0m\r\n`, ); }, ); return window.electronAPI.readyLocalTerminal(sessionId); }) .catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); terminal.write(`\r\n\x1b[31m${message}\x1b[0m\r\n`); }); const observer = new ResizeObserver(() => fit()); if (xtermRef.current) observer.observe(xtermRef.current); return () => { disposed = true; terminal.attachCustomKeyEventHandler(() => true); observer.disconnect(); input.dispose(); removeData(); removeExit(); element?.removeEventListener("contextmenu", handleContextMenu); clipboardProvider.dispose(); const sessionId = sessionIdRef.current; sessionIdRef.current = null; if (sessionId) window.electronAPI.closeLocalTerminal(sessionId); fitAddonRef.current = null; fitAddon.dispose(); }; }, [fit, instanceId, shell, terminal, xtermRef]); useEffect(() => { if (isVisible) requestAnimationFrame(fit); }, [fit, isVisible]); return (
{isWindows && (
)}
); }