import { MouseEventHandler, MutableRefObject, useEffect, useRef, useState } from "react"; import styles from "./Terminal.module.css"; import { useVirtualRoot } from "../../../hooks/virtual-drive/virtualRootContext"; import { OutputLine } from "./OutputLine"; import { InputLine } from "./InputLine"; import { ANSI, HOSTNAME, USERNAME, WELCOME_MESSAGE } from "../../../config/apps/terminal.config"; import { CommandsManager } from "../../../features/apps/terminal/commands"; import { removeFromArray } from "../../../features/_utils/array.utils"; import { Stream } from "../../../features/apps/terminal/stream"; import { formatError } from "../../../features/apps/terminal/_utils/terminal.utils"; import { WindowProps } from "../../windows/WindowView"; import { VirtualFolder } from "../../../features/virtual-drive/folder/virtualFolder"; import { CommandResponse } from "../../../features/apps/terminal/command"; import { APP_NAMES } from "../../../config/apps.config"; import { useSettingsManager } from "../../../hooks/settings/settingsManagerContext"; import { SettingsManager } from "../../../features/settings/settingsManager"; import { clamp } from "../../../features/_utils/math.utils"; interface TerminalProps extends WindowProps { path: string; input: string; } interface HistoryEntry { text: string; isInput: boolean; value?: string; clear?: boolean; } export function Terminal({ path: startPath, input, setTitle, close: exit, active, focus }: TerminalProps) { const [inputKey, setInputKey] = useState(0); const [inputValue, setInputValue] = useState(input ?? ""); const [history, setHistory] = useState([{ text: WELCOME_MESSAGE.replace("$APP_NAME", APP_NAMES.TERMINAL), isInput: false, }]); const virtualRoot = useVirtualRoot(); const [currentDirectory, setCurrentDirectory] = useState(virtualRoot?.navigate(startPath ?? "~")); const inputRef = useRef(null); const [historyIndex, setHistoryIndex] = useState(0); const [stream, setStream] = useState(null); const [streamOutput, setStreamOutput] = useState(null); const ref = useRef(null); const [streamFocused, setStreamFocused] = useState(false); const settingsManager = useSettingsManager(); useEffect(() => { if (currentDirectory != null) setTitle?.(`${USERNAME}@${HOSTNAME}: ${currentDirectory.root ? "/" : currentDirectory.path}`); }, [currentDirectory?.path, currentDirectory?.root, setTitle]); useEffect(() => { if (!inputRef.current || !active) return; (inputRef.current as unknown as HTMLInputElement).focus(); }, [inputRef, active]); const scrollDown = () => { (ref.current as unknown as HTMLDivElement).scrollTop = (ref.current as unknown as HTMLDivElement).scrollHeight; }; useEffect(() => { if (streamFocused || ref.current == null || streamOutput == null) return; scrollDown(); setStreamFocused(true); }, [streamFocused, streamOutput, ref]); useEffect(() => { if (ref.current == null || stream != null) return; scrollDown(); }, [inputValue]); const prefix = `${ANSI.fg.cyan + USERNAME}@${HOSTNAME + ANSI.reset}:` + `${ANSI.fg.blue + ((currentDirectory?.root || currentDirectory == null) ? "/" : currentDirectory?.path) + ANSI.reset}$ `; const updatedHistory = history; const pushHistory = (entry: HistoryEntry) => { updatedHistory.push(entry); setHistory(updatedHistory); }; const promptOutput = (text: string) => { pushHistory({ text, isInput: false }); }; const connectStream = (stream: Stream, pipes: string[]) => { setStream(stream); setStreamFocused(false); const onKeyDown = (event: KeyboardEvent) => { if (active && (event.ctrlKey || event.metaKey) && event.key === "c") { stream.stop(); } }; let lastOutput: CommandResponse | null = null; stream.on(Stream.EVENT_NAMES.new, (text) => { void (async () => { let output: CommandResponse = text as CommandResponse; for (const pipe of pipes) { if (output instanceof Stream) continue; // Output from the previous command gets added as an argument for the next command output = await handleInput(output ? `${pipe} ${output as string}` : pipe); } if ((output as unknown) instanceof Stream) { stream.stop(); promptOutput(ANSI.fg.red + "Stream failed"); return; } lastOutput = output; setStreamOutput(output as string); })(); }); stream.on(Stream.EVENT_NAMES.stop, () => { document.removeEventListener("keydown", onKeyDown); promptOutput(lastOutput as string); setStream(null); setStreamOutput(null); }); document.addEventListener("keydown", onKeyDown); }; const handleInput = async (value: string): Promise => { const rawInputValueStart = value.indexOf(" ") + 1; const rawInputValue = rawInputValueStart <= 0 ? "" : value.substr(rawInputValueStart); const timestamp = Date.now(); value = value.trim(); if (value === "") return; // Parse arguments const args = value.match(/(?:[^\s"]+|"[^"]*")+/g); if (args == null) return; if (args[0].toLowerCase() === "sudo" && args.length >= 2) args.shift(); // Get command const commandName = args.shift()?.toLowerCase(); if (commandName == null) return; const command = CommandsManager.find(commandName); if (!command) return formatError(commandName, "Command not found"); // Get options const options: string[] = []; const inputs: Record = {}; args.filter((arg: string) => arg.startsWith("-")).forEach((option: string) => { const addOption = (key: string) => { if (options.includes(key)) return; options.push(key); const commandOption = command.getOption(options[options.length - 1]); if (commandOption?.isInput) { const optionInput = args[args.indexOf(option) + 1]; inputs[commandOption.short] = optionInput; removeFromArray(optionInput, args); } }; if (option.startsWith("--")) { const longOption = option.substring(2).toLowerCase(); addOption(longOption); } else { const shortOptions = option.substring(1).split(""); shortOptions.forEach((shortOption: string) => { addOption(shortOption); }); } removeFromArray(option, args); }); // Check usage if (command.requireArgs && args.length === 0) return formatError(commandName, `Incorrect usage: ${commandName} requires at least 1 argument`); if (command.requireOptions && options.length === 0) return formatError(commandName, `Incorrect usage: ${commandName} requires at least 1 option`); // Execute command let response: CommandResponse | null = null; try { response = await command.execute(args, { promptOutput, pushHistory, virtualRoot, currentDirectory: currentDirectory as VirtualFolder, setCurrentDirectory, username: USERNAME, hostname: HOSTNAME, rawInputValue, options, exit, inputs, timestamp, settingsManager: settingsManager as SettingsManager, }); if (response == null) return formatError(commandName, "Command failed"); if (!(response as { blank: boolean }).blank) return response; } catch (error) { console.error(error); return formatError(commandName, "Command failed"); } }; const resetInput = () => { setInputValue(""); setHistoryIndex(0); }; const submitInput = async (value: string) => { pushHistory({ text: prefix + value, isInput: true, value }); // Piping is used to chain commands let pipes = value.split(" | "); const completedPipes: string[] = []; let output: CommandResponse | null = null; for (const pipe of pipes) { if (output instanceof Stream) continue; // Output from the previous command gets added as an argument for the next command output = await handleInput(output ? `${pipe} ${output as string}` : pipe); completedPipes.push(pipe); } resetInput(); pipes = pipes.filter((pipe) => !completedPipes.includes(pipe)); if (output) { if (output instanceof Stream) { connectStream(output, pipes); } else { promptOutput(`${output as string}\n`); } } }; const updateHistoryIndex = (delta: number) => { const inputHistory = history.filter(({ isInput }) => isInput); const index = clamp(historyIndex + delta, 0, inputHistory.length); if (index === historyIndex) { if (delta < 0) { setInputValue(""); } return; } if (index === 0) { setInputValue(""); } else { setInputValue(inputHistory[inputHistory.length - index].value ?? ""); } setHistoryIndex(index); }; const onKeyDown = (event: KeyboardEvent) => { const value = (event.target as HTMLInputElement).value; const { key } = event; if (key === "Enter") { void submitInput(value); setInputKey((previousKey) => previousKey + 1); } else if (key === "ArrowUp") { event.preventDefault(); updateHistoryIndex(1); } else if (key === "ArrowDown") { event.preventDefault(); updateHistoryIndex(-1); } else if (!stream && (event.ctrlKey || event.metaKey) && key === "c") { setInputValue((value) => value + "^C"); } }; const onChange = (event: KeyboardEvent) => { const value = (event.target as HTMLInputElement).value; return setInputValue(value); }; const displayHistory = () => { const visibleHistory = history.slice(-16); let startIndex = 0; visibleHistory.forEach((entry, index) => { if (entry.clear) startIndex = index + 1; }); return visibleHistory.slice(startIndex).map(({ text }, index) => { return ; }); }; const onMouseDown = (event: MouseEvent) => { focus?.(event); if (event.button === 2) { event.preventDefault(); navigator.clipboard.readText?.().then((text) => { setInputValue(inputValue + text); }).catch((error) => { console.error(error); }); } }; const onContextMenu = (event: Event) => { event.preventDefault(); }; return (
{ if (window.getSelection()?.toString() === "") { event.preventDefault(); (inputRef.current as HTMLInputElement | null)?.focus(); } }} > {displayHistory()} {!stream ? } /> : }
); }