mirror of
https://github.com/AeThex-Corporation/AeThex-OS.git
synced 2026-04-19 06:47:21 +00:00
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { Link } from "wouter";
|
|
import { useLabTerminal } from "@/hooks/use-lab-terminal";
|
|
import { ArrowLeft, Terminal as TerminalIcon, Copy, Trash2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
interface TerminalLine {
|
|
type: 'input' | 'output' | 'error' | 'system';
|
|
content: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
export default function Terminal() {
|
|
const [lines, setLines] = useState<TerminalLine[]>([
|
|
{
|
|
type: 'system',
|
|
content: '▸ AeThex Terminal v4.2 - Type "help" for commands',
|
|
timestamp: Date.now(),
|
|
},
|
|
]);
|
|
|
|
const [cliCommand, setCliCommand] = useState<string>("build");
|
|
const [cliStatus, setCliStatus] = useState<"idle" | "running" | "done" | "error">("idle");
|
|
const [cliLabel, setCliLabel] = useState<string>("");
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
const currentRunId = useRef<string | null>(null);
|
|
const { toast } = useToast();
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
eventSourceRef.current?.close();
|
|
};
|
|
}, []);
|
|
|
|
const [input, setInput] = useState("");
|
|
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
const terminalEndRef = useRef<HTMLDivElement>(null);
|
|
const { executionHistory, executeCode, isExecuting } = useLabTerminal();
|
|
|
|
const scrollToBottom = () => {
|
|
terminalEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
};
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [lines]);
|
|
|
|
// Show last execution in terminal
|
|
useEffect(() => {
|
|
if (executionHistory.length > 0) {
|
|
const latest = executionHistory[0];
|
|
setLines((prev) => [
|
|
...prev,
|
|
{
|
|
type: 'input',
|
|
content: `> run "${latest.code.split('\n')[0]}..."`,
|
|
timestamp: latest.timestamp,
|
|
},
|
|
{
|
|
type: latest.status === 'error' ? 'error' : 'output',
|
|
content: latest.output,
|
|
timestamp: latest.timestamp,
|
|
},
|
|
]);
|
|
}
|
|
}, [executionHistory]);
|
|
|
|
const processCommand = (cmd: string) => {
|
|
const trimmed = cmd.trim();
|
|
if (!trimmed) return;
|
|
|
|
setLines((prev) => [
|
|
...prev,
|
|
{ type: 'input', content: `> ${trimmed}`, timestamp: Date.now() },
|
|
]);
|
|
|
|
const parts = trimmed.split(' ');
|
|
const command = parts[0].toLowerCase();
|
|
|
|
switch (command) {
|
|
case 'help':
|
|
setLines((prev) => [
|
|
...prev,
|
|
{
|
|
type: 'system',
|
|
content: `Available commands:
|
|
help - Show this help message
|
|
clear - Clear terminal
|
|
execute <code> - Execute JavaScript code
|
|
run <file> - Run code from Lab
|
|
history - Show command history
|
|
exit - Exit terminal`,
|
|
timestamp: Date.now(),
|
|
},
|
|
]);
|
|
break;
|
|
|
|
case 'clear':
|
|
setLines([]);
|
|
break;
|
|
|
|
case 'history':
|
|
setLines((prev) => [
|
|
...prev,
|
|
{
|
|
type: 'output',
|
|
content: commandHistory.join('\n') || '(empty)',
|
|
timestamp: Date.now(),
|
|
},
|
|
]);
|
|
break;
|
|
|
|
case 'execute':
|
|
const code = parts.slice(1).join(' ');
|
|
if (code) {
|
|
executeCode(code, 'javascript');
|
|
} else {
|
|
setLines((prev) => [
|
|
...prev,
|
|
{
|
|
type: 'error',
|
|
content: 'Usage: execute <code>',
|
|
timestamp: Date.now(),
|
|
},
|
|
]);
|
|
}
|
|
break;
|
|
|
|
case 'run':
|
|
setLines((prev) => [
|
|
...prev,
|
|
{
|
|
type: 'output',
|
|
content: '▸ Open Lab to run files',
|
|
timestamp: Date.now(),
|
|
},
|
|
]);
|
|
break;
|
|
|
|
case 'exit':
|
|
setLines((prev) => [
|
|
...prev,
|
|
{
|
|
type: 'system',
|
|
content: '▸ Type "help" to see available commands',
|
|
timestamp: Date.now(),
|
|
},
|
|
]);
|
|
break;
|
|
|
|
default:
|
|
setLines((prev) => [
|
|
...prev,
|
|
{
|
|
type: 'error',
|
|
content: `Command not found: ${command}. Type "help" for available commands.`,
|
|
timestamp: Date.now(),
|
|
},
|
|
]);
|
|
}
|
|
|
|
setCommandHistory((prev) => [...prev, trimmed]);
|
|
setInput("");
|
|
setHistoryIndex(-1);
|
|
};
|
|
|
|
const appendCliLine = (type: TerminalLine['type'], content: string) => {
|
|
setLines((prev) => [...prev, { type, content, timestamp: Date.now() }]);
|
|
};
|
|
|
|
const stopCli = async () => {
|
|
if (!currentRunId.current) return;
|
|
try {
|
|
await fetch(`/api/admin/cli/stop/${currentRunId.current}`, { method: "POST", credentials: "include" });
|
|
} catch (_) {
|
|
// ignore
|
|
}
|
|
eventSourceRef.current?.close();
|
|
eventSourceRef.current = null;
|
|
currentRunId.current = null;
|
|
setCliStatus("idle");
|
|
};
|
|
|
|
const startCli = async () => {
|
|
if (cliStatus === "running") return;
|
|
setCliStatus("running");
|
|
appendCliLine('system', `▸ Running ${cliCommand}...`);
|
|
toast({ title: "CLI", description: `Started ${cliCommand}`, variant: "default" });
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/cli/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ command: cliCommand })
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
appendCliLine('error', `Start failed: ${text || res.status}`);
|
|
setCliStatus("error");
|
|
toast({ title: "CLI Error", description: `Failed to start ${cliCommand}`, variant: "destructive" });
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
currentRunId.current = data.id;
|
|
setCliLabel(data.label);
|
|
|
|
const es = new EventSource(`/api/admin/cli/stream/${data.id}`, { withCredentials: true } as any);
|
|
eventSourceRef.current = es;
|
|
|
|
es.addEventListener('message', (evt) => {
|
|
appendCliLine('output', evt.data);
|
|
});
|
|
|
|
es.addEventListener('error', (evt) => {
|
|
appendCliLine('error', 'Stream error');
|
|
setCliStatus("error");
|
|
toast({ title: "CLI Error", description: `Stream error for ${cliCommand}`, variant: "destructive" });
|
|
es.close();
|
|
});
|
|
|
|
es.addEventListener('done', (evt) => {
|
|
const status = evt.data === 'success' ? 'done' : 'error';
|
|
setCliStatus(status as any);
|
|
appendCliLine(status === 'done' ? 'system' : 'error', `▸ ${cliLabel || cliCommand} ${status}`);
|
|
toast({ title: status === 'done' ? "CLI Success" : "CLI Failed", description: `${cliLabel || cliCommand} ${status}` });
|
|
es.close();
|
|
currentRunId.current = null;
|
|
});
|
|
|
|
} catch (err) {
|
|
appendCliLine('error', 'Failed to start CLI command');
|
|
setCliStatus("error");
|
|
toast({ title: "CLI Error", description: `Failed to start ${cliCommand}`, variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
processCommand(input);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
const newIndex = historyIndex + 1;
|
|
if (newIndex < commandHistory.length) {
|
|
setHistoryIndex(newIndex);
|
|
setInput(commandHistory[commandHistory.length - 1 - newIndex]);
|
|
}
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (historyIndex > 0) {
|
|
const newIndex = historyIndex - 1;
|
|
setHistoryIndex(newIndex);
|
|
setInput(commandHistory[commandHistory.length - 1 - newIndex]);
|
|
} else if (historyIndex === 0) {
|
|
setHistoryIndex(-1);
|
|
setInput("");
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="h-screen w-full bg-[#0a0a0c] text-[#a9b7c6] flex flex-col font-mono">
|
|
{/* Header */}
|
|
<div className="h-12 border-b border-[#2b2d30] bg-[#1e1f22] flex items-center px-4 justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link href="/">
|
|
<button className="text-[#a9b7c6] hover:text-white transition-colors">
|
|
<ArrowLeft className="w-4 h-4" />
|
|
</button>
|
|
</Link>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<TerminalIcon className="w-4 h-4 text-cyan-400" />
|
|
<span className="font-bold text-white">AeThex Terminal v4.2</span>
|
|
<span className="text-xs text-green-500 bg-green-500/10 px-2 py-0.5 rounded ml-2">
|
|
[ ONLINE ]
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => navigator.clipboard.writeText(lines.map((l) => l.content).join('\n'))}
|
|
className="text-[#a9b7c6] hover:bg-[#2b2d30]"
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setLines([])}
|
|
className="text-[#a9b7c6] hover:bg-[#2b2d30]"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Terminal Output */}
|
|
|
|
{/* Admin CLI runner */}
|
|
<div className="border-b border-[#2b2d30] bg-[#121216] px-4 py-3 flex flex-wrap items-center gap-3 text-sm">
|
|
<div className="text-white/80 font-semibold">AeThex CLI</div>
|
|
<Select value={cliCommand} onValueChange={setCliCommand}>
|
|
<SelectTrigger className="w-48 bg-[#0f0f12] border-white/10 text-white/80">
|
|
<SelectValue placeholder="Select command" />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#0f0f12] text-white/80 border-white/10">
|
|
<SelectItem value="build">build (npm run build)</SelectItem>
|
|
<SelectItem value="migrate-status">migrate-status (drizzle status)</SelectItem>
|
|
<SelectItem value="migrate">migrate (drizzle migrate:push)</SelectItem>
|
|
<SelectItem value="seed">seed (ts-node script/seed.ts)</SelectItem>
|
|
<SelectItem value="test">test (./test-implementation.sh)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
size="sm"
|
|
onClick={startCli}
|
|
disabled={cliStatus === "running"}
|
|
className="bg-cyan-600 hover:bg-cyan-500"
|
|
>
|
|
{cliStatus === "running" ? "Running..." : "Run"}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={stopCli}
|
|
disabled={!currentRunId.current}
|
|
className="border-white/10 text-white/70 hover:text-white"
|
|
>
|
|
Stop
|
|
</Button>
|
|
<div className="text-xs text-white/50">
|
|
Status: {cliStatus.toUpperCase()}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-1 bg-[#0a0a0c]">
|
|
{lines.map((line, i) => (
|
|
<motion.div
|
|
key={i}
|
|
initial={{ opacity: 0, y: -5 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className={`text-xs leading-relaxed ${
|
|
line.type === 'error'
|
|
? 'text-red-500'
|
|
: line.type === 'input'
|
|
? 'text-cyan-400'
|
|
: line.type === 'system'
|
|
? 'text-yellow-600'
|
|
: 'text-[#a9b7c6]'
|
|
}`}
|
|
>
|
|
{line.content.split('\n').map((part, idx) => (
|
|
<div key={idx}>{part}</div>
|
|
))}
|
|
</motion.div>
|
|
))}
|
|
<div ref={terminalEndRef} />
|
|
</div>
|
|
|
|
{/* Input Bar */}
|
|
<div className="border-t border-[#2b2d30] bg-[#1e1f22] p-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-cyan-400">▸</span>
|
|
<Input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Type command... (help for list)"
|
|
className="flex-1 bg-[#0a0a0c] border-0 text-[#a9b7c6] placeholder-[#555] focus:ring-0 focus:outline-none font-mono text-xs"
|
|
disabled={isExecuting}
|
|
autoFocus
|
|
/>
|
|
{isExecuting && (
|
|
<span className="text-yellow-500 text-xs animate-pulse">executing...</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|