All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m34s
233 lines
8.0 KiB
TypeScript
233 lines
8.0 KiB
TypeScript
import { useState, useEffect, useRef } from "preact/hooks";
|
|
import type { JSX } from "preact";
|
|
import type { Command } from "../lib/terminal/types";
|
|
import { buildFileSystem } from "../lib/terminal/fileSystem";
|
|
import { executeCommand, type CommandContext } from "../lib/terminal/commands";
|
|
import {
|
|
getCompletions,
|
|
formatOutput,
|
|
saveCommandToHistory,
|
|
loadCommandHistory,
|
|
} from "../lib/terminal/utils";
|
|
|
|
const Terminal = () => {
|
|
const [currentPath, setCurrentPath] = useState("/");
|
|
const [commandHistory, setCommandHistory] = useState<Command[]>([
|
|
{
|
|
input: "",
|
|
output:
|
|
'Welcome to Atridad\'s Shell!\nType "help" to see available commands.\n',
|
|
timestamp: new Date(),
|
|
path: "/",
|
|
},
|
|
]);
|
|
const [currentInput, setCurrentInput] = useState("");
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
const [fileSystem, setFileSystem] = useState<{ [key: string]: any }>({});
|
|
const [isTrainRunning, setIsTrainRunning] = useState(false);
|
|
const [trainPosition, setTrainPosition] = useState(100);
|
|
const [persistentHistory, setPersistentHistory] = useState<string[]>([]);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (terminalRef.current && !isTrainRunning) {
|
|
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
|
}
|
|
}, [commandHistory, isTrainRunning]);
|
|
|
|
// Load command history from localStorage
|
|
useEffect(() => {
|
|
const history = loadCommandHistory();
|
|
setPersistentHistory(history);
|
|
}, []);
|
|
|
|
// Initialize file system
|
|
useEffect(() => {
|
|
buildFileSystem().then(setFileSystem);
|
|
}, []);
|
|
|
|
const handleSubmit = (e: JSX.TargetedEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
|
|
const commandContext: CommandContext = {
|
|
currentPath,
|
|
fileSystem,
|
|
setCurrentPath,
|
|
setIsTrainRunning,
|
|
setTrainPosition,
|
|
};
|
|
|
|
const output = executeCommand(currentInput, commandContext);
|
|
const newCommand: Command = {
|
|
input: currentInput,
|
|
output,
|
|
timestamp: new Date(),
|
|
path: currentPath,
|
|
};
|
|
|
|
// Save command to persistent history
|
|
const updatedHistory = saveCommandToHistory(
|
|
currentInput,
|
|
persistentHistory,
|
|
);
|
|
setPersistentHistory(updatedHistory);
|
|
|
|
if (currentInput.trim().toLowerCase() === "clear") {
|
|
setCommandHistory([]);
|
|
} else {
|
|
setCommandHistory((prev: Command[]) => [...prev, newCommand]);
|
|
}
|
|
|
|
setCurrentInput("");
|
|
setHistoryIndex(-1);
|
|
};
|
|
|
|
const handleKeyDown = (e: JSX.TargetedKeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
|
|
const { completion, replaceFrom } = getCompletions(
|
|
currentInput,
|
|
currentPath,
|
|
fileSystem,
|
|
);
|
|
|
|
if (completion) {
|
|
const beforeReplacement = currentInput.substring(0, replaceFrom);
|
|
const newInput = beforeReplacement + completion;
|
|
setCurrentInput(newInput + (completion.endsWith("/") ? "" : " "));
|
|
}
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
if (persistentHistory.length > 0) {
|
|
const newIndex =
|
|
historyIndex === -1
|
|
? persistentHistory.length - 1
|
|
: Math.max(0, historyIndex - 1);
|
|
setHistoryIndex(newIndex);
|
|
setCurrentInput(persistentHistory[newIndex]);
|
|
}
|
|
} else if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
if (historyIndex !== -1) {
|
|
const newIndex = Math.min(
|
|
persistentHistory.length - 1,
|
|
historyIndex + 1,
|
|
);
|
|
if (
|
|
newIndex === persistentHistory.length - 1 &&
|
|
historyIndex === newIndex
|
|
) {
|
|
setHistoryIndex(-1);
|
|
setCurrentInput("");
|
|
} else {
|
|
setHistoryIndex(newIndex);
|
|
setCurrentInput(persistentHistory[newIndex]);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-base-100 text-base-content font-mono text-sm h-full flex flex-col rounded-lg border-2 border-primary shadow-2xl relative">
|
|
<div className="bg-base-200 px-4 py-2 rounded-t-lg border-b border-base-300">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 bg-error rounded-full"></div>
|
|
<div className="w-3 h-3 bg-warning rounded-full"></div>
|
|
<div className="w-3 h-3 bg-success rounded-full"></div>
|
|
<span className="ml-4 text-base-content/70 text-xs">
|
|
guest@atri.dad: {currentPath}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
ref={terminalRef}
|
|
className={`flex-1 p-4 overflow-y-auto scrollbar-thin scrollbar-thumb-base-300 scrollbar-track-base-100 relative ${
|
|
isTrainRunning ? 'opacity-0' : 'opacity-100'
|
|
}`}
|
|
onClick={() => !isTrainRunning && inputRef.current?.focus()}
|
|
>
|
|
<div className="min-h-full">
|
|
{commandHistory.map((command: Command, index: number) => (
|
|
<div key={index} className="mb-2">
|
|
{command.input && (
|
|
<div className="flex items-center">
|
|
<span className="text-primary font-semibold">
|
|
guest@atri.dad
|
|
</span>
|
|
<span className="text-base-content">:</span>
|
|
<span className="text-secondary font-semibold">
|
|
{command.path}
|
|
</span>
|
|
<span className="text-base-content">$ </span>
|
|
<span className="text-accent">{command.input}</span>
|
|
</div>
|
|
)}
|
|
{command.output && (
|
|
<div
|
|
className="whitespace-pre-wrap text-base-content/80 mt-1"
|
|
dangerouslySetInnerHTML={{
|
|
__html: formatOutput(command.output),
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{!isTrainRunning && (
|
|
<form onSubmit={handleSubmit} className="flex items-center">
|
|
<span className="text-primary font-semibold">guest@atri.dad</span>
|
|
<span className="text-base-content">:</span>
|
|
<span className="text-secondary font-semibold">
|
|
{currentPath}
|
|
</span>
|
|
<span className="text-base-content">$ </span>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={currentInput}
|
|
onInput={(e) =>
|
|
setCurrentInput((e.target as HTMLInputElement).value)
|
|
}
|
|
onKeyDown={handleKeyDown}
|
|
className="flex-1 bg-transparent border-none outline-none text-accent ml-1"
|
|
spellcheck={false}
|
|
/>
|
|
</form>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Train animation overlay - positioned over the content area but outside the opacity div */}
|
|
{isTrainRunning && (
|
|
<div className="absolute inset-x-0 top-16 bottom-0 flex items-center justify-center overflow-hidden pointer-events-none">
|
|
<div
|
|
className="text-white font-mono text-xs whitespace-nowrap"
|
|
style={{
|
|
transform: `translateX(${trainPosition}%)`,
|
|
transition: "none",
|
|
}}
|
|
>
|
|
<pre className="leading-none">{`
|
|
==== ________ ___________
|
|
_D _| |_______/ \\__I_I_____===__|_________|
|
|
|(_)--- | H\\________/ | | =|___ ___| _________________
|
|
/ | | H | | | | ||_| |_|| _| \\_____A
|
|
| | | H |__--------------------| [___] | =| |
|
|
| ________|___H__/__|_____/[][]~\\_______| | -| |
|
|
|/ | |-----------I_____I [][] [] D |=======|____|________________________|_
|
|
__/ =| o |=-O=====O=====O=====O \\ ____Y___________|__|__________________________|_
|
|
|/-=|___|= || || || |_____/~\\___/ |_D__D__D_| |_D__D__D_|
|
|
\\_/ \\__/ \\__/ \\__/ \\__/ \\_/ \\_/ \\_/ \\_/ \\_/`}</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Terminal;
|