-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat: Improve terminal reuse logic to prevent proliferation #8525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,6 +1,7 @@ | ||||||
| import * as vscode from "vscode" | ||||||
| import pWaitFor from "p-wait-for" | ||||||
|
|
||||||
| import { arePathsEqual } from "../../utils/path" | ||||||
| import type { RooTerminalCallbacks, RooTerminalProcessResultPromise } from "./types" | ||||||
| import { BaseTerminal } from "./BaseTerminal" | ||||||
| import { TerminalProcess } from "./TerminalProcess" | ||||||
|
|
@@ -47,7 +48,14 @@ export class Terminal extends BaseTerminal { | |||||
| this.busy = true | ||||||
|
|
||||||
| const process = new TerminalProcess(this) | ||||||
| process.command = command | ||||||
| const currentCwd = this.getCurrentWorkingDirectory() | ||||||
| let commandToRun = command | ||||||
|
|
||||||
| if (this.requestedCwd && !arePathsEqual(this.requestedCwd, currentCwd)) { | ||||||
| // Wrap path in quotes to handle spaces | ||||||
| commandToRun = `cd "${this.requestedCwd}" && ${command}` | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Path escaping and shell portability: requestedCwd is interpolated into a shell command without escaping. If it contains a double quote, this will break command parsing. Also, using '&&' assumes a POSIX-like shell; Windows PowerShell 5 doesn't support '&&'. Escape quotes and consider shell-aware composition.
Suggested change
|
||||||
| } | ||||||
| process.command = commandToRun | ||||||
| this.process = process | ||||||
|
|
||||||
| // Set up event handlers from callbacks before starting process. | ||||||
|
|
@@ -76,7 +84,7 @@ export class Terminal extends BaseTerminal { | |||||
| ShellIntegrationManager.zshCleanupTmpDir(this.id) | ||||||
|
|
||||||
| // Run the command in the terminal | ||||||
| process.run(command) | ||||||
| process.run(commandToRun) | ||||||
| }) | ||||||
| .catch(() => { | ||||||
| console.log(`[Terminal ${this.id}] Shell integration not available. Command execution aborted.`) | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -192,12 +192,22 @@ export class TerminalRegistry { | |||||
| }) | ||||||
| } | ||||||
|
|
||||||
| // Third priority: find any idle terminal with the same provider, | ||||||
| // preferably one with the same task ID. | ||||||
| if (!terminal) { | ||||||
| const idleTerminals = terminals.filter((t) => !t.busy && t.provider === provider) | ||||||
| if (idleTerminals.length > 0) { | ||||||
| terminal = idleTerminals.find((t) => t.taskId === taskId) ?? idleTerminals[0] | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // If no suitable terminal found, create a new one. | ||||||
| if (!terminal) { | ||||||
| terminal = this.createTerminal(cwd, provider) | ||||||
| } | ||||||
|
|
||||||
| terminal.taskId = taskId | ||||||
| terminal.requestedCwd = cwd | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Normalize CWD for consistency: requestedCwd is assigned directly from input. Other comparisons normalize to fsPath; assign the normalized path here to avoid mismatches across platforms.
Suggested change
|
||||||
|
|
||||||
| return terminal | ||||||
| } | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| // npx vitest run src/integrations/terminal/__tests__/Terminal.spec.ts | ||
|
|
||
| import * as vscode from "vscode" | ||
| import { Terminal } from "../Terminal" | ||
| import { TerminalProcess } from "../TerminalProcess" | ||
| import { vi } from "vitest" | ||
|
|
||
| // Mock dependencies | ||
| vi.mock("vscode", () => ({ | ||
| window: { | ||
| createTerminal: vi.fn().mockReturnValue({ | ||
| shellIntegration: { | ||
| cwd: { fsPath: "/initial/cwd" }, | ||
| }, | ||
| sendText: vi.fn(), | ||
| exitStatus: undefined, | ||
| }), | ||
| }, | ||
| ThemeIcon: vi.fn(), | ||
| })) | ||
|
|
||
| vi.mock("p-wait-for", () => ({ | ||
| default: vi.fn().mockResolvedValue(true), | ||
| })) | ||
|
|
||
| vi.mock("../TerminalProcess") | ||
| vi.mock("../../../utils/path", () => ({ | ||
| arePathsEqual: vi.fn((a, b) => a === b), | ||
| })) | ||
|
|
||
| describe("Terminal", () => { | ||
| describe("runCommand", () => { | ||
| let terminal: Terminal | ||
| let mockRun: any | ||
| let mockProcess: any | ||
|
|
||
| beforeEach(() => { | ||
| terminal = new Terminal(1, undefined, "/initial/cwd") | ||
| mockRun = vi.fn() | ||
| mockProcess = { | ||
| run: mockRun, | ||
| on: vi.fn(), | ||
| once: vi.fn(), | ||
| emit: vi.fn(), | ||
| } | ||
| vi.mocked(TerminalProcess).mockImplementation((): any => { | ||
| return mockProcess | ||
| }) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.clearAllMocks() | ||
| }) | ||
|
|
||
| it("should prepend cd command if requestedCwd differs from currentCwd", () => { | ||
| terminal.requestedCwd = "/requested/cwd" | ||
| vi.spyOn(terminal, "getCurrentWorkingDirectory").mockReturnValue("/initial/cwd") | ||
|
|
||
| const callbacks = { | ||
| onLine: vi.fn(), | ||
| onCompleted: vi.fn(), | ||
| onShellExecutionStarted: vi.fn(), | ||
| onShellExecutionComplete: vi.fn(), | ||
| } | ||
|
|
||
| terminal.runCommand("ls", callbacks) | ||
|
|
||
| expect(mockProcess.command).toBe('cd "/requested/cwd" && ls') | ||
| expect(mockRun).toHaveBeenCalledWith('cd "/requested/cwd" && ls') | ||
| }) | ||
|
|
||
| it("should not prepend cd command if requestedCwd is the same as currentCwd", () => { | ||
| terminal.requestedCwd = "/initial/cwd" | ||
| vi.spyOn(terminal, "getCurrentWorkingDirectory").mockReturnValue("/initial/cwd") | ||
| const callbacks = { | ||
| onLine: vi.fn(), | ||
| onCompleted: vi.fn(), | ||
| onShellExecutionStarted: vi.fn(), | ||
| onShellExecutionComplete: vi.fn(), | ||
| } | ||
|
|
||
| terminal.runCommand("ls", callbacks) | ||
|
|
||
| expect(mockProcess.command).toBe("ls") | ||
| expect(mockRun).toHaveBeenCalledWith("ls") | ||
| }) | ||
|
|
||
| it("should not prepend cd command if requestedCwd is not set", () => { | ||
| vi.spyOn(terminal, "getCurrentWorkingDirectory").mockReturnValue("/initial/cwd") | ||
| const callbacks = { | ||
| onLine: vi.fn(), | ||
| onCompleted: vi.fn(), | ||
| onShellExecutionStarted: vi.fn(), | ||
| onShellExecutionComplete: vi.fn(), | ||
| } | ||
|
|
||
| terminal.runCommand("ls", callbacks) | ||
|
|
||
| expect(mockProcess.command).toBe("ls") | ||
| expect(mockRun).toHaveBeenCalledWith("ls") | ||
| }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When constructing the command (e.g.,
cd "${this.requestedCwd}" && ...), consider sanitizing 'requestedCwd' further to escape embedded quotes or other special characters.