diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx index 13e104cb..4fd3f0df 100644 --- a/frontend/src/components/BashExecModal.tsx +++ b/frontend/src/components/BashExecModal.tsx @@ -1,9 +1,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { Terminal as TerminalIcon } from 'lucide-react'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import '@xterm/xterm/css/xterm.css'; +import { loadXtermModules, type Terminal, type FitAddon, type XtermModules } from '@/lib/xtermLoader'; type TerminalContainer = HTMLDivElement & { __resizeObserver?: ResizeObserver }; @@ -45,10 +43,13 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN return; } + let cancelled = false; let attempts = 0; const maxAttempts = 15; // up to 1.5 seconds + let modules: XtermModules | null = null; const checkAndInit = () => { + if (cancelled || !modules) return; // If already initialized, stop. if (xtermRef.current) return; @@ -72,23 +73,28 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN initTimeoutRef.current = setTimeout(checkAndInit, 100); } else { console.warn('BashExecModal: terminal container has zero dimensions after 1.5s, forcing init anyway.'); - initTerminal(container); + initTerminal(container, modules); } return; } // Node exists and has layout - safe to initialize xterm! - initTerminal(container); + initTerminal(container, modules); }; - // Start polling - initTimeoutRef.current = setTimeout(checkAndInit, 50); + void loadXtermModules().then((mods) => { + if (cancelled) return; + modules = mods; + initTimeoutRef.current = setTimeout(checkAndInit, 50); + }).catch((err) => { + console.error('BashExecModal: failed to load xterm:', err); + }); - function initTerminal(containerEl: HTMLDivElement) { + function initTerminal(containerEl: HTMLDivElement, mods: XtermModules) { // xterm.js requires literal color strings in its theme config; CSS variables // and oklch() are not supported by the canvas renderer. These values are // intentionally hardcoded to match the terminal well aesthetic. - const term = new Terminal({ + const term = new mods.Terminal({ theme: { background: '#0a0a0a', foreground: '#d4d4d4', @@ -101,7 +107,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN cursorBlink: true, }); - const fitAddon = new FitAddon(); + const fitAddon = new mods.FitAddon(); term.loadAddon(fitAddon); term.open(containerEl); @@ -200,6 +206,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN } return () => { + cancelled = true; // Clean up ResizeObserver const el = terminalRef.current as TerminalContainer | null; if (el?.__resizeObserver) { diff --git a/frontend/src/components/HostConsole.tsx b/frontend/src/components/HostConsole.tsx index 578c5e36..4ded27f1 100644 --- a/frontend/src/components/HostConsole.tsx +++ b/frontend/src/components/HostConsole.tsx @@ -1,11 +1,8 @@ import { useEffect, useRef, useState, useCallback } from 'react'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import { SerializeAddon } from '@xterm/addon-serialize'; import { ArrowLeft, Copy, Trash2, Download, RefreshCw } from 'lucide-react'; import { Button } from './ui/button'; import { PageMasthead, type MastheadTone } from './ui/PageMasthead'; -import '@xterm/xterm/css/xterm.css'; +import { loadXtermModules, type Terminal, type FitAddon, type SerializeAddon } from '@/lib/xtermLoader'; import { useNodes } from '@/context/NodeContext'; import { copyToClipboard } from '@/lib/clipboard'; @@ -71,124 +68,131 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { if (!container) return; let mounted = true; + let resizeObserver: ResizeObserver | null = null; + let resizeTimeout: ReturnType | undefined; - const term = new Terminal({ - theme: getTerminalTheme(), - fontFamily: "'Geist Mono', monospace", - fontSize: 14, - cursorBlink: true, - }); - - const fitAddon = new FitAddon(); - const serializeAddon = new SerializeAddon(); - term.loadAddon(fitAddon); - term.loadAddon(serializeAddon); - term.open(container); - - xtermRef.current = term; - fitAddonRef.current = fitAddon; - serializeRef.current = serializeAddon; - - requestAnimationFrame(() => { - try { - if (mounted) { - fitAddon.fit(); - setDims({ cols: term.cols, rows: term.rows }); - } - } catch { - // Ignore fit errors during initial render - } - }); - - const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const activeNodeId = localStorage.getItem('sencho-active-node') || ''; - const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : ''; - const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : ''; - const queryString = [nodeParam, stackParam].filter(Boolean).join('&'); - const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`; - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { + void loadXtermModules().then((mods) => { if (!mounted) return; - setConnState('connected'); - setLastActivityAt(Date.now()); - term.focus(); - setTimeout(() => { + const term = new mods.Terminal({ + theme: getTerminalTheme(), + fontFamily: "'Geist Mono', monospace", + fontSize: 14, + cursorBlink: true, + }); + + const fitAddon = new mods.FitAddon(); + const serializeAddon = new mods.SerializeAddon(); + term.loadAddon(fitAddon); + term.loadAddon(serializeAddon); + term.open(container); + + xtermRef.current = term; + fitAddonRef.current = fitAddon; + serializeRef.current = serializeAddon; + + requestAnimationFrame(() => { try { if (mounted) { fitAddon.fit(); setDims({ cols: term.cols, rows: term.rows }); } } catch { - // Ignore + // Ignore fit errors during initial render } - if (ws.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { + }); + + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const activeNodeId = localStorage.getItem('sencho-active-node') || ''; + const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : ''; + const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : ''; + const queryString = [nodeParam, stackParam].filter(Boolean).join('&'); + const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + if (!mounted) return; + setConnState('connected'); + setLastActivityAt(Date.now()); + term.focus(); + + setTimeout(() => { + try { + if (mounted) { + fitAddon.fit(); + setDims({ cols: term.cols, rows: term.rows }); + } + } catch { + // Ignore + } + if (ws.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { + ws.send(JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + })); + } + }, 100); + }; + + ws.onmessage = (event) => { + if (!mounted) return; + const text = typeof event.data === 'string' ? event.data : event.data.toString(); + term.write(text); + setLastActivityAt(Date.now()); + }; + + ws.onerror = () => { + if (!mounted) return; + term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n'); + setConnState('disconnected'); + }; + + ws.onclose = () => { + if (!mounted) return; + term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n'); + setConnState('disconnected'); + }; + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ - type: 'resize', - cols: term.cols, - rows: term.rows, + type: 'input', + payload: data, })); } - }, 100); - }; + }); - ws.onmessage = (event) => { - if (!mounted) return; - const text = typeof event.data === 'string' ? event.data : event.data.toString(); - term.write(text); - setLastActivityAt(Date.now()); - }; + resizeObserver = new ResizeObserver(() => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + if (!mounted || !fitAddonRef.current || !wsRef.current) return; + try { + fitAddonRef.current.fit(); + setDims({ cols: term.cols, rows: term.rows }); + } catch { + return; + } + if (wsRef.current.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { + wsRef.current.send(JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + })); + } + }, 50); + }); - ws.onerror = () => { - if (!mounted) return; - term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n'); - setConnState('disconnected'); - }; - - ws.onclose = () => { - if (!mounted) return; - term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n'); - setConnState('disconnected'); - }; - - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'input', - payload: data, - })); - } + resizeObserver.observe(container); + }).catch((err) => { + console.error('HostConsole: failed to load xterm:', err); }); - let resizeTimeout: ReturnType; - const resizeObserver = new ResizeObserver(() => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { - if (!mounted || !fitAddonRef.current || !wsRef.current) return; - try { - fitAddonRef.current.fit(); - setDims({ cols: term.cols, rows: term.rows }); - } catch { - return; - } - if (wsRef.current.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { - wsRef.current.send(JSON.stringify({ - type: 'resize', - cols: term.cols, - rows: term.rows, - })); - } - }, 50); - }); - - resizeObserver.observe(container); - return () => { mounted = false; - resizeObserver.disconnect(); - clearTimeout(resizeTimeout); + if (resizeObserver) resizeObserver.disconnect(); + if (resizeTimeout) clearTimeout(resizeTimeout); if (wsRef.current) { wsRef.current.close(); wsRef.current = null; diff --git a/frontend/src/components/Terminal.tsx b/frontend/src/components/Terminal.tsx index 063fcb24..5a25ee05 100644 --- a/frontend/src/components/Terminal.tsx +++ b/frontend/src/components/Terminal.tsx @@ -1,12 +1,8 @@ -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import { SearchAddon } from '@xterm/addon-search'; -import { SerializeAddon } from '@xterm/addon-serialize'; import { useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Download, Search, ChevronUp, ChevronDown, X } from 'lucide-react'; -import '@xterm/xterm/css/xterm.css'; +import { loadXtermModules, type Terminal, type FitAddon, type SearchAddon, type SerializeAddon } from '@/lib/xtermLoader'; interface TerminalComponentProps { stackName?: string; @@ -50,11 +46,17 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter let mounted = true; - const initTerminal = () => { + const initTerminal = async () => { if (!mounted || !terminalRef.current) return; + const mods = await loadXtermModules().catch((err) => { + console.error('Terminal: failed to load xterm:', err); + return null; + }); + if (!mods || !mounted || !terminalRef.current) return; + try { - const term = new Terminal({ + const term = new mods.Terminal({ cursorBlink: true, convertEol: true, allowProposedApi: true, @@ -84,9 +86,9 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter scrollback: 10000, }); - const fitAddon = new FitAddon(); - const searchAddon = new SearchAddon(); - const serializeAddon = new SerializeAddon(); + const fitAddon = new mods.FitAddon(); + const searchAddon = new mods.SearchAddon(); + const serializeAddon = new mods.SerializeAddon(); term.loadAddon(fitAddon); term.loadAddon(searchAddon); @@ -167,7 +169,7 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter }; // Initialize terminal after a small delay to ensure container is rendered - const timeoutId = setTimeout(initTerminal, 50); + const timeoutId = setTimeout(() => { void initTerminal(); }, 50); // Attach ResizeObserver to the terminal's parent container let resizeTimeout: number | undefined; diff --git a/frontend/src/lib/xtermLoader.ts b/frontend/src/lib/xtermLoader.ts new file mode 100644 index 00000000..b5cc4f2a --- /dev/null +++ b/frontend/src/lib/xtermLoader.ts @@ -0,0 +1,53 @@ +/** + * Lazy-loaded xterm. The terminal is only mounted by surfaces the user + * explicitly opens (TerminalComponent, BashExecModal, HostConsole), but + * the eager top-level imports used to pull the ~660 KB @xterm chunk plus + * xterm.css into every cold app start. Defer the chunk until the first + * terminal mount; the loader caches the imported modules so subsequent + * mounts hit a warm cache. + * + * All four addons live in the same `xterm` manual chunk (see + * vite.config.ts), so loading the namespace is the same network cost + * whether one or four classes are needed downstream. + */ + +export type Terminal = InstanceType; +export type FitAddon = InstanceType; +export type SearchAddon = InstanceType; +export type SerializeAddon = InstanceType; + +export interface XtermModules { + Terminal: typeof import('@xterm/xterm')['Terminal']; + FitAddon: typeof import('@xterm/addon-fit')['FitAddon']; + SearchAddon: typeof import('@xterm/addon-search')['SearchAddon']; + SerializeAddon: typeof import('@xterm/addon-serialize')['SerializeAddon']; +} + +let cachedPromise: Promise | null = null; + +export function loadXtermModules(): Promise { + if (!cachedPromise) { + cachedPromise = (async () => { + const [xtermMod, fitMod, searchMod, serializeMod] = await Promise.all([ + import('@xterm/xterm'), + import('@xterm/addon-fit'), + import('@xterm/addon-search'), + import('@xterm/addon-serialize'), + import('@xterm/xterm/css/xterm.css'), + ]); + return { + Terminal: xtermMod.Terminal, + FitAddon: fitMod.FitAddon, + SearchAddon: searchMod.SearchAddon, + SerializeAddon: serializeMod.SerializeAddon, + }; + })().catch((err) => { + // On a failed chunk fetch (transient network blip), drop the cached + // promise so the next terminal mount retries the load instead of + // permanently rethrowing. + cachedPromise = null; + throw err; + }); + } + return cachedPromise; +}