diff --git a/modules/webcontainers/components/terminal.tsx b/modules/webcontainers/components/terminal.tsx index 743a32fd..ffc8d250 100644 --- a/modules/webcontainers/components/terminal.tsx +++ b/modules/webcontainers/components/terminal.tsx @@ -27,500 +27,516 @@ export interface TerminalRef { focusTerminal: () => void; } -const - TerminalInner = forwardRef(({ - webcontainerUrl: _webcontainerUrl, - className, - theme = "dark", - webContainerInstance - }, ref) => { - const terminalRef = useRef(null); - const term = useRef(null); - const fitAddon = useRef(null); - const searchAddon = useRef(null); - const [isConnected, setIsConnected] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [showSearch, setShowSearch] = useState(false); - - // Command line state - const currentLine = useRef(""); - const cursorPosition = useRef(0); - const commandHistory = useRef([]); - const historyIndex = useRef(-1); - const currentProcess = useRef(null); - const shellProcess = useRef(null); - - const terminalThemes = { - dark: { - background: "#09090B", - foreground: "#FAFAFA", - cursor: "#FAFAFA", - cursorAccent: "#09090B", - selection: "#27272A", - black: "#18181B", - red: "#EF4444", - green: "#22C55E", - yellow: "#EAB308", - blue: "#3B82F6", - magenta: "#A855F7", - cyan: "#06B6D4", - white: "#F4F4F5", - brightBlack: "#3F3F46", - brightRed: "#F87171", - brightGreen: "#4ADE80", - brightYellow: "#FDE047", - brightBlue: "#60A5FA", - brightMagenta: "#C084FC", - brightCyan: "#22D3EE", - brightWhite: "#FFFFFF", - }, - light: { - background: "#FFFFFF", - foreground: "#18181B", - cursor: "#18181B", - cursorAccent: "#FFFFFF", - selection: "#E4E4E7", - black: "#18181B", - red: "#DC2626", - green: "#16A34A", - yellow: "#CA8A04", - blue: "#2563EB", - magenta: "#9333EA", - cyan: "#0891B2", - white: "#F4F4F5", - brightBlack: "#71717A", - brightRed: "#EF4444", - brightGreen: "#22C55E", - brightYellow: "#EAB308", - brightBlue: "#3B82F6", - brightMagenta: "#A855F7", - brightCyan: "#06B6D4", - brightWhite: "#FAFAFA", - }, - }; - - const writePrompt = useCallback(() => { +const TerminalInner = forwardRef(({ + webcontainerUrl: _webcontainerUrl, + className, + theme = "dark", + webContainerInstance +}, ref) => { + const terminalRef = useRef(null); + const term = useRef(null); + const fitAddon = useRef(null); + const searchAddon = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [showSearch, setShowSearch] = useState(false); + + // Command line state + const currentLine = useRef(""); + const cursorPosition = useRef(0); + const commandHistory = useRef([]); + const historyIndex = useRef(-1); + const currentProcess = useRef(null); + const shellProcess = useRef(null); + + const terminalThemes = { + dark: { + background: "#09090B", + foreground: "#FAFAFA", + cursor: "#FAFAFA", + cursorAccent: "#09090B", + selection: "#27272A", + black: "#18181B", + red: "#EF4444", + green: "#22C55E", + yellow: "#EAB308", + blue: "#3B82F6", + magenta: "#A855F7", + cyan: "#06B6D4", + white: "#F4F4F5", + brightBlack: "#3F3F46", + brightRed: "#F87171", + brightGreen: "#4ADE80", + brightYellow: "#FDE047", + brightBlue: "#60A5FA", + brightMagenta: "#C084FC", + brightCyan: "#22D3EE", + brightWhite: "#FFFFFF", + }, + light: { + background: "#FFFFFF", + foreground: "#18181B", + cursor: "#18181B", + cursorAccent: "#FFFFFF", + selection: "#E4E4E7", + black: "#18181B", + red: "#DC2626", + green: "#16A34A", + yellow: "#CA8A04", + blue: "#2563EB", + magenta: "#9333EA", + cyan: "#0891B2", + white: "#F4F4F5", + brightBlack: "#71717A", + brightRed: "#EF4444", + brightGreen: "#22C55E", + brightYellow: "#EAB308", + brightBlue: "#3B82F6", + brightMagenta: "#A855F7", + brightCyan: "#06B6D4", + brightWhite: "#FAFAFA", + }, + }; + + const writePrompt = useCallback(() => { + if (term.current) { + term.current.write("\r\n$ "); + currentLine.current = ""; + cursorPosition.current = 0; + } + }, []); + + // Expose methods through ref + useImperativeHandle(ref, () => ({ + writeToTerminal: (data: string) => { + if (term.current) { + term.current.write(data); + } + }, + clearTerminal: () => { + clearTerminal(); + }, + focusTerminal: () => { if (term.current) { - term.current.write("\r\n$ "); - currentLine.current = ""; - cursorPosition.current = 0; + term.current.focus(); } - }, []); + }, + })); - // Expose methods through ref - useImperativeHandle(ref, () => ({ - writeToTerminal: (data: string) => { - if (term.current) { - term.current.write(data); - } - }, - clearTerminal: () => { - clearTerminal(); - }, - focusTerminal: () => { - if (term.current) { - term.current.focus(); - } - }, - })); + const executeCommand = useCallback(async (command: string) => { + if (!webContainerInstance || !term.current) return; - const executeCommand = useCallback(async (command: string) => { - if (!webContainerInstance || !term.current) return; + // Add to history + if (command.trim() && commandHistory.current[commandHistory.current.length - 1] !== command) { + commandHistory.current.push(command); + } + historyIndex.current = -1; - // Add to history - if (command.trim() && commandHistory.current[commandHistory.current.length - 1] !== command) { - commandHistory.current.push(command); + try { + // Handle built-in commands + if (command.trim() === "clear") { + term.current.clear(); + writePrompt(); + return; } - historyIndex.current = -1; - - try { - // Handle built-in commands - if (command.trim() === "clear") { - term.current.clear(); - writePrompt(); - return; - } - if (command.trim() === "history") { - commandHistory.current.forEach((cmd, index) => { - term.current!.writeln(` ${index + 1} ${cmd}`); - }); - writePrompt(); - return; - } + if (command.trim() === "history") { + commandHistory.current.forEach((cmd, index) => { + term.current!.writeln(` ${index + 1} ${cmd}`); + }); + writePrompt(); + return; + } - if (command.trim() === "") { - writePrompt(); - return; - } + if (command.trim() === "") { + writePrompt(); + return; + } - // Parse command - const parts = command.trim().split(' '); - const cmd = parts[0]; - const args = parts.slice(1); - - // Execute in WebContainer - term.current.writeln(""); - const process = await webContainerInstance.spawn(cmd, args, { - terminal: { - cols: term.current.cols, - rows: term.current.rows, - }, - }); + // Parse command + const parts = command.trim().split(' '); + const cmd = parts[0]; + const args = parts.slice(1); + + // Execute in WebContainer + term.current.writeln(""); + const process = await webContainerInstance.spawn(cmd, args, { + terminal: { + cols: term.current.cols, + rows: term.current.rows, + }, + }); - currentProcess.current = process; + currentProcess.current = process; - // Handle process output - process.output.pipeTo(new WritableStream({ - write(data) { - if (term.current) { - term.current.write(data); - } - }, - })); + // Handle process output + process.output.pipeTo(new WritableStream({ + write(data) { + if (term.current) { + term.current.write(data); + } + }, + })); + + // Wait for process to complete + const _exitCode = await process.exit; + currentProcess.current = null; - // Wait for process to complete - const _exitCode = await process.exit; - currentProcess.current = null; + // Show new prompt + writePrompt(); - // Show new prompt + } catch (_error) { + if (term.current) { + term.current.writeln(`\r\nCommand not found: ${command}`); writePrompt(); + } + currentProcess.current = null; + } + }, [webContainerInstance, writePrompt]); + + const handleTerminalInput = useCallback((data: string) => { + if (!term.current) return; + + // Handle special characters + switch (data) { + case '\r': // Enter + executeCommand(currentLine.current); + break; + + case '\u007F': // Backspace + if (cursorPosition.current > 0) { + currentLine.current = + currentLine.current.slice(0, cursorPosition.current - 1) + + currentLine.current.slice(cursorPosition.current); + cursorPosition.current--; + + // Update terminal display + term.current.write('\b \b'); + } + break; - } catch (_error) { - if (term.current) { - term.current.writeln(`\r\nCommand not found: ${command}`); - writePrompt(); + case '\u0003': // Ctrl+C + if (currentProcess.current) { + currentProcess.current.kill(); + currentProcess.current = null; } - currentProcess.current = null; - } - }, [webContainerInstance, writePrompt]); - - const handleTerminalInput = useCallback((data: string) => { - if (!term.current) return; - - // Handle special characters - switch (data) { - case '\r': // Enter - executeCommand(currentLine.current); - break; - - case '\u007F': // Backspace - if (cursorPosition.current > 0) { - currentLine.current = - currentLine.current.slice(0, cursorPosition.current - 1) + - currentLine.current.slice(cursorPosition.current); - cursorPosition.current--; - - // Update terminal display - term.current.write('\b \b'); + term.current.writeln("^C"); + writePrompt(); + break; + + case '\u001b[A': // Up arrow + if (commandHistory.current.length > 0) { + if (historyIndex.current === -1) { + historyIndex.current = commandHistory.current.length - 1; + } else if (historyIndex.current > 0) { + historyIndex.current--; } - break; - case '\u0003': // Ctrl+C - if (currentProcess.current) { - currentProcess.current.kill(); - currentProcess.current = null; - } - term.current.writeln("^C"); - writePrompt(); - break; - - case '\u001b[A': // Up arrow - if (commandHistory.current.length > 0) { - if (historyIndex.current === -1) { - historyIndex.current = commandHistory.current.length - 1; - } else if (historyIndex.current > 0) { - historyIndex.current--; - } + // Clear current line and write history command + const historyCommand = commandHistory.current[historyIndex.current]; + term.current.write('\r$ ' + ' '.repeat(currentLine.current.length) + '\r$ '); + term.current.write(historyCommand); + currentLine.current = historyCommand; + cursorPosition.current = historyCommand.length; + } + break; - // Clear current line and write history command + case '\u001b[B': // Down arrow + if (historyIndex.current !== -1) { + if (historyIndex.current < commandHistory.current.length - 1) { + historyIndex.current++; const historyCommand = commandHistory.current[historyIndex.current]; term.current.write('\r$ ' + ' '.repeat(currentLine.current.length) + '\r$ '); term.current.write(historyCommand); currentLine.current = historyCommand; cursorPosition.current = historyCommand.length; + } else { + historyIndex.current = -1; + term.current.write('\r$ ' + ' '.repeat(currentLine.current.length) + '\r$ '); + currentLine.current = ""; + cursorPosition.current = 0; } - break; - - case '\u001b[B': // Down arrow - if (historyIndex.current !== -1) { - if (historyIndex.current < commandHistory.current.length - 1) { - historyIndex.current++; - const historyCommand = commandHistory.current[historyIndex.current]; - term.current.write('\r$ ' + ' '.repeat(currentLine.current.length) + '\r$ '); - term.current.write(historyCommand); - currentLine.current = historyCommand; - cursorPosition.current = historyCommand.length; - } else { - historyIndex.current = -1; - term.current.write('\r$ ' + ' '.repeat(currentLine.current.length) + '\r$ '); - currentLine.current = ""; - cursorPosition.current = 0; - } - } - break; - - default: - // Regular character input - if (data >= ' ' || data === '\t') { - currentLine.current = - currentLine.current.slice(0, cursorPosition.current) + - data + - currentLine.current.slice(cursorPosition.current); - cursorPosition.current++; - term.current.write(data); - } - break; + } + break; + + default: + // Regular character input + if (data >= ' ' || data === '\t') { + currentLine.current = + currentLine.current.slice(0, cursorPosition.current) + + data + + currentLine.current.slice(cursorPosition.current); + cursorPosition.current++; + term.current.write(data); + } + break; + } + }, [executeCommand, writePrompt]); + + const initializeTerminal = useCallback(() => { + if (!terminalRef.current || term.current) return; + + const terminal = new Terminal({ + cursorBlink: true, + fontFamily: '"Fira Code", "JetBrains Mono", "Consolas", monospace', + fontSize: 14, + lineHeight: 1.2, + letterSpacing: 0, + theme: terminalThemes[theme], + allowTransparency: false, + convertEol: true, + scrollback: 1000, + tabStopWidth: 4, + }); + + // Add addons + const fitAddonInstance = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + const searchAddonInstance = new SearchAddon(); + + terminal.loadAddon(fitAddonInstance); + terminal.loadAddon(webLinksAddon); + terminal.loadAddon(searchAddonInstance); + + terminal.open(terminalRef.current); + + fitAddon.current = fitAddonInstance; + searchAddon.current = searchAddonInstance; + term.current = terminal; + + // Handle terminal input + terminal.onData(handleTerminalInput); + + // Initial fit + setTimeout(() => { + if (fitAddonInstance && term.current) { + try { + fitAddonInstance.fit(); + } catch (e) { + console.warn("Failed to fit terminal:", e); + } } - }, [executeCommand, writePrompt]); - - const initializeTerminal = useCallback(() => { - if (!terminalRef.current || term.current) return; - - const terminal = new Terminal({ - cursorBlink: true, - fontFamily: '"Fira Code", "JetBrains Mono", "Consolas", monospace', - fontSize: 14, - lineHeight: 1.2, - letterSpacing: 0, - theme: terminalThemes[theme], - allowTransparency: false, - convertEol: true, - scrollback: 1000, - tabStopWidth: 4, - }); - - // Add addons - const fitAddonInstance = new FitAddon(); - const webLinksAddon = new WebLinksAddon(); - const searchAddonInstance = new SearchAddon(); + }, 100); - terminal.loadAddon(fitAddonInstance); - terminal.loadAddon(webLinksAddon); - terminal.loadAddon(searchAddonInstance); + // Welcome message + terminal.writeln("🚀 WebContainer Terminal"); + terminal.writeln("Type 'help' for available commands"); + writePrompt(); - terminal.open(terminalRef.current); + return terminal; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [theme, handleTerminalInput, writePrompt]); - fitAddon.current = fitAddonInstance; - searchAddon.current = searchAddonInstance; - term.current = terminal; + const connectToWebContainer = useCallback(async () => { + if (!webContainerInstance || !term.current) return; - // Handle terminal input - terminal.onData(handleTerminalInput); - - // Initial fit - setTimeout(() => { - if (fitAddonInstance && term.current) { - try { - fitAddonInstance.fit(); - } catch (e) { - console.warn("Failed to fit terminal:", e); - } - } - }, 100); - - // Welcome message - terminal.writeln("🚀 WebContainer Terminal"); - terminal.writeln("Type 'help' for available commands"); + try { + setIsConnected(true); + term.current.writeln("✅ Connected to WebContainer"); + term.current.writeln("Ready to execute commands"); writePrompt(); - - return terminal; -// eslint-disable-next-line react-hooks/exhaustive-deps - }, [theme, handleTerminalInput, writePrompt]); - - const connectToWebContainer = useCallback(async () => { - if (!webContainerInstance || !term.current) return; - - try { - setIsConnected(true); - term.current.writeln("✅ Connected to WebContainer"); - term.current.writeln("Ready to execute commands"); - writePrompt(); - } catch (error) { - setIsConnected(false); - term.current.writeln("❌ Failed to connect to WebContainer"); - console.error("WebContainer connection error:", error); + } catch (error) { + setIsConnected(false); + term.current.writeln("❌ Failed to connect to WebContainer"); + console.error("WebContainer connection error:", error); + } + }, [webContainerInstance, writePrompt]); + + const clearTerminal = useCallback(() => { + if (term.current) { + term.current.clear(); + term.current.writeln("🚀 WebContainer Terminal"); + writePrompt(); + } + }, [writePrompt]); + + const copyTerminalContent = useCallback(async () => { + if (term.current) { + const content = term.current.getSelection(); + if (content) { + try { + await navigator.clipboard.writeText(content); + } catch (error) { + console.error("Failed to copy to clipboard:", error); + } } - }, [webContainerInstance, writePrompt]); - - const clearTerminal = useCallback(() => { - if (term.current) { - term.current.clear(); - term.current.writeln("🚀 WebContainer Terminal"); - writePrompt(); + } + }, []); + + const downloadTerminalLog = useCallback(() => { + if (term.current) { + const buffer = term.current.buffer.active; + let content = ""; + + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) { + content += line.translateToString(true) + "\n"; + } } - }, [writePrompt]); - const copyTerminalContent = useCallback(async () => { - if (term.current) { - const content = term.current.getSelection(); - if (content) { - try { - await navigator.clipboard.writeText(content); - } catch (error) { - console.error("Failed to copy to clipboard:", error); + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `terminal-log-${new Date().toISOString().slice(0, 19)}.txt`; + a.click(); + URL.revokeObjectURL(url); + } + }, []); + + const searchInTerminal = useCallback((term: string) => { + if (searchAddon.current && term) { + searchAddon.current.findNext(term); + } + }, []); + + useEffect(() => { + initializeTerminal(); + + // Handle resize + const resizeObserver = new ResizeObserver(() => { + if (fitAddon.current && term.current) { + setTimeout(() => { + if (fitAddon.current && term.current) { + try { + fitAddon.current.fit(); + } catch (e) { + console.warn("Failed to fit terminal:", e); + } } - } + }, 100); } - }, []); + }); - const downloadTerminalLog = useCallback(() => { - if (term.current) { - const buffer = term.current.buffer.active; - let content = ""; - - for (let i = 0; i < buffer.length; i++) { - const line = buffer.getLine(i); - if (line) { - content += line.translateToString(true) + "\n"; - } - } + if (terminalRef.current) { + resizeObserver.observe(terminalRef.current); + } - const blob = new Blob([content], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `terminal-log-${new Date().toISOString().slice(0, 19)}.txt`; - a.click(); - URL.revokeObjectURL(url); + return () => { + resizeObserver.disconnect(); + if (currentProcess.current) { + currentProcess.current.kill(); } - }, []); - - const searchInTerminal = useCallback((term: string) => { - if (searchAddon.current && term) { - searchAddon.current.findNext(term); + if (shellProcess.current) { + shellProcess.current.kill(); } - }, []); - - useEffect(() => { - initializeTerminal(); - - // Handle resize - const resizeObserver = new ResizeObserver(() => { - if (fitAddon.current && term.current) { - setTimeout(() => { - if (fitAddon.current && term.current) { - try { - fitAddon.current.fit(); - } catch (e) { - console.warn("Failed to fit terminal:", e); - } - } - }, 100); - } - }); - - if (terminalRef.current) { - resizeObserver.observe(terminalRef.current); + if (term.current) { + term.current.dispose(); + term.current = null; } - - return () => { - resizeObserver.disconnect(); - if (currentProcess.current) { - currentProcess.current.kill(); - } - if (shellProcess.current) { -// eslint-disable-next-line react-hooks/exhaustive-deps - shellProcess.current.kill(); - } - if (term.current) { - term.current.dispose(); - term.current = null; - } - }; - }, [initializeTerminal]); - - useEffect(() => { - if (webContainerInstance && term.current && !isConnected) { - connectToWebContainer(); + }; + }, [initializeTerminal]); + + useEffect(() => { + if (webContainerInstance && term.current && !isConnected) { + connectToWebContainer(); + } + }, [webContainerInstance, connectToWebContainer, isConnected]); + + // ✅ NEW: Keyboard shortcut for clearing terminal (Ctrl+L / Cmd+L) + useEffect(() => { + const handleKeyboardShortcut = (event: KeyboardEvent) => { + // Check for Ctrl+L (Windows/Linux) or Cmd+L (Mac) + if ((event.ctrlKey || event.metaKey) && event.key === 'l') { + event.preventDefault(); // Prevent browser default behavior (focus address bar) + clearTerminal(); } - }, [webContainerInstance, connectToWebContainer, isConnected]); - - return ( -
- {/* Terminal Header */} -
-
-
-
-
-
-
- WebContainer Terminal - {isConnected && ( -
-
- Connected -
- )} -
+ }; -
- {showSearch && ( -
- { - setSearchTerm(e.target.value); - searchInTerminal(e.target.value); - }} - className="h-6 w-32 text-xs" - /> -
- )} - - - - - - - - + window.addEventListener('keydown', handleKeyboardShortcut); + return () => window.removeEventListener('keydown', handleKeyboardShortcut); + }, [clearTerminal]); + + return ( +
+ {/* Terminal Header */} +
+
+
+
+
+
+ WebContainer Terminal + {isConnected && ( +
+
+ Connected +
+ )}
- {/* Terminal Content */} -
-
+
+ {showSearch && ( +
+ { + setSearchTerm(e.target.value); + searchInTerminal(e.target.value); + }} + className="h-6 w-32 text-xs" + /> +
+ )} + + + + + + + +
- ); - }); + + {/* Terminal Content */} +
+
+
+
+ ); +}); TerminalInner.displayName = "TerminalComponent";