510 lines
14 KiB
JavaScript
510 lines
14 KiB
JavaScript
import { useState, useEffect, useRef } from "react";
|
|
import { fileSystem } from "../data/filesystem";
|
|
|
|
export default function Terminal() {
|
|
const [currentPath, setCurrentPath] = useState("/home/guest");
|
|
const [commandHistory, setCommandHistory] = useState([]);
|
|
const [inputValue, setInputValue] = useState("");
|
|
const inputRef = useRef(null);
|
|
const terminalRef = useRef(null);
|
|
const [hasBeenCleared, setHasBeenCleared] = useState(false);
|
|
|
|
// Focus the input when the component mounts and on click
|
|
useEffect(() => {
|
|
inputRef.current.focus();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Make sure input is focused after render
|
|
if (inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, [commandHistory]); // Re-focus after command history changes
|
|
|
|
const focusInput = () => {
|
|
inputRef.current.focus();
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
console.log("Key pressed:", e.key, "Input value:", inputValue); // Add debugging
|
|
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
e.stopPropagation(); // Stop event propagation
|
|
console.log("Enter pressed, executing command:", inputValue);
|
|
executeCommand();
|
|
return false; // Ensure the event is fully handled
|
|
} else if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
handleTabCompletion();
|
|
} else if (e.key === "l" && e.ctrlKey) {
|
|
e.preventDefault();
|
|
handleClear();
|
|
setInputValue("");
|
|
return;
|
|
}
|
|
};
|
|
|
|
const handleContainerKeyDown = (e) => {
|
|
if (e.key === "Enter" && document.activeElement === inputRef.current) {
|
|
e.preventDefault();
|
|
executeCommand();
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setCommandHistory([]);
|
|
setHasBeenCleared(true);
|
|
|
|
// Return focus to input
|
|
setTimeout(() => {
|
|
if (inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, 10);
|
|
|
|
return "";
|
|
};
|
|
|
|
// Path resolution functions
|
|
const resolvePath = (path) => {
|
|
// If it's an absolute path, start from root
|
|
if (path.startsWith("/")) {
|
|
return path;
|
|
}
|
|
|
|
// If it's ".", return current path
|
|
if (path === ".") {
|
|
return currentPath;
|
|
}
|
|
|
|
// If it's "..", go up one level
|
|
if (path === "..") {
|
|
const pathParts = currentPath.split("/").filter(Boolean);
|
|
if (pathParts.length === 0) {
|
|
return "/";
|
|
}
|
|
pathParts.pop();
|
|
return "/" + pathParts.join("/");
|
|
}
|
|
|
|
// Handle relative paths - append to current path
|
|
if (currentPath.endsWith("/")) {
|
|
return currentPath + path;
|
|
}
|
|
return currentPath + "/" + path;
|
|
};
|
|
|
|
const getItemAtPath = (path) => {
|
|
const normalizedPath =
|
|
path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
|
|
|
|
if (normalizedPath === "/") {
|
|
return fileSystem["/"];
|
|
}
|
|
|
|
const pathParts = normalizedPath.split("/").filter(Boolean);
|
|
let current = fileSystem["/"];
|
|
|
|
for (const part of pathParts) {
|
|
if (!current || current.type !== "directory" || !current.children[part]) {
|
|
return null;
|
|
}
|
|
current = current.children[part];
|
|
}
|
|
|
|
return current;
|
|
};
|
|
|
|
// Handle tab completion
|
|
const handleTabCompletion = () => {
|
|
if (!inputValue.trim()) return;
|
|
|
|
const parts = inputValue.trim().split(" ");
|
|
const command = parts[0].toLowerCase();
|
|
|
|
// If completing a command
|
|
if (parts.length === 1) {
|
|
const commands = [
|
|
"ls",
|
|
"cd",
|
|
"cat",
|
|
"pwd",
|
|
"date",
|
|
"help",
|
|
"clear",
|
|
"view",
|
|
];
|
|
const matchingCommands = commands.filter((cmd) =>
|
|
cmd.startsWith(command)
|
|
);
|
|
|
|
if (matchingCommands.length === 1) {
|
|
setInputValue(matchingCommands[0]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If completing a path/file/directory
|
|
if (["cd", "cat", "ls", "view"].includes(command)) {
|
|
const partialPath = parts[parts.length - 1];
|
|
const suggestions = getPathSuggestions(partialPath);
|
|
|
|
if (suggestions.length === 1) {
|
|
const completedParts = [...parts];
|
|
completedParts[completedParts.length - 1] = suggestions[0];
|
|
setInputValue(completedParts.join(" "));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Get path suggestions based on current directory
|
|
const getPathSuggestions = (partialPath) => {
|
|
const dirPath = partialPath.includes("/")
|
|
? partialPath.substring(0, partialPath.lastIndexOf("/") + 1)
|
|
: "";
|
|
|
|
const prefix = partialPath.includes("/")
|
|
? partialPath.substring(partialPath.lastIndexOf("/") + 1)
|
|
: partialPath;
|
|
|
|
const resolvedDirPath = resolvePath(dirPath || currentPath);
|
|
const dir = getItemAtPath(resolvedDirPath);
|
|
|
|
if (!dir || dir.type !== "directory") return [];
|
|
|
|
return Object.keys(dir.children)
|
|
.filter((name) => name.startsWith(prefix))
|
|
.map((name) => {
|
|
const fullPath = dirPath + name;
|
|
if (dir.children[name].type === "directory") {
|
|
return fullPath + "/";
|
|
}
|
|
return fullPath;
|
|
});
|
|
};
|
|
|
|
// Process and execute commands
|
|
const executeCommand = () => {
|
|
if (!inputValue.trim()) return;
|
|
|
|
// Create a new history entry with the CURRENT PATH at execution time
|
|
const newHistoryEntry = {
|
|
command: inputValue,
|
|
output: "",
|
|
path: currentPath, // Store the path at execution time
|
|
};
|
|
|
|
const parts = inputValue.trim().split(/\s+/);
|
|
const command = parts[0].toLowerCase();
|
|
const args = parts.slice(1);
|
|
|
|
let output = "";
|
|
|
|
switch (command) {
|
|
case "ls":
|
|
output = handleLs(args);
|
|
break;
|
|
case "cd":
|
|
output = handleCd(args);
|
|
break;
|
|
case "cat":
|
|
output = handleCat(args);
|
|
break;
|
|
case "view":
|
|
output = handleView(args);
|
|
break;
|
|
case "pwd":
|
|
output = currentPath;
|
|
break;
|
|
case "date":
|
|
output = new Date().toLocaleString();
|
|
break;
|
|
case "help":
|
|
output =
|
|
"Available commands:\n" +
|
|
" ls - List directory contents\n" +
|
|
" cd [directory] - Change directory\n" +
|
|
" cat [file] - Display file contents\n" +
|
|
" view [image] - Display image files\n" +
|
|
" pwd - Print working directory\n" +
|
|
" clear - Clear the terminal\n" +
|
|
" help - Display this help message\n\n" +
|
|
"Shortcuts:\n" +
|
|
" Tab - Autocomplete commands and paths\n" +
|
|
" Ctrl+L - Clear the terminal";
|
|
break;
|
|
case "clear":
|
|
clearTerminal();
|
|
setInputValue("");
|
|
return;
|
|
default:
|
|
output = `Command not found: ${command}`;
|
|
}
|
|
|
|
newHistoryEntry.output = output;
|
|
setCommandHistory([...commandHistory, newHistoryEntry]);
|
|
setInputValue("");
|
|
|
|
// Auto-scroll to bottom and re-focus input after rendering
|
|
setTimeout(() => {
|
|
if (terminalRef.current) {
|
|
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
|
|
}
|
|
if (inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, 50);
|
|
};
|
|
|
|
// Handle ls command
|
|
const handleLs = (args) => {
|
|
let targetPath = currentPath;
|
|
|
|
if (args.length > 0) {
|
|
// Handle path argument
|
|
targetPath = resolvePath(args[0]);
|
|
}
|
|
|
|
const item = getItemAtPath(targetPath);
|
|
|
|
if (!item) {
|
|
return `ls: ${args[0]}: No such file or directory`;
|
|
}
|
|
|
|
if (item.type !== "directory") {
|
|
return `ls: ${args[0]}: Not a directory`;
|
|
}
|
|
|
|
const children = Object.keys(item.children).sort();
|
|
|
|
if (children.length === 0) {
|
|
return "<span class='text-gray-400'>Empty directory</span>";
|
|
}
|
|
|
|
return children
|
|
.map((name) => {
|
|
const child = item.children[name];
|
|
const itemClass =
|
|
child.type === "directory"
|
|
? "directory"
|
|
: child.type === "image"
|
|
? "image"
|
|
: "file";
|
|
|
|
return `<div class="file-item ${itemClass}">${name}</div>`;
|
|
})
|
|
.join("");
|
|
};
|
|
|
|
// Handle cd command
|
|
const handleCd = (args) => {
|
|
if (!args.length || args[0] === "~" || args[0] === "/home/guest") {
|
|
setCurrentPath("/home/guest");
|
|
return "";
|
|
}
|
|
|
|
// Handle special case for tilde expansion
|
|
let targetPath = args[0];
|
|
if (targetPath.startsWith("~/")) {
|
|
targetPath = "/home/guest/" + targetPath.substring(2);
|
|
} else if (targetPath === "~") {
|
|
targetPath = "/home/guest";
|
|
}
|
|
|
|
let newPath = resolvePath(targetPath);
|
|
|
|
// Ensure path has leading slash
|
|
if (!newPath.startsWith("/")) {
|
|
newPath = "/" + newPath;
|
|
}
|
|
|
|
// Normalize path (remove trailing slash except for root)
|
|
if (newPath !== "/" && newPath.endsWith("/")) {
|
|
newPath = newPath.slice(0, -1);
|
|
}
|
|
|
|
const targetDir = getItemAtPath(newPath);
|
|
|
|
if (!targetDir) {
|
|
return `cd: ${targetPath}: No such directory`;
|
|
}
|
|
|
|
if (targetDir.type !== "directory") {
|
|
return `cd: ${targetPath}: Not a directory`;
|
|
}
|
|
|
|
setCurrentPath(newPath);
|
|
return "";
|
|
};
|
|
|
|
// Handle cat command
|
|
const handleCat = (args) => {
|
|
if (!args.length) {
|
|
return "Usage: cat [filename]";
|
|
}
|
|
|
|
const fileName = args[0];
|
|
const currentDir = getItemAtPath(currentPath);
|
|
|
|
if (!currentDir || currentDir.type !== "directory") {
|
|
return "Current location is not a directory";
|
|
}
|
|
|
|
if (
|
|
fileName in currentDir.children &&
|
|
currentDir.children[fileName].type === "file"
|
|
) {
|
|
return currentDir.children[fileName].content;
|
|
}
|
|
|
|
return `cat: ${fileName}: No such file`;
|
|
};
|
|
|
|
// Handle view command for images
|
|
const handleView = (args) => {
|
|
if (!args.length) {
|
|
return "Usage: view [image filename]";
|
|
}
|
|
|
|
const fileName = args[0];
|
|
let file;
|
|
|
|
// Handle paths with slashes
|
|
if (fileName.includes("/")) {
|
|
const path = resolvePath(fileName);
|
|
file = getItemAtPath(path);
|
|
} else {
|
|
// Look in current directory
|
|
const currentDir = getItemAtPath(currentPath);
|
|
if (!currentDir || currentDir.type !== "directory") {
|
|
return "Current location is not a directory";
|
|
}
|
|
|
|
file = currentDir.children[fileName];
|
|
}
|
|
|
|
if (!file) {
|
|
return `view: ${fileName}: No such file`;
|
|
}
|
|
|
|
if (file.type !== "image") {
|
|
return `view: ${fileName}: Not an image file`;
|
|
}
|
|
|
|
// Return HTML with the image
|
|
setTimeout(() => {
|
|
// Re-focus the input after image is displayed
|
|
if (inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, 50);
|
|
|
|
return `<div class="mt-2 flex flex-col items-center">
|
|
<img src="${file.src}" alt="${file.description || "Image"}"
|
|
class="max-w-full rounded shadow-md" style="max-height: 300px;" />
|
|
<div class="text-sm text-gray-400 mt-1">${file.description || ""}</div>
|
|
</div>`;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="h-full text-terminal-text font-mono p-4 overflow-hidden flex flex-col text-left bg-gradient-to-b from-gray-900 to-black rounded-b-lg"
|
|
onClick={focusInput}
|
|
onKeyDown={handleContainerKeyDown}
|
|
tabIndex="-1"
|
|
>
|
|
<div
|
|
ref={terminalRef}
|
|
className="flex-1 overflow-y-auto overflow-x-hidden pb-4 custom-scrollbar text-left"
|
|
style={{
|
|
maxHeight: "calc(100% - 30px)",
|
|
scrollbarWidth: "thin",
|
|
scrollbarColor: "rgba(128, 90, 213, 0.7) rgba(35, 35, 35, 0.5)",
|
|
}}
|
|
>
|
|
{!hasBeenCleared && (
|
|
<div className="text-lg mb-4 text-terminal-accent text-left">
|
|
<pre className="text-purple-300 font-bold">
|
|
{`
|
|
_ _ ____ ____ _ ____ ___ _ _ _____ __ __ _____
|
|
| \\ | | _ \\ / ___| / \\ | __ ) / _ \\| | | |_ _| | \\/ | ____|
|
|
| \\| | |_) | | / _ \\ | _ \\| | | | | | | | | | |\\/| | _|
|
|
| |\\ | __/| |___ / ___ \\| |_) | |_| | |_| | | | | | | | |___
|
|
|_| \\_|_| \\____| /_/ \\_\\____/ \\___/ \\___/ |_| |_| |_|_____|
|
|
|
|
`}
|
|
</pre>
|
|
<div className="text-green-400 font-semibold mb-2">
|
|
Welcome to my interactive terminal!
|
|
</div>
|
|
<div className="text-amber-300 mb-3">
|
|
Type{" "}
|
|
<span className="bg-gray-800 px-1 rounded text-white">help</span>{" "}
|
|
to see available commands.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{commandHistory.map((entry, index) => (
|
|
<div key={index} className="mb-2 text-left">
|
|
<div className="flex">
|
|
<span className="text-terminal-prompt mr-2">
|
|
guest@npc-shell:
|
|
{entry.path === "/home/guest"
|
|
? "~"
|
|
: entry.path.replace("/home/guest", "~")}
|
|
$
|
|
</span>
|
|
<span className="text-terminal-command">{entry.command}</span>
|
|
</div>
|
|
{entry.output && (
|
|
<div
|
|
className={`mt-1 text-left terminal-content ${
|
|
entry.command.startsWith("ls")
|
|
? "command-output-ls"
|
|
: "whitespace-pre-line"
|
|
}`}
|
|
dangerouslySetInnerHTML={{ __html: entry.output }}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
executeCommand();
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<div className="flex items-center">
|
|
<span className="text-terminal-prompt mr-2 flex items-center">
|
|
<span className="text-green-400">guest</span>
|
|
<span className="text-gray-500">@</span>
|
|
<span className="text-purple-400">npc-shell</span>
|
|
<span className="text-gray-500">:</span>
|
|
<span className="text-blue-400">
|
|
{currentPath === "/home/guest"
|
|
? "~"
|
|
: currentPath.replace("/home/guest", "~")}
|
|
</span>
|
|
<span className="text-yellow-500">$</span>
|
|
</span>
|
|
<input
|
|
ref={inputRef}
|
|
className="bg-transparent text-terminal-command outline-none border-none flex-1 caret-purple-400"
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
autoComplete="off"
|
|
spellCheck="false"
|
|
/>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|