373 lines
No EOL
10 KiB
TypeScript
373 lines
No EOL
10 KiB
TypeScript
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<HistoryEntry[]>([{
|
|
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<Stream | null>(null);
|
|
const [streamOutput, setStreamOutput] = useState<string | null>(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<CommandResponse> => {
|
|
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<string, string> = {};
|
|
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 <OutputLine text={text} key={index}/>;
|
|
});
|
|
};
|
|
|
|
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 (
|
|
<div
|
|
ref={ref}
|
|
className={styles.Terminal}
|
|
onMouseDown={onMouseDown as unknown as MouseEventHandler}
|
|
onContextMenu={onContextMenu as unknown as MouseEventHandler}
|
|
onClick={(event) => {
|
|
if (window.getSelection()?.toString() === "") {
|
|
event.preventDefault();
|
|
(inputRef.current as HTMLInputElement | null)?.focus();
|
|
}
|
|
}}
|
|
>
|
|
{displayHistory()}
|
|
{!stream
|
|
? <InputLine
|
|
key={inputKey}
|
|
value={inputValue}
|
|
prefix={prefix}
|
|
onKeyDown={onKeyDown}
|
|
onChange={onChange}
|
|
inputRef={inputRef as unknown as MutableRefObject<HTMLInputElement>}
|
|
/>
|
|
: <OutputLine text={streamOutput ?? ""}/>
|
|
}
|
|
</div>
|
|
);
|
|
} |