builder / components /console /index.tsx
Leon4gr45's picture
Upload folder using huggingface_hub
eeb9404 verified
Raw
History Blame Contribute Delete
25.8 kB
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
import { Terminal as TerminalIcon, Play, Square, Trash2 } from 'lucide-react';
import { PanelHeader } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
import { scriptRunner } from '@/lib/scripting/script-runner';
import { vfsShell } from '@/lib/vfs/cli-shell';
import { vfs } from '@/lib/vfs';
import type { ScriptRuntime, ScriptWorkerResponse } from '@/lib/scripting/types';
import type { ProjectRuntime } from '@/lib/vfs/types';
import { getRuntimeConfig } from '@/lib/runtimes/registry';
import { logger } from '@/lib/utils';
interface ConsolePanelProps {
projectId: string;
runtime: ProjectRuntime;
onClose?: () => void;
bufferedMessages?: { level: string; text: string }[];
onBufferConsumed?: () => void;
}
/** Tokenize a command string into argv, respecting quotes. */
function tokenize(input: string): string[] {
const tokens: string[] = [];
let current = '';
let inSingle = false;
let inDouble = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === '\\' && !inSingle) {
escaped = true;
continue;
}
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
continue;
}
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
continue;
}
if (ch === ' ' && !inSingle && !inDouble) {
if (current.length > 0) {
tokens.push(current);
current = '';
}
continue;
}
current += ch;
}
if (current.length > 0) {
tokens.push(current);
}
return tokens;
}
/** Get the script runtime from a file extension. */
function runtimeFromExtension(path: string): ScriptRuntime | null {
if (path.endsWith('.py')) return 'python';
if (path.endsWith('.lua')) return 'lua';
return null;
}
/** Check if a file is executable in the console. */
function isExecutableFile(path: string): boolean {
return runtimeFromExtension(path) !== null;
}
const MAX_HISTORY = 50;
/** Write a colored log line to the terminal based on level. */
function writeLogLine(term: import('@xterm/xterm').Terminal, level: string, text: string) {
switch (level) {
case 'warn':
term.writeln(`\x1b[33m[warn] ${text}\x1b[0m`);
break;
case 'error':
term.writeln(`\x1b[31m[error] ${text}\x1b[0m`);
break;
case 'info':
term.writeln(`\x1b[36m[info] ${text}\x1b[0m`);
break;
case 'debug':
term.writeln(`\x1b[2m[debug] ${text}\x1b[0m`);
break;
default:
term.writeln(text);
break;
}
}
export function ConsolePanel({ projectId, runtime, onClose, bufferedMessages, onBufferConsumed }: ConsolePanelProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<import('@xterm/xterm').Terminal | null>(null);
const fitAddonRef = useRef<import('@xterm/addon-fit').FitAddon | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [isReady, setIsReady] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Entry point / file dropdown
const isTerminalRuntime = getRuntimeConfig(runtime).previewMode === 'terminal';
const defaultEntryPoint = runtime === 'python' ? '/main.py' : runtime === 'lua' ? '/main.lua' : '';
const [selectedEntryPoint, setSelectedEntryPoint] = useState(defaultEntryPoint);
const [executableFiles, setExecutableFiles] = useState<string[]>([]);
// Input line buffer
const inputBufferRef = useRef('');
const cursorPosRef = useRef(0);
const isRunningRef = useRef(false);
// Command history
const historyRef = useRef<string[]>([]);
const historyIndexRef = useRef(-1);
const savedInputRef = useRef('');
// Cooldown: ignore filesChanged for 1s after script completion
// (output-file VFS writes arrive after 'complete', re-triggering auto-run)
const lastRunCompletedRef = useRef(0);
// Track AI generation state to suppress auto-run during generation
const generatingRef = useRef(false);
const lastBuildSuccessRef = useRef(false);
// Load executable files from VFS
const loadExecutableFiles = useCallback(async () => {
try {
await vfs.init();
const files = await vfs.listFiles(projectId);
const execs = files
.filter((f: { path: string }) => isExecutableFile(f.path))
.map((f: { path: string }) => f.path)
.sort();
setExecutableFiles(execs);
} catch (_err) {
// ignore
}
}, [projectId]);
// Initialize xterm
useEffect(() => {
let cancelled = false;
let terminal: import('@xterm/xterm').Terminal | null = null;
let fitAddon: import('@xterm/addon-fit').FitAddon | null = null;
const init = async () => {
if (!terminalRef.current) return;
const { Terminal } = await import('@xterm/xterm');
const { FitAddon } = await import('@xterm/addon-fit');
try {
// @ts-expect-error — CSS module import handled by Next.js bundler
await import('@xterm/xterm/css/xterm.css');
} catch (_cssErr) { /* non-fatal */ }
if (cancelled || !terminalRef.current) return;
terminalRef.current.innerHTML = '';
terminal = new Terminal({
theme: {
background: '#1a1a2e',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#1a1a2e',
selectionBackground: 'rgba(255, 255, 255, 0.2)',
black: '#1a1a2e',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#748ffc',
magenta: '#cc5de8',
cyan: '#66d9e8',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#91a7ff',
brightMagenta: '#e599f7',
brightCyan: '#99e9f2',
brightWhite: '#f8f9fa',
},
fontSize: 13,
fontFamily: '"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Monaco, monospace',
lineHeight: 1.4,
cursorBlink: true,
disableStdin: false,
convertEol: true,
scrollback: 5000,
});
fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalRef.current);
requestAnimationFrame(() => {
if (fitAddon && !cancelled) {
try { fitAddon.fit(); } catch (_e) { /* not attached yet */ }
}
});
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
setIsReady(true);
// Welcome message
terminal.writeln('\x1b[2mConsole ready \u2014 type \'help\' for commands\x1b[0m');
writePrompt(terminal);
// Replay buffered messages captured while console was hidden
if (bufferedMessages && bufferedMessages.length > 0) {
for (const msg of bufferedMessages) {
writeLogLine(terminal, msg.level, msg.text);
}
onBufferConsumed?.();
writePrompt(terminal);
}
// Wire up input handler
terminal.onData((data) => {
handleTerminalInput(data);
});
};
init().catch(err => logger.error('Failed to initialize console:', err));
return () => {
cancelled = true;
terminal?.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load files on mount and when files change
useEffect(() => {
loadExecutableFiles();
const handler = () => loadExecutableFiles();
window.addEventListener('filesChanged', handler);
return () => window.removeEventListener('filesChanged', handler);
}, [loadExecutableFiles]);
// Resize handler
useEffect(() => {
const handleResize = () => {
if (fitAddonRef.current) {
try { fitAddonRef.current.fit(); } catch (_e) { /* ignore */ }
}
};
window.addEventListener('resize', handleResize);
const observer = new ResizeObserver(handleResize);
if (terminalRef.current?.parentElement) {
observer.observe(terminalRef.current.parentElement);
}
return () => {
window.removeEventListener('resize', handleResize);
observer.disconnect();
};
}, [isReady]);
// Track AI generation state to suppress auto-run
useEffect(() => {
const handler = (e: Event) => {
generatingRef.current = (e as CustomEvent).detail?.generating ?? false;
};
window.addEventListener('generationStateChanged', handler);
return () => window.removeEventListener('generationStateChanged', handler);
}, []);
// Show compilation output for non-terminal runtimes (React, Preact, Svelte, Vue, static)
useEffect(() => {
if (isTerminalRuntime) return;
const handleCompilation = (e: Event) => {
// Suppress during AI generation
if (generatingRef.current) return;
const detail = (e as CustomEvent).detail;
const term = xtermRef.current;
if (!term) return;
if (detail.success) {
// Skip consecutive success messages (multiple compiles on load)
if (!lastBuildSuccessRef.current) {
term.writeln('\x1b[32m\u2713 Build successful\x1b[0m');
}
lastBuildSuccessRef.current = true;
} else {
lastBuildSuccessRef.current = false;
for (const err of detail.errors) {
term.writeln(`\x1b[31m\u2717 ${err.file}: ${err.error}\x1b[0m`);
}
}
};
window.addEventListener('compilationComplete', handleCompilation);
return () => window.removeEventListener('compilationComplete', handleCompilation);
}, [isTerminalRuntime]);
// Capture console.log/warn/error/info/debug from preview iframe
useEffect(() => {
if (isTerminalRuntime) return;
const handler = (e: Event) => {
if (generatingRef.current) return;
const term = xtermRef.current;
if (!term) return;
const { level, args } = (e as CustomEvent<{ level: string; args: string[] }>).detail;
writeLogLine(term, level, args.join(' '));
};
window.addEventListener('previewConsole', handler);
return () => window.removeEventListener('previewConsole', handler);
}, [isTerminalRuntime]);
// Listen for "runInConsole" custom event from file explorer
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ path: string }>).detail;
if (detail?.path) {
setSelectedEntryPoint(detail.path);
runFile(detail.path);
}
};
window.addEventListener('runInConsole', handler);
return () => window.removeEventListener('runInConsole', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Subscribe to script runner output
useEffect(() => {
const unsubscribe = scriptRunner.onOutput((msg: ScriptWorkerResponse) => {
const term = xtermRef.current;
if (!term) return;
switch (msg.type) {
case 'stdout':
term.writeln(msg.data);
break;
case 'stderr':
term.writeln(`\x1b[31m${msg.data}\x1b[0m`);
break;
case 'status':
term.writeln(`\x1b[2m${msg.data}\x1b[0m`);
break;
case 'error':
term.writeln(`\x1b[31;1m${msg.data}\x1b[0m`);
break;
case 'complete':
isRunningRef.current = false;
lastRunCompletedRef.current = Date.now();
setIsRunning(false);
if (msg.exitCode === 0) {
term.writeln('\x1b[2m--- Script completed ---\x1b[0m');
} else {
term.writeln(`\x1b[31;2m--- Script exited with code ${msg.exitCode} ---\x1b[0m`);
}
writePrompt(term);
break;
case 'output-file':
handleOutputFile(msg.path, msg.content);
break;
}
});
return unsubscribe;
}, [projectId]);
// Handle output files from script execution
const handleOutputFile = useCallback(async (path: string, content: string) => {
try {
await vfs.init();
const files = await vfs.listFiles(projectId);
const existing = files.find((f: { path: string }) => f.path === path);
if (existing) {
await vfs.updateFile(projectId, path, content);
} else {
try { await vfs.createDirectory(projectId, '/output'); } catch (_e) { /* exists */ }
await vfs.createFile(projectId, path, content);
}
} catch (err) {
logger.error('Failed to write output file:', err);
}
}, [projectId]);
// Auto-run on file changes (terminal runtimes only)
useEffect(() => {
if (!isTerminalRuntime) return;
const handleFilesChanged = () => {
// Skip if AI is generating — wait for explicit python/lua command
if (generatingRef.current) return;
// Skip if a script is running or just finished (output-file writes trigger filesChanged)
if (isRunningRef.current) return;
if (Date.now() - lastRunCompletedRef.current < 1000) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
if (isRunningRef.current) return;
if (Date.now() - lastRunCompletedRef.current < 1000) return;
const ep = selectedEntryPoint || defaultEntryPoint;
if (ep) runFile(ep);
}, 150);
};
window.addEventListener('filesChanged', handleFilesChanged);
return () => {
window.removeEventListener('filesChanged', handleFilesChanged);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEntryPoint, defaultEntryPoint, isTerminalRuntime, projectId]);
// Auto-run entry point on initial ready (terminal runtimes only)
useEffect(() => {
if (!isReady || !isTerminalRuntime) return;
const ep = selectedEntryPoint || defaultEntryPoint;
if (ep) {
// Short delay to let the terminal render the welcome message first
const timer = setTimeout(() => runFile(ep), 100);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady]);
// Cleanup on unmount
useEffect(() => {
return () => {
scriptRunner.dispose();
};
}, []);
// --- Input handling ---
function writePrompt(term: import('@xterm/xterm').Terminal) {
term.write('\r\n\x1b[32m$\x1b[0m ');
}
function redrawLine() {
const term = xtermRef.current;
if (!term) return;
const buf = inputBufferRef.current;
const pos = cursorPosRef.current;
// Move to start of line, clear it, rewrite prompt + buffer
term.write('\r\x1b[K\x1b[32m$\x1b[0m ' + buf);
// Position cursor correctly
const charsAfterCursor = buf.length - pos;
if (charsAfterCursor > 0) {
term.write(`\x1b[${charsAfterCursor}D`);
}
}
function handleTerminalInput(data: string) {
const term = xtermRef.current;
if (!term) return;
// During script execution, only Ctrl+C is handled
if (isRunningRef.current) {
if (data === '\x03') {
stopScript();
}
return;
}
for (let i = 0; i < data.length; i++) {
const ch = data[i];
// Check for escape sequences (arrow keys etc.)
if (ch === '\x1b' && i + 2 < data.length && data[i + 1] === '[') {
const code = data[i + 2];
i += 2; // skip '[' and code
switch (code) {
case 'A': // Up arrow — history back
navigateHistory(-1);
break;
case 'B': // Down arrow — history forward
navigateHistory(1);
break;
case 'C': // Right arrow
if (cursorPosRef.current < inputBufferRef.current.length) {
cursorPosRef.current++;
term.write('\x1b[C');
}
break;
case 'D': // Left arrow
if (cursorPosRef.current > 0) {
cursorPosRef.current--;
term.write('\x1b[D');
}
break;
}
continue;
}
switch (ch) {
case '\r': // Enter
term.write('\r\n');
processCommand();
break;
case '\x7f': // Backspace
if (cursorPosRef.current > 0) {
const buf = inputBufferRef.current;
inputBufferRef.current = buf.slice(0, cursorPosRef.current - 1) + buf.slice(cursorPosRef.current);
cursorPosRef.current--;
redrawLine();
}
break;
case '\x03': // Ctrl+C
term.write('^C');
inputBufferRef.current = '';
cursorPosRef.current = 0;
historyIndexRef.current = -1;
writePrompt(term);
break;
case '\x0c': // Ctrl+L — clear
term.clear();
redrawLine();
break;
default:
// Only insert printable characters
if (ch >= ' ') {
const buf = inputBufferRef.current;
inputBufferRef.current = buf.slice(0, cursorPosRef.current) + ch + buf.slice(cursorPosRef.current);
cursorPosRef.current++;
redrawLine();
}
break;
}
}
}
function navigateHistory(direction: number) {
const history = historyRef.current;
if (history.length === 0) return;
const idx = historyIndexRef.current;
if (direction < 0) {
// Going back in history
if (idx === -1) {
// Save current input before navigating
savedInputRef.current = inputBufferRef.current;
historyIndexRef.current = history.length - 1;
} else if (idx > 0) {
historyIndexRef.current = idx - 1;
} else {
return; // Already at oldest
}
} else {
// Going forward in history
if (idx === -1) return; // Already at current input
if (idx < history.length - 1) {
historyIndexRef.current = idx + 1;
} else {
// Return to saved input
historyIndexRef.current = -1;
inputBufferRef.current = savedInputRef.current;
cursorPosRef.current = inputBufferRef.current.length;
redrawLine();
return;
}
}
inputBufferRef.current = history[historyIndexRef.current];
cursorPosRef.current = inputBufferRef.current.length;
redrawLine();
}
async function processCommand() {
const line = inputBufferRef.current.trim();
inputBufferRef.current = '';
cursorPosRef.current = 0;
historyIndexRef.current = -1;
if (!line) {
writePrompt(xtermRef.current!);
return;
}
// Push to history (avoid consecutive duplicates)
const history = historyRef.current;
if (history.length === 0 || history[history.length - 1] !== line) {
history.push(line);
if (history.length > MAX_HISTORY) history.shift();
}
const tokens = tokenize(line);
if (tokens.length === 0) {
writePrompt(xtermRef.current!);
return;
}
const cmd = tokens[0];
// Route commands
switch (cmd) {
case 'exec':
await handleExec(tokens.slice(1));
break;
case 'clear':
xtermRef.current?.clear();
writePrompt(xtermRef.current!);
break;
case 'help':
printHelp();
break;
default:
// Route to VFS shell
await handleBashCommand(tokens);
break;
}
}
async function handleExec(args: string[]) {
const term = xtermRef.current;
if (!term) return;
if (args.length === 0) {
term.writeln('\x1b[33mUsage: exec <file.py|file.lua>\x1b[0m');
writePrompt(term);
return;
}
const filePath = args[0].startsWith('/') ? args[0] : '/' + args[0];
const sr = runtimeFromExtension(filePath);
if (!sr) {
term.writeln(`\x1b[31mUnsupported file type: ${filePath}\x1b[0m`);
term.writeln('\x1b[2mSupported: .py, .lua\x1b[0m');
writePrompt(term);
return;
}
setSelectedEntryPoint(filePath);
runFile(filePath);
}
function runFile(filePath: string) {
const term = xtermRef.current;
if (!term) return;
const sr = runtimeFromExtension(filePath);
if (!sr) return;
// Abort any running execution
if (isRunningRef.current) {
scriptRunner.abort();
}
isRunningRef.current = true;
setIsRunning(true);
term.writeln(`\x1b[2m--- Running ${filePath} ---\x1b[0m`);
scriptRunner.execute(projectId, sr, filePath).catch(err => {
term.writeln(`\x1b[31mFailed to execute: ${err}\x1b[0m`);
isRunningRef.current = false;
setIsRunning(false);
writePrompt(term);
});
}
async function handleBashCommand(tokens: string[]) {
const term = xtermRef.current;
if (!term) return;
try {
const result = await vfsShell.execute(projectId, tokens);
if (result.success) {
if (result.stdout) {
// Write each line individually to handle EOL properly
const lines = result.stdout.split('\n');
for (const line of lines) {
term.writeln(line);
}
}
} else {
if (result.stderr) {
term.writeln(`\x1b[31m${result.stderr}\x1b[0m`);
}
}
} catch (err) {
term.writeln(`\x1b[31mError: ${err instanceof Error ? err.message : 'Unknown error'}\x1b[0m`);
}
writePrompt(term);
}
function printHelp() {
const term = xtermRef.current;
if (!term) return;
term.writeln('');
term.writeln('\x1b[1mAvailable commands:\x1b[0m');
term.writeln(' \x1b[36mexec <file>\x1b[0m Run a script (.py, .lua)');
term.writeln(' \x1b[36mclear\x1b[0m Clear the console');
term.writeln(' \x1b[36mhelp\x1b[0m Show this help');
term.writeln('');
term.writeln('\x1b[1mVFS commands:\x1b[0m');
term.writeln(' ls, cat, head, tail, grep, rg, find, mkdir, touch,');
term.writeln(' rm, mv, cp, echo, sed, wc, tree, curl');
term.writeln('');
term.writeln('\x1b[1mOperators:\x1b[0m | (pipe), > >> (redirect), && || (chain)');
term.writeln('');
writePrompt(term);
}
// --- Button handlers ---
const handleRun = useCallback(() => {
const ep = selectedEntryPoint || defaultEntryPoint;
if (ep) runFile(ep);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEntryPoint, defaultEntryPoint, projectId]);
const stopScript = useCallback(() => {
scriptRunner.abort();
isRunningRef.current = false;
setIsRunning(false);
const term = xtermRef.current;
if (term) {
term.writeln('\x1b[33m--- Script aborted ---\x1b[0m');
writePrompt(term);
}
}, []);
const clearConsole = useCallback(() => {
xtermRef.current?.clear();
}, []);
// Determine if we should show run/stop buttons
const hasExecutableFiles = executableFiles.length > 0 || isTerminalRuntime;
return (
<div className="h-full flex flex-col">
<PanelHeader
icon={TerminalIcon}
title="Console"
color="var(--button-terminal-active, #22c55e)"
onClose={onClose}
panelKey="console"
actions={
<>
{hasExecutableFiles && (
isRunning ? (
<Button
size="sm"
variant="ghost"
className="h-6 rounded-full border border-border/60 bg-muted/50 px-2.5 gap-1.5 text-destructive hover:text-destructive md:h-5 md:px-2 md:border-0 md:bg-transparent md:rounded-md"
onClick={stopScript}
>
<Square className="h-2.5 w-2.5 md:h-3 md:w-3" />
<span className="text-xs">Stop</span>
</Button>
) : (
<Button
size="sm"
variant="ghost"
className="h-6 rounded-full border border-border/60 bg-muted/50 px-2.5 gap-1.5 md:h-5 md:px-2 md:border-0 md:bg-transparent md:rounded-md"
onClick={handleRun}
disabled={!selectedEntryPoint && !defaultEntryPoint}
>
<Play className="h-2.5 w-2.5 md:h-3 md:w-3" />
<span className="text-xs">Run</span>
</Button>
)
)}
<Button
size="sm"
variant="ghost"
className="h-6 rounded-full border border-border/60 bg-muted/50 px-2.5 gap-1.5 md:h-5 md:px-2 md:border-0 md:bg-transparent md:rounded-md"
onClick={clearConsole}
>
<Trash2 className="h-2.5 w-2.5 md:h-3 md:w-3" />
<span className="text-xs">Clear</span>
</Button>
</>
}
>
{/* File dropdown (when executable files exist) */}
{hasExecutableFiles && (
<select
value={selectedEntryPoint}
onChange={(e) => setSelectedEntryPoint(e.target.value)}
className="text-xs bg-background border border-border rounded px-1.5 py-0.5 max-w-[140px] ml-1"
>
{executableFiles.map(f => (
<option key={f} value={f}>{f}</option>
))}
{selectedEntryPoint && !executableFiles.includes(selectedEntryPoint) && (
<option value={selectedEntryPoint}>{selectedEntryPoint}</option>
)}
</select>
)}
</PanelHeader>
{/* Terminal area */}
<div className="flex-1 overflow-hidden p-1" style={{ backgroundColor: '#1a1a2e' }}>
<div ref={terminalRef} className="h-full w-full" />
</div>
</div>
);
}