import React, { useState, useEffect, useRef } from "react"; import { Theme } from "@mui/material/styles"; import { createStyles, makeStyles } from "@mui/styles"; import { Paper, Popper, TextField, Typography } from "@mui/material"; import { KEY, KEYCODE } from "../../utils/helpers/keyCodes"; import { Terminal } from "../../Terminal"; import { Player } from "@player"; import { getTabCompletionPossibilities } from "../getTabCompletionPossibilities"; import { Settings } from "../../Settings/Settings"; import { longestCommonStart } from "../../utils/StringHelperFunctions"; const useStyles = makeStyles((theme: Theme) => createStyles({ textfield: { margin: theme.spacing(0), }, input: { backgroundColor: theme.colors.backgroundprimary, }, nopadding: { padding: theme.spacing(0), }, preformatted: { margin: theme.spacing(0), }, list: { padding: theme.spacing(0), height: "100%", }, absolute: { margin: theme.spacing(0), position: "absolute", bottom: "5px", opacity: "0.75", maxWidth: "100%", whiteSpace: "pre", overflow: "hidden", pointerEvents: "none", }, }), ); // Save command in case we de-load this screen. let command = ""; export function TerminalInput(): React.ReactElement { const terminalInput = useRef(null); const [value, setValue] = useState(command); const [postUpdateValue, setPostUpdateValue] = useState<{ postUpdate: () => void } | null>(); const [possibilities, setPossibilities] = useState([]); const [searchResults, setSearchResults] = useState([]); const [searchResultsIndex, setSearchResultsIndex] = useState(0); const [autofilledValue, setAutofilledValue] = useState(false); const classes = useStyles(); // If we have no data in the current terminal history, let's initialize it from the player save if (Terminal.commandHistory.length === 0 && Player.terminalCommandHistory.length > 0) { Terminal.commandHistory = Player.terminalCommandHistory; Terminal.commandHistoryIndex = Terminal.commandHistory.length; } // Need to run after state updates, for example if we need to move cursor // *after* we modify input useEffect(() => { if (postUpdateValue?.postUpdate) { postUpdateValue.postUpdate(); setPostUpdateValue(null); } }, [postUpdateValue]); function saveValue(newValue: string, postUpdate?: () => void): void { command = newValue; setValue(newValue); if (postUpdate) { setPostUpdateValue({ postUpdate }); } } function handleValueChange(event: React.ChangeEvent): void { saveValue(event.target.value); setPossibilities([]); setSearchResults([]); setAutofilledValue(false); } function resetSearch(isAutofilled = false) { setSearchResults([]); setAutofilledValue(isAutofilled); setSearchResultsIndex(0); } function getSearchSuggestionPrespace() { const currentPrefix = `[${Player.getCurrentServer().hostname} /${Terminal.cwd()}]> `; const prefixLength = `${currentPrefix}${value}`.length; return Array(prefixLength).fill(" "); } function modifyInput(mod: Modification): void { const ref = terminalInput.current; if (!ref) return; const inputLength = value.length; const start = ref.selectionStart; if (start === null) return; const inputText = ref.value; switch (mod) { case "backspace": if (start > 0 && start <= inputLength + 1) { saveValue(inputText.substr(0, start - 1) + inputText.substr(start)); } break; case "deletewordbefore": // Delete rest of word before the cursor for (let delStart = start - 1; delStart > -2; --delStart) { if ((inputText.charAt(delStart) === KEY.SPACE || delStart === -1) && delStart !== start - 1) { saveValue(inputText.substr(0, delStart + 1) + inputText.substr(start), () => { // Move cursor to correct location // foo bar |baz bum --> foo |baz bum const ref = terminalInput.current; ref?.setSelectionRange(delStart + 1, delStart + 1); }); return; } } break; case "deletewordafter": // Delete rest of word after the cursor, including trailing space for (let delStart = start + 1; delStart <= value.length + 1; ++delStart) { if (inputText.charAt(delStart) === KEY.SPACE || delStart === value.length + 1) { saveValue(inputText.substr(0, start) + inputText.substr(delStart + 1), () => { // Move cursor to correct location // foo bar |baz bum --> foo bar |bum const ref = terminalInput.current; ref?.setSelectionRange(start, start); }); return; } } break; case "clearafter": // Deletes everything after cursor saveValue(inputText.substr(0, start)); break; case "clearbefore": // Deletes everything before cursor saveValue(inputText.substr(start), () => moveTextCursor("home")); break; case "clearall": // Deletes everything in the input saveValue(""); resetSearch(); break; } } function moveTextCursor(loc: Location): void { const ref = terminalInput.current; if (!ref) return; const inputLength = value.length; const start = ref.selectionStart; if (start === null) return; switch (loc) { case "home": ref.setSelectionRange(0, 0); break; case "end": ref.setSelectionRange(inputLength, inputLength); break; case "prevchar": if (start > 0) { ref.setSelectionRange(start - 1, start - 1); } break; case "prevword": for (let i = start - 2; i >= 0; --i) { if (ref.value.charAt(i) === KEY.SPACE) { ref.setSelectionRange(i + 1, i + 1); return; } } ref.setSelectionRange(0, 0); break; case "nextchar": ref.setSelectionRange(start + 1, start + 1); break; case "nextword": for (let i = start + 1; i <= inputLength; ++i) { if (ref.value.charAt(i) === KEY.SPACE) { ref.setSelectionRange(i, i); return; } } ref.setSelectionRange(inputLength, inputLength); break; default: console.warn("Invalid loc argument in Terminal.moveTextCursor()"); break; } } // Catch all key inputs and redirect them to the terminal. useEffect(() => { function keyDown(this: Document, event: KeyboardEvent): void { if (Terminal.contractOpen) return; if (Terminal.action !== null && event.key === KEY.C && event.ctrlKey) { Terminal.finishAction(true); return; } const ref = terminalInput.current; if (event.ctrlKey || event.metaKey) return; if (event.key === KEY.C && (event.ctrlKey || event.metaKey)) return; // trying to copy if (ref) ref.focus(); } document.addEventListener("keydown", keyDown); return () => document.removeEventListener("keydown", keyDown); }); async function onKeyDown(event: React.KeyboardEvent): Promise { const ref = terminalInput.current; // Run command or insert newline if (event.key === KEY.ENTER) { event.preventDefault(); const command = searchResults.length ? searchResults[searchResultsIndex] : value; Terminal.print(`[${Player.getCurrentServer().hostname} /${Terminal.cwd()}]> ${command}`); if (command) { Terminal.executeCommands(command); saveValue(""); resetSearch(); } return; } // Autocomplete if (event.key === KEY.TAB) { event.preventDefault(); if (searchResults.length) { saveValue(searchResults[searchResultsIndex]); resetSearch(true); return; } const possibilities = await getTabCompletionPossibilities(value, Terminal.cwd()); if (possibilities.length === 0) return; setSearchResults([]); if (possibilities.length === 1) { saveValue(value.replace(/[^ ]*$/, possibilities[0]) + " "); return; } // More than one possibility, check to see if there is a longer common string than currentText. const longestMatch = longestCommonStart(possibilities); saveValue(value.replace(/[^ ]*$/, longestMatch)); setPossibilities(possibilities); } // Clear screen. if (event.key === KEY.L && event.ctrlKey) { event.preventDefault(); Terminal.clear(); } // Select previous command. if (event.key === KEY.UP_ARROW || (Settings.EnableBashHotkeys && event.key === KEY.P && event.ctrlKey)) { if (Settings.EnableBashHotkeys || (Settings.EnableHistorySearch && value)) { event.preventDefault(); } const i = Terminal.commandHistoryIndex; const len = Terminal.commandHistory.length; if (len == 0) { return; } // If there is a partial command in the terminal, hitting "up" will filter the history if (value && !autofilledValue && Settings.EnableHistorySearch) { if (searchResults.length > 0) { setSearchResultsIndex((searchResultsIndex + 1) % searchResults.length); return; } const newResults = [...new Set(Terminal.commandHistory.filter((item) => item?.startsWith(value)).reverse())]; if (newResults.length) { setSearchResults(newResults); } // Prevent moving through the history when the user has a search term even if there are // no search results, to be consistent with zsh-type terminal behavior return; } if (i < 0 || i > len) { Terminal.commandHistoryIndex = len; } if (i != 0) { --Terminal.commandHistoryIndex; } const prevCommand = Terminal.commandHistory[Terminal.commandHistoryIndex]; saveValue(prevCommand); resetSearch(true); if (ref) { setTimeout(function () { ref.selectionStart = ref.selectionEnd = 10000; }, 10); } } // Select next command if (event.key === KEY.DOWN_ARROW || (Settings.EnableBashHotkeys && event.key === KEY.M && event.ctrlKey)) { if (Settings.EnableBashHotkeys) { event.preventDefault(); } if (searchResults.length > 0) { setSearchResultsIndex(searchResultsIndex === 0 ? searchResults.length - 1 : searchResultsIndex - 1); return; } const i = Terminal.commandHistoryIndex; const len = Terminal.commandHistory.length; if (len == 0) { return; } if (i < 0 || i > len) { Terminal.commandHistoryIndex = len; } // Latest command, put nothing if (i == len || i == len - 1) { Terminal.commandHistoryIndex = len; saveValue(""); resetSearch(); } else { ++Terminal.commandHistoryIndex; const prevCommand = Terminal.commandHistory[Terminal.commandHistoryIndex]; saveValue(prevCommand); resetSearch(true); } } if (event.key === KEY.ESC && searchResults.length) { resetSearch(); } // Extra Bash Emulation Hotkeys, must be enabled through options if (Settings.EnableBashHotkeys) { if (event.code === KEYCODE.C && event.ctrlKey && ref && ref.selectionStart === ref.selectionEnd) { event.preventDefault(); Terminal.print(`[${Player.getCurrentServer().hostname} /${Terminal.cwd()}]> ${value}`); modifyInput("clearall"); } if (event.code === KEYCODE.A && event.ctrlKey) { event.preventDefault(); moveTextCursor("home"); } if (event.code === KEYCODE.E && event.ctrlKey) { event.preventDefault(); moveTextCursor("end"); } if (event.code === KEYCODE.B && event.ctrlKey) { event.preventDefault(); moveTextCursor("prevchar"); } if (event.code === KEYCODE.B && event.altKey) { event.preventDefault(); moveTextCursor("prevword"); } if (event.code === KEYCODE.F && event.ctrlKey) { event.preventDefault(); moveTextCursor("nextchar"); } if (event.code === KEYCODE.F && event.altKey) { event.preventDefault(); moveTextCursor("nextword"); } if ((event.code === KEYCODE.H || event.code === KEYCODE.D) && event.ctrlKey) { modifyInput("backspace"); event.preventDefault(); } if (event.code === KEYCODE.W && event.ctrlKey) { event.preventDefault(); modifyInput("deletewordbefore"); } if (event.code === KEYCODE.D && event.altKey) { event.preventDefault(); modifyInput("deletewordafter"); } if (event.code === KEYCODE.U && event.ctrlKey) { event.preventDefault(); modifyInput("clearbefore"); } if (event.code === KEYCODE.K && event.ctrlKey) { event.preventDefault(); modifyInput("clearafter"); } } } return ( <> [{Player.getCurrentServer().hostname} /{Terminal.cwd()}]>  ), spellCheck: false, onBlur: () => { setPossibilities([]); resetSearch(); }, onKeyDown: onKeyDown, }} > 0} anchorEl={terminalInput.current} placement={"top"} sx={{ maxWidth: "75%" }} > Possible autocomplete candidates: {possibilities.join(" ")} {getSearchSuggestionPrespace()} {(searchResults[searchResultsIndex] ?? "").substring(value.length)} ); } type Modification = | "clearall" | "home" | "end" | "prevchar" | "prevword" | "nextword" | "backspace" | "deletewordbefore" | "deletewordafter" | "clearbefore" | "clearafter"; type Location = "home" | "end" | "prevchar" | "nextchar" | "prevword" | "nextword";