2025-03-24 06:16:11 +01:00

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>
);
}