diff --git a/src/Terminal/getTabCompletionPossibilities.ts b/src/Terminal/getTabCompletionPossibilities.ts index 59773031e..b699599cd 100644 --- a/src/Terminal/getTabCompletionPossibilities.ts +++ b/src/Terminal/getTabCompletionPossibilities.ts @@ -17,14 +17,21 @@ import { parseUnknownError } from "../utils/ErrorHelper"; import { DarknetServer } from "../Server/DarknetServer"; import { CompletedProgramName } from "@enums"; +/** Extract the text being autocompleted, handling unclosed double quotes as a single token */ +export function extractCurrentText(terminalText: string): string { + const quoteCount = (terminalText.match(/"/g) || []).length; + if (quoteCount % 2 === 1) return terminalText.substring(terminalText.lastIndexOf('"')); + return /[^ ]*$/.exec(terminalText)?.[0] ?? ""; +} + /** Suggest all completion possibilities for the last argument in the last command being typed * @param terminalText The current full text entered in the terminal * @param baseDir The current working directory. * @returns Array of possible string replacements for the current text being autocompleted. */ export async function getTabCompletionPossibilities(terminalText: string, baseDir = root): Promise { - // Get the current command text - const currentText = /[^ ]*$/.exec(terminalText)?.[0] ?? ""; + // Get the current command text, treating unclosed quotes as a single token + const currentText = extractCurrentText(terminalText); // Remove the current text from the commands string const valueWithoutCurrent = terminalText.substring(0, terminalText.length - currentText.length); // Parse the commands string, this handles alias replacement as well. diff --git a/src/Terminal/ui/TerminalInput.tsx b/src/Terminal/ui/TerminalInput.tsx index a3650d25e..0b96c8563 100644 --- a/src/Terminal/ui/TerminalInput.tsx +++ b/src/Terminal/ui/TerminalInput.tsx @@ -6,7 +6,7 @@ import { Paper, Popper, TextField, Typography } from "@mui/material"; import { KEY } from "../../utils/KeyboardEventKey"; import { Terminal } from "../../Terminal"; import { Player } from "@player"; -import { getTabCompletionPossibilities } from "../getTabCompletionPossibilities"; +import { extractCurrentText, getTabCompletionPossibilities } from "../getTabCompletionPossibilities"; import { Settings } from "../../Settings/Settings"; import { longestCommonStart } from "../../utils/StringHelperFunctions"; import { exceptionAlert } from "../../utils/helpers/exceptionAlert"; @@ -266,13 +266,16 @@ export function TerminalInput(): React.ReactElement { if (possibilities.length === 0) return; setSearchResults([]); + // Use quote-aware replacement: if mid-quote, replace from the opening quote + const currentText = extractCurrentText(value); + const replacePattern = currentText.startsWith('"') ? /"[^"]*$/ : /[^ ]*$/; if (possibilities.length === 1) { - saveValue(value.replace(/[^ ]*$/, possibilities[0]) + " "); + saveValue(value.replace(replacePattern, 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)); + saveValue(value.replace(replacePattern, longestMatch)); setPossibilities(possibilities); } diff --git a/test/jest/Terminal/tabCompletion.test.ts b/test/jest/Terminal/tabCompletion.test.ts index c72884557..8498ef4e3 100644 --- a/test/jest/Terminal/tabCompletion.test.ts +++ b/test/jest/Terminal/tabCompletion.test.ts @@ -1,7 +1,7 @@ /* eslint-disable no-await-in-loop */ import { Player } from "../../../src/Player"; -import { getTabCompletionPossibilities } from "../../../src/Terminal/getTabCompletionPossibilities"; +import { getTabCompletionPossibilities, extractCurrentText } from "../../../src/Terminal/getTabCompletionPossibilities"; import { Server } from "../../../src/Server/Server"; import { AddToAllServers, prestigeAllServers } from "../../../src/Server/AllServers"; import { LocationName } from "../../../src/Enums"; @@ -187,6 +187,27 @@ describe("getTabCompletionPossibilities", function () { }); }); +describe("extractCurrentText", () => { + it("returns last word for unquoted input", () => { + expect(extractCurrentText("run myscript.js foo")).toBe("foo"); + }); + it("returns empty string for input ending with space", () => { + expect(extractCurrentText("run myscript.js ")).toBe(""); + }); + it("returns text from opening quote for unclosed double quote", () => { + expect(extractCurrentText('run myscript.js "nonunique se')).toBe('"nonunique se'); + }); + it("returns last word when all quotes are closed", () => { + expect(extractCurrentText('run myscript.js "arg1" foo')).toBe("foo"); + }); + it("handles empty input", () => { + expect(extractCurrentText("")).toBe(""); + }); + it("returns text from opening quote with one word inside", () => { + expect(extractCurrentText('run "partial')).toBe('"partial'); + }); +}); + function asDirectory(dir: string): Directory { if (!isAbsolutePath(dir) || !isDirectoryPath(dir)) throw new Error(`Directory ${dir} failed typechecking`); return dir;