From a3ab70f3dfe1a44e3687c95f6a2c531c9828c435 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 23 Jul 2025 11:06:49 -0400 Subject: [PATCH] Split commands on newlines --- .../__tests__/command-validation.spec.ts | 115 ++++++++++++++++++ webview-ui/src/utils/command-validation.ts | 26 +++- 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/utils/__tests__/command-validation.spec.ts b/webview-ui/src/utils/__tests__/command-validation.spec.ts index 661d09c802a..cf19f11bfcd 100644 --- a/webview-ui/src/utils/__tests__/command-validation.spec.ts +++ b/webview-ui/src/utils/__tests__/command-validation.spec.ts @@ -50,6 +50,121 @@ describe("Command Validation", () => { parseCommand('npm test | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"'), ).toEqual(["npm test", 'Select-String -NotMatch "node_modules"', 'Select-String "FAIL|Error"']) }) + + describe("newline handling", () => { + it("splits commands by Unix newlines (\\n)", () => { + expect(parseCommand("echo hello\ngit status\nnpm install")).toEqual([ + "echo hello", + "git status", + "npm install", + ]) + }) + + it("splits commands by Windows newlines (\\r\\n)", () => { + expect(parseCommand("echo hello\r\ngit status\r\nnpm install")).toEqual([ + "echo hello", + "git status", + "npm install", + ]) + }) + + it("splits commands by old Mac newlines (\\r)", () => { + expect(parseCommand("echo hello\rgit status\rnpm install")).toEqual([ + "echo hello", + "git status", + "npm install", + ]) + }) + + it("handles mixed line endings", () => { + expect(parseCommand("echo hello\ngit status\r\nnpm install\rls -la")).toEqual([ + "echo hello", + "git status", + "npm install", + "ls -la", + ]) + }) + + it("ignores empty lines", () => { + expect(parseCommand("echo hello\n\n\ngit status\r\n\r\nnpm install")).toEqual([ + "echo hello", + "git status", + "npm install", + ]) + }) + + it("handles newlines with chain operators", () => { + expect(parseCommand('npm install && npm test\ngit add .\ngit commit -m "test"')).toEqual([ + "npm install", + "npm test", + "git add .", + 'git commit -m "test"', + ]) + }) + + it("splits on actual newlines even within quotes", () => { + // Note: Since we split by newlines first, actual newlines in the input + // will split the command, even if they appear to be within quotes + // Using template literal to create actual newline + const commandWithNewlineInQuotes = `echo "Hello +World" +git status` + // The quotes get stripped because they're no longer properly paired after splitting + expect(parseCommand(commandWithNewlineInQuotes)).toEqual(["echo Hello", "World", "git status"]) + }) + + it("handles quoted strings on single line", () => { + // When quotes are on the same line, they are preserved + expect(parseCommand('echo "Hello World"\ngit status')).toEqual(['echo "Hello World"', "git status"]) + }) + + it("handles complex multi-line commands", () => { + const multiLineCommand = `npm install +npm test && npm run build +echo "Done" | tee output.log +git status; git add . +ls -la || echo "Failed"` + + expect(parseCommand(multiLineCommand)).toEqual([ + "npm install", + "npm test", + "npm run build", + 'echo "Done"', + "tee output.log", + "git status", + "git add .", + "ls -la", + 'echo "Failed"', + ]) + }) + + it("handles newlines with subshells", () => { + expect(parseCommand("echo $(date)\nnpm test\ngit status")).toEqual([ + "echo", + "date", + "npm test", + "git status", + ]) + }) + + it("handles newlines with redirections", () => { + expect(parseCommand("npm test 2>&1\necho done\nls -la > files.txt")).toEqual([ + "npm test 2>&1", + "echo done", + "ls -la > files.txt", + ]) + }) + + it("handles empty input with newlines", () => { + expect(parseCommand("\n\n\n")).toEqual([]) + expect(parseCommand("\r\n\r\n")).toEqual([]) + expect(parseCommand("\r\r\r")).toEqual([]) + }) + + it("handles whitespace-only lines", () => { + expect(parseCommand("echo hello\n \t \ngit status")).toEqual(["echo hello", "git status"]) + }) + }) }) describe("isAutoApprovedSingleCommand (legacy behavior)", () => { diff --git a/webview-ui/src/utils/command-validation.ts b/webview-ui/src/utils/command-validation.ts index 1dbc7229433..69d07beb9b5 100644 --- a/webview-ui/src/utils/command-validation.ts +++ b/webview-ui/src/utils/command-validation.ts @@ -60,17 +60,41 @@ type ShellToken = string | { op: string } | { command: string } /** * Split a command string into individual sub-commands by - * chaining operators (&&, ||, ;, or |). + * chaining operators (&&, ||, ;, or |) and newlines. * * Uses shell-quote to properly handle: * - Quoted strings (preserves quotes) * - Subshell commands ($(cmd) or `cmd`) * - PowerShell redirections (2>&1) * - Chain operators (&&, ||, ;, |) + * - Newlines as command separators */ export function parseCommand(command: string): string[] { if (!command?.trim()) return [] + // Split by newlines first (handle different line ending formats) + // This regex splits on \r\n (Windows), \n (Unix), or \r (old Mac) + const lines = command.split(/\r\n|\r|\n/) + const allCommands: string[] = [] + + for (const line of lines) { + // Skip empty lines + if (!line.trim()) continue + + // Process each line through the existing parsing logic + const lineCommands = parseCommandLine(line) + allCommands.push(...lineCommands) + } + + return allCommands +} + +/** + * Parse a single line of commands (internal helper function) + */ +function parseCommandLine(command: string): string[] { + if (!command?.trim()) return [] + // Storage for replaced content const redirections: string[] = [] const subshells: string[] = []