import { getErrorMessage } from "@/lib/error-message.js"; import React from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { RobustClipboardProvider } from "@/lib/clipboard-provider"; import { copyToClipboard, readFromClipboard } from "@/lib/clipboard"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { Button } from "@/components/button.tsx"; import { Select2 } from "@/components/select2"; import { Card, CardContent } from "@/components/card.tsx"; import { getBasePath } from "@/lib/base-path"; import { resolveConnectionOrigin, buildOriginWsUrl, } from "@/lib/connection-origin.ts"; import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react"; import { toast } from "sonner"; import type { SSHHost } from "@/types"; import { isElectron } from "@/main-axios.ts"; import { useTranslation } from "react-i18next"; import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme"; import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles"; import { useTheme } from "@/components/theme-provider"; import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx"; import { ConnectionLogProvider, useConnectionLog, } from "@/ssh/connection-log/ConnectionLogContext.tsx"; import { useConnectionRetry } from "@/lib/useConnectionRetry.ts"; interface ConsoleTerminalProps { containerId: string; containerName: string; containerState: string; hostConfig: SSHHost; } export function ConsoleTerminal( props: ConsoleTerminalProps, ): React.ReactElement { return ( ); } function ConsoleTerminalInner({ containerId, containerName, containerState, hostConfig, }: ConsoleTerminalProps): React.ReactElement { const { t } = useTranslation(); const { theme: appTheme } = useTheme(); const { instance: terminal, ref: xtermRef } = useXTerm(); const { addLog, clearLogs } = useConnectionLog(); const terminalConfig = React.useMemo( () => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }), [hostConfig.terminalConfig], ); const themeColors = React.useMemo(() => { const activeTheme = terminalConfig.theme; return resolveTermixThemeColors(activeTheme, appTheme); }, [terminalConfig.theme, appTheme]); const [isConnected, setIsConnected] = React.useState(false); const [isConnecting, setIsConnecting] = React.useState(false); const [selectedShell, setSelectedShell] = React.useState("bash"); const wsRef = React.useRef(null); const fitAddonRef = React.useRef(null); const pingIntervalRef = React.useRef(null); React.useEffect(() => { if (!terminal) return; const fitAddon = new FitAddon(); const clipboardProvider = new RobustClipboardProvider(); const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider); const webLinksAddon = new WebLinksAddon(); fitAddonRef.current = fitAddon; terminal.loadAddon(fitAddon); terminal.loadAddon(clipboardAddon); terminal.loadAddon(webLinksAddon); const fontConfig = TERMINAL_FONTS.find( (f) => f.value === terminalConfig.fontFamily, ); const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback; ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value); terminal.options.cursorBlink = terminalConfig.cursorBlink; terminal.options.cursorStyle = terminalConfig.cursorStyle; terminal.options.fontSize = terminalConfig.fontSize; terminal.options.fontFamily = fontFamily; terminal.options.scrollback = terminalConfig.scrollback; terminal.options.letterSpacing = terminalConfig.letterSpacing; terminal.options.lineHeight = terminalConfig.lineHeight; const readTextFromClipboard = async (): Promise => { return readFromClipboard(); }; const writeTextToClipboard = async (text: string): Promise => { await copyToClipboard(text); }; terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => { if (e.type !== "keydown") return true; if ( ((e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) || (e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey)) && e.key.toLowerCase() === "v" ) { e.preventDefault(); e.stopPropagation(); readTextFromClipboard() .then((text) => { if (text) terminal.paste(text); }) .catch(() => { toast.error(t("terminal.clipboardReadFailed")); }); return false; } if ( e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key.toLowerCase() === "c" && terminal.hasSelection() ) { e.preventDefault(); e.stopPropagation(); const selection = terminal.getSelection(); if (selection) { writeTextToClipboard(selection).catch(() => { toast.error(t("terminal.clipboardWriteFailed")); }); terminal.clearSelection(); } return false; } if ( e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key === "Insert" && terminal.hasSelection() ) { e.preventDefault(); e.stopPropagation(); const selection = terminal.getSelection(); if (selection) { writeTextToClipboard(selection).catch(() => { toast.error(t("terminal.clipboardWriteFailed")); }); } return false; } if ( e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && e.key === "Insert" ) { e.preventDefault(); e.stopPropagation(); readTextFromClipboard() .then((text) => { if (text) terminal.paste(text); }) .catch(() => { toast.error(t("terminal.clipboardReadFailed")); }); return false; } return true; }); terminal.options.theme = { background: themeColors.background, foreground: themeColors.foreground, cursor: themeColors.cursor, cursorAccent: themeColors.cursorAccent, selectionBackground: themeColors.selectionBackground, selectionForeground: themeColors.selectionForeground, black: themeColors.black, red: themeColors.red, green: themeColors.green, yellow: themeColors.yellow, blue: themeColors.blue, magenta: themeColors.magenta, cyan: themeColors.cyan, white: themeColors.white, brightBlack: themeColors.brightBlack, brightRed: themeColors.brightRed, brightGreen: themeColors.brightGreen, brightYellow: themeColors.brightYellow, brightBlue: themeColors.brightBlue, brightMagenta: themeColors.brightMagenta, brightCyan: themeColors.brightCyan, brightWhite: themeColors.brightWhite, }; setTimeout(() => { fitAddon.fit(); }, 100); const resizeHandler = () => { if (fitAddonRef.current) { fitAddonRef.current.fit(); if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { const { rows, cols } = terminal; wsRef.current.send( JSON.stringify({ type: "resize", data: { rows, cols }, }), ); } } }; window.addEventListener("resize", resizeHandler); return () => { window.removeEventListener("resize", resizeHandler); clipboardProvider.dispose(); if (wsRef.current) { try { wsRef.current.send(JSON.stringify({ type: "disconnect" })); } catch { // Best-effort disconnect during cleanup. } wsRef.current.close(); wsRef.current = null; } terminal.dispose(); }; }, [terminal, t, terminalConfig, themeColors]); const disconnect = React.useCallback(() => { if (wsRef.current) { try { wsRef.current.send(JSON.stringify({ type: "disconnect" })); } catch { // Best-effort disconnect. } wsRef.current.close(); wsRef.current = null; } setIsConnected(false); retryRef.current.reset(); clearLogs(); if (terminal) { try { terminal.clear(); } catch { // Terminal clear can fail after disposal. } } }, [terminal, clearLogs]); const connect = React.useCallback(async () => { if (!terminal || containerState !== "running") { toast.error(t("docker.containerMustBeRunning")); return; } setIsConnecting(true); addLog({ type: "info", stage: "docker_connecting", message: t("docker.connectingTo", { containerName }), }); try { if (fitAddonRef.current) { fitAddonRef.current.fit(); } const isElectronApp = isElectron(); const isDev = !isElectronApp && process.env.NODE_ENV === "development" && (window.location.port === "3000" || window.location.port === "5173" || window.location.port === ""); let baseWsUrl: string; let wsProtocols: string[] = []; if (isDev) { baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`; } else if (isElectronApp) { const origin = await resolveConnectionOrigin({ connectionType: "ssh", connectionOrigin: hostConfig.connectionOrigin, }); const resolvedUrl = await buildOriginWsUrl({ origin, localPort: 30009, localPath: "/docker/console/", remotePath: "/docker/console/", }); if (!resolvedUrl) { setIsConnecting(false); toast.error(t("errors.remoteServerRequired")); return; } baseWsUrl = resolvedUrl.url; wsProtocols = resolvedUrl.protocols; } else { baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`; } const ws = new WebSocket(baseWsUrl, wsProtocols); ws.onopen = () => { const cols = terminal.cols || 80; const rows = terminal.rows || 24; ws.send( JSON.stringify({ type: "connect", data: { hostConfig, containerId, shell: selectedShell, cols, rows, }, }), ); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); switch (msg.type) { case "output": terminal.write(msg.data); break; case "connected": setIsConnected(true); setIsConnecting(false); retryRef.current.markConnected(); if (msg.data?.shellChanged) { toast.warning( `Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`, ); } else { toast.success(t("docker.connectedTo", { containerName })); } setTimeout(() => { if (fitAddonRef.current) { fitAddonRef.current.fit(); } if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "resize", data: { rows: terminal.rows, cols: terminal.cols }, }), ); } }, 100); break; case "disconnected": setIsConnected(false); setIsConnecting(false); retryRef.current.reset(); terminal.write( `\r\n\x1b[1;33m${msg.message || t("docker.disconnected")}\x1b[0m\r\n`, ); if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } break; case "error": setIsConnecting(false); toast.error(msg.message || t("docker.consoleError")); terminal.write( `\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`, ); addLog({ type: "error", stage: "error", message: msg.message || t("docker.consoleError"), }); retryRef.current.markFailed(); break; } } catch (error) { console.error("Failed to parse WebSocket message:", error); } }; ws.onerror = (error) => { console.error("WebSocket error:", error); setIsConnecting(false); setIsConnected(false); toast.error(t("docker.failedToConnect")); addLog({ type: "error", stage: "error", message: t("docker.failedToConnect"), }); retryRef.current.markFailed(); }; if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); } pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping" })); } }, 30000); ws.onclose = () => { if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } setIsConnected(false); setIsConnecting(false); if (wsRef.current === ws) { wsRef.current = null; } }; wsRef.current = ws; terminal.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "input", data, }), ); } }); } catch (error) { setIsConnecting(false); const message = `Failed to connect: ${getErrorMessage(error)}`; toast.error(message); addLog({ type: "error", stage: "error", message }); retryRef.current.markFailed(); } }, [ terminal, containerState, hostConfig, containerId, selectedShell, containerName, t, addLog, ]); const retry = useConnectionRetry({ connect, autoStart: false, }); const retryRef = React.useRef(retry); retryRef.current = retry; React.useEffect(() => { return () => { if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } if (wsRef.current) { try { wsRef.current.send(JSON.stringify({ type: "disconnect" })); } catch { // Best-effort disconnect during cleanup. } wsRef.current.close(); wsRef.current = null; } setIsConnected(false); }; }, []); if (containerState !== "running") { return (

{t("docker.containerNotRunning")}

{t("docker.startContainerToAccess")}

); } return (
{t("docker.console")}
setSelectedShell(event.target.value)} disabled={isConnected} placeholder={t("docker.selectShell")} className="w-[120px]" >
{!isConnected ? ( ) : ( )}
{!isConnected && !isConnecting && retry.status !== "error" && retry.status !== "disconnected" && (

{t("docker.notConnected")}

{t("docker.clickToConnect")}

)} {!isConnected && (isConnecting || retry.status === "error" || retry.status === "disconnected") && ( { clearLogs(); retry.retryNow(); }} retryLabel={t("docker.connect")} /> )}
); }