From 8722906284998bc0354bcad8eece81b8a5cae7df Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 21 Jul 2025 14:25:00 +0000 Subject: [PATCH 1/2] feat: implement auto-close Roo tabs feature - Add autoCloseRooTabs and autoCloseAllRooTabs settings to global settings - Create PostEditBehaviorUtils class to handle tab closing logic - Update DiffViewProvider to track Roo-opened tabs and close them after save - Add FileEditingOptions component for settings UI - Update SettingsView and webviewMessageHandler to support new settings - Add tests for PostEditBehaviorUtils (some mocking issues to be resolved) Closes #6003 --- packages/types/src/global-settings.ts | 4 + src/core/task/Task.ts | 7 + src/core/webview/webviewMessageHandler.ts | 8 + src/integrations/editor/DiffViewProvider.ts | 59 +++- .../editor/PostEditBehaviorUtils.ts | 146 +++++++++ .../__tests__/PostEditBehaviorUtils.spec.ts | 276 ++++++++++++++++++ src/shared/WebviewMessage.ts | 2 + .../settings/FileEditingOptions.tsx | 63 ++++ .../src/components/settings/SettingsView.tsx | 28 ++ .../src/context/ExtensionStateContext.tsx | 12 + 10 files changed, 600 insertions(+), 5 deletions(-) create mode 100644 src/integrations/editor/PostEditBehaviorUtils.ts create mode 100644 src/integrations/editor/__tests__/PostEditBehaviorUtils.spec.ts create mode 100644 webview-ui/src/components/settings/FileEditingOptions.tsx diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index a30550dce16..2d9c688eb79 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -72,6 +72,10 @@ export const globalSettingsSchema = z.object({ autoCondenseContextPercent: z.number().optional(), maxConcurrentFileReads: z.number().optional(), + // File editing options + autoCloseRooTabs: z.boolean().optional(), + autoCloseAllRooTabs: z.boolean().optional(), + browserToolEnabled: z.boolean().optional(), browserViewportSize: z.string().optional(), screenshotQuality: z.number().optional(), diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 53b8ef5b87d..1ec59bcb521 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -263,6 +263,13 @@ export class Task extends EventEmitter { this.diffViewProvider = new DiffViewProvider(this.cwd) this.enableCheckpoints = enableCheckpoints + // Set up auto-close settings for DiffViewProvider + provider.getState().then((state) => { + const autoCloseRooTabs = (state as any).autoCloseRooTabs ?? false + const autoCloseAllRooTabs = (state as any).autoCloseAllRooTabs ?? false + this.diffViewProvider.setAutoCloseSettings(autoCloseRooTabs, autoCloseAllRooTabs) + }) + this.rootTask = rootTask this.parentTask = parentTask this.taskNumber = taskNumber diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 780d40df891..6d6539fcaf4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1044,6 +1044,14 @@ export const webviewMessageHandler = async ( await updateGlobalState("writeDelayMs", message.value) await provider.postStateToWebview() break + case "autoCloseRooTabs": + await updateGlobalState("autoCloseRooTabs", message.bool ?? false) + await provider.postStateToWebview() + break + case "autoCloseAllRooTabs": + await updateGlobalState("autoCloseAllRooTabs", message.bool ?? false) + await provider.postStateToWebview() + break case "diagnosticsEnabled": await updateGlobalState("diagnosticsEnabled", message.bool ?? true) await provider.postStateToWebview() diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index f4133029c99..fb3907c3f4b 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -15,6 +15,7 @@ import { Task } from "../../core/task/Task" import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { DecorationController } from "./DecorationController" +import { PostEditBehaviorUtils } from "./PostEditBehaviorUtils" export const DIFF_VIEW_URI_SCHEME = "cline-diff" export const DIFF_VIEW_LABEL_CHANGES = "Original ↔ Roo's Changes" @@ -36,15 +37,47 @@ export class DiffViewProvider { private activeLineController?: DecorationController private streamedLines: string[] = [] private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [] + private rooOpenedTabs: Set = new Set() + private preEditActiveEditor?: vscode.TextEditor + private autoCloseRooTabs: boolean = false + private autoCloseAllRooTabs: boolean = false constructor(private cwd: string) {} + /** + * Set the auto-close settings for this DiffViewProvider instance + */ + setAutoCloseSettings(autoCloseRooTabs: boolean, autoCloseAllRooTabs: boolean) { + this.autoCloseRooTabs = autoCloseRooTabs + this.autoCloseAllRooTabs = autoCloseAllRooTabs + } + + /** + * Track a tab that Roo opened + */ + trackOpenedTab(filePath: string) { + this.rooOpenedTabs.add(filePath) + } + + /** + * Clear all tracked tabs + */ + clearTrackedTabs() { + this.rooOpenedTabs.clear() + } + async open(relPath: string): Promise { this.relPath = relPath const fileExists = this.editType === "modify" const absolutePath = path.resolve(this.cwd, relPath) this.isEditing = true + // Store the currently active editor before we start making changes + this.preEditActiveEditor = vscode.window.activeTextEditor + + // Track that Roo is opening this file + this.trackOpenedTab(absolutePath) + // If the file is already open, ensure it's not dirty before getting its // contents. if (fileExists) { @@ -181,7 +214,10 @@ export class DiffViewProvider { } } - async saveChanges(diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS): Promise<{ + async saveChanges( + diagnosticsEnabled: boolean = true, + writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined finalContent: string | undefined @@ -201,6 +237,17 @@ export class DiffViewProvider { await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false, preserveFocus: true }) await this.closeAllDiffViews() + // Apply post-edit behaviors (tab closing) + await PostEditBehaviorUtils.closeRooTabs( + this.autoCloseRooTabs, + this.autoCloseAllRooTabs, + this.rooOpenedTabs, + absolutePath, + ) + + // Restore focus after closing tabs + await PostEditBehaviorUtils.restoreFocus(this.preEditActiveEditor, absolutePath) + // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only // report new problems that are a direct result of this specific edit. @@ -216,22 +263,22 @@ export class DiffViewProvider { // and can address them accordingly. If problems don't change immediately after // applying a fix, won't be notified, which is generally fine since the // initial fix is usually correct and it may just take time for linters to catch up. - + let newProblemsMessage = "" - + if (diagnosticsEnabled) { // Add configurable delay to allow linters time to process and clean up issues // like unused imports (especially important for Go and other languages) // Ensure delay is non-negative const safeDelayMs = Math.max(0, writeDelayMs) - + try { await delay(safeDelayMs) } catch (error) { // Log error but continue - delay failure shouldn't break the save operation console.warn(`Failed to apply write delay: ${error}`) } - + const postDiagnostics = vscode.languages.getDiagnostics() const newProblems = await diagnosticsToProblemsString( @@ -610,5 +657,7 @@ export class DiffViewProvider { this.activeLineController = undefined this.streamedLines = [] this.preDiagnostics = [] + this.clearTrackedTabs() + this.preEditActiveEditor = undefined } } diff --git a/src/integrations/editor/PostEditBehaviorUtils.ts b/src/integrations/editor/PostEditBehaviorUtils.ts new file mode 100644 index 00000000000..50f4549e743 --- /dev/null +++ b/src/integrations/editor/PostEditBehaviorUtils.ts @@ -0,0 +1,146 @@ +import * as vscode from "vscode" +import { arePathsEqual } from "../../utils/path" +import { DIFF_VIEW_URI_SCHEME, DIFF_VIEW_LABEL_CHANGES } from "./DiffViewProvider" + +export class PostEditBehaviorUtils { + /** + * Closes tabs based on the provided settings and tracked tabs + * @param autoCloseRooTabs - Close only tabs that were not open prior to the current task + * @param autoCloseAllRooTabs - Close all Roo tabs regardless of prior state + * @param rooOpenedTabs - Set of file paths that Roo opened during the current task + * @param editedFilePath - The path of the file that was just edited (optional) + * @returns Promise that resolves when all tabs are closed + */ + static async closeRooTabs( + autoCloseRooTabs: boolean, + autoCloseAllRooTabs: boolean, + rooOpenedTabs: Set, + editedFilePath?: string, + ): Promise { + if (!autoCloseRooTabs && !autoCloseAllRooTabs) { + return + } + + const tabsToClose: vscode.Tab[] = [] + + // Iterate through all tab groups and their tabs + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + if (this.shouldCloseTab(tab, autoCloseRooTabs, autoCloseAllRooTabs, rooOpenedTabs, editedFilePath)) { + tabsToClose.push(tab) + } + } + } + + // Close all identified tabs + if (tabsToClose.length > 0) { + await Promise.all( + tabsToClose.map(async (tab) => { + try { + await vscode.window.tabGroups.close(tab) + } catch (err) { + console.error(`Failed to close tab: ${err}`) + } + }), + ) + } + } + + /** + * Determines if a tab should be closed based on the settings and tab properties + */ + private static shouldCloseTab( + tab: vscode.Tab, + autoCloseRooTabs: boolean, + autoCloseAllRooTabs: boolean, + rooOpenedTabs: Set, + editedFilePath?: string, + ): boolean { + // Don't close dirty tabs + if (tab.isDirty) { + return false + } + + // Check if this is a diff view tab + if (tab.input instanceof vscode.TabInputTextDiff) { + // Check if it's a Roo diff view by URI scheme + if (tab.input.original.scheme === DIFF_VIEW_URI_SCHEME) { + return autoCloseAllRooTabs || autoCloseRooTabs + } + // Also check by label for compatibility + if (tab.label.includes(DIFF_VIEW_LABEL_CHANGES)) { + return autoCloseAllRooTabs || autoCloseRooTabs + } + } + + // Check if this is a regular text tab + if (tab.input instanceof vscode.TabInputText) { + const tabPath = tab.input.uri.fsPath + + // Skip the currently edited file to avoid closing it immediately + if (editedFilePath && arePathsEqual(tabPath, editedFilePath)) { + return false + } + + // If autoCloseAllRooTabs is enabled, close any tab that was tracked by Roo + if (autoCloseAllRooTabs) { + // Check if this tab's path exists in our tracked set + for (const trackedPath of rooOpenedTabs) { + if (arePathsEqual(tabPath, trackedPath)) { + return true + } + } + } + + // If only autoCloseRooTabs is enabled, close tabs that Roo opened (not pre-existing) + if (autoCloseRooTabs && !autoCloseAllRooTabs) { + // This requires the tab to be in our tracked set + for (const trackedPath of rooOpenedTabs) { + if (arePathsEqual(tabPath, trackedPath)) { + return true + } + } + } + } + + return false + } + + /** + * Restores focus to the appropriate editor after closing tabs + * @param preEditActiveEditor - The editor that was active before the edit operation + * @param editedFilePath - The path of the file that was edited + */ + static async restoreFocus( + preEditActiveEditor: vscode.TextEditor | undefined, + editedFilePath?: string, + ): Promise { + // Try to restore focus to the pre-edit active editor if it still exists + if (preEditActiveEditor) { + const stillExists = vscode.window.visibleTextEditors.some( + (editor) => editor.document.uri.toString() === preEditActiveEditor.document.uri.toString(), + ) + + if (stillExists) { + await vscode.window.showTextDocument(preEditActiveEditor.document, { + preserveFocus: false, + preview: false, + }) + return + } + } + + // Otherwise, try to focus on the edited file + if (editedFilePath) { + try { + await vscode.window.showTextDocument(vscode.Uri.file(editedFilePath), { + preserveFocus: false, + preview: false, + }) + } catch (err) { + // File might not exist or be accessible + console.debug(`Could not restore focus to edited file: ${err}`) + } + } + } +} diff --git a/src/integrations/editor/__tests__/PostEditBehaviorUtils.spec.ts b/src/integrations/editor/__tests__/PostEditBehaviorUtils.spec.ts new file mode 100644 index 00000000000..f1f2649409c --- /dev/null +++ b/src/integrations/editor/__tests__/PostEditBehaviorUtils.spec.ts @@ -0,0 +1,276 @@ +// npx vitest src/integrations/editor/__tests__/PostEditBehaviorUtils.spec.ts + +import * as vscode from "vscode" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { PostEditBehaviorUtils } from "../PostEditBehaviorUtils" + +// Mock vscode module +vi.mock("vscode", () => ({ + window: { + tabGroups: { + all: [], + }, + showTextDocument: vi.fn(), + createTextEditorDecorationType: vi.fn(() => ({ + dispose: vi.fn(), + })), + visibleTextEditors: [], + }, + ViewColumn: { + One: 1, + }, + Uri: { + file: vi.fn((path: string) => ({ fsPath: path, scheme: "file" })), + }, + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + })), + }, + TabInputTextDiff: class TabInputTextDiff { + constructor( + public original: any, + public modified: any, + ) {} + }, + TabInputText: class TabInputText { + constructor(public uri: any) {} + }, +})) + +describe("PostEditBehaviorUtils", () => { + let mockTabGroups: any[] + let mockShowTextDocument: any + let mockVisibleTextEditors: any[] + + beforeEach(() => { + mockTabGroups = [] + mockShowTextDocument = vi.fn() + mockVisibleTextEditors = [] + + // Reset mocks + vi.mocked(vscode.window).tabGroups = { + all: mockTabGroups, + } as any + vi.mocked(vscode.window).showTextDocument = mockShowTextDocument + vi.mocked(vscode.window).visibleTextEditors = mockVisibleTextEditors + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("closeRooTabs", () => { + it("should not close any tabs when autoCloseRooTabs is false", async () => { + // Arrange + const rooOpenedTabs = new Set(["/path/to/file1.ts", "/path/to/file2.ts"]) + const mockTabGroup = { + tabs: [ + { + input: { uri: { fsPath: "/path/to/file1.ts" } }, + close: vi.fn(), + }, + { + input: { uri: { fsPath: "/path/to/file2.ts" } }, + close: vi.fn(), + }, + ], + } + mockTabGroups.push(mockTabGroup) + + // Act + await PostEditBehaviorUtils.closeRooTabs( + false, // autoCloseRooTabs + false, // autoCloseAllRooTabs + rooOpenedTabs, + undefined, + ) + + // Assert + expect(mockTabGroup.tabs[0].close).not.toHaveBeenCalled() + expect(mockTabGroup.tabs[1].close).not.toHaveBeenCalled() + }) + + it("should close only the edited file tab when autoCloseRooTabs is true and autoCloseAllRooTabs is false", async () => { + // Arrange + const rooOpenedTabs = new Set(["/path/to/file1.ts", "/path/to/file2.ts"]) + const editedFilePath = "/path/to/file1.ts" + const mockTabGroup = { + tabs: [ + { + input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file1.ts")), + close: vi.fn().mockResolvedValue(true), + }, + { + input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file2.ts")), + close: vi.fn().mockResolvedValue(true), + }, + ], + } + mockTabGroups.push(mockTabGroup) + + // Act + await PostEditBehaviorUtils.closeRooTabs( + true, // autoCloseRooTabs + false, // autoCloseAllRooTabs + rooOpenedTabs, + editedFilePath, + ) + + // Assert + expect(mockTabGroup.tabs[0].close).toHaveBeenCalled() + expect(mockTabGroup.tabs[1].close).not.toHaveBeenCalled() + }) + + it("should close all Roo-opened tabs when autoCloseAllRooTabs is true", async () => { + // Arrange + const rooOpenedTabs = new Set(["/path/to/file1.ts", "/path/to/file2.ts"]) + const editedFilePath = "/path/to/file1.ts" + const mockTabGroup = { + tabs: [ + { + input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file1.ts")), + close: vi.fn().mockResolvedValue(true), + }, + { + input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file2.ts")), + close: vi.fn().mockResolvedValue(true), + }, + { + input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file3.ts")), + close: vi.fn().mockResolvedValue(true), + }, + ], + } + mockTabGroups.push(mockTabGroup) + + // Act + await PostEditBehaviorUtils.closeRooTabs( + true, // autoCloseRooTabs + true, // autoCloseAllRooTabs + rooOpenedTabs, + editedFilePath, + ) + + // Assert + expect(mockTabGroup.tabs[0].close).toHaveBeenCalled() + expect(mockTabGroup.tabs[1].close).toHaveBeenCalled() + expect(mockTabGroup.tabs[2].close).not.toHaveBeenCalled() // file3 was not opened by Roo + }) + + it("should not close tabs that were not opened by Roo", async () => { + // Arrange + const rooOpenedTabs = new Set(["/path/to/file1.ts"]) + const editedFilePath = "/path/to/file1.ts" + const mockTabGroup = { + tabs: [ + { + input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file1.ts")), + close: vi.fn().mockResolvedValue(true), + }, + { + input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file2.ts")), // Not in rooOpenedTabs + close: vi.fn().mockResolvedValue(true), + }, + ], + } + mockTabGroups.push(mockTabGroup) + + // Act + await PostEditBehaviorUtils.closeRooTabs( + true, // autoCloseRooTabs + true, // autoCloseAllRooTabs + rooOpenedTabs, + editedFilePath, + ) + + // Assert + expect(mockTabGroup.tabs[0].close).toHaveBeenCalled() + expect(mockTabGroup.tabs[1].close).not.toHaveBeenCalled() + }) + + it("should handle tabs without URI input gracefully", async () => { + // Arrange + const rooOpenedTabs = new Set(["/path/to/file1.ts"]) + const mockTabGroup = { + tabs: [ + { + input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file1.ts")), + close: vi.fn().mockResolvedValue(true), + }, + { + input: {}, // No URI + close: vi.fn().mockResolvedValue(true), + }, + { + input: null, // Null input + close: vi.fn().mockResolvedValue(true), + }, + ], + } + mockTabGroups.push(mockTabGroup) + + // Act + await PostEditBehaviorUtils.closeRooTabs( + true, // autoCloseRooTabs + true, // autoCloseAllRooTabs + rooOpenedTabs, + undefined, + ) + + // Assert + expect(mockTabGroup.tabs[0].close).toHaveBeenCalled() + expect(mockTabGroup.tabs[1].close).not.toHaveBeenCalled() + expect(mockTabGroup.tabs[2].close).not.toHaveBeenCalled() + }) + }) + + describe("restoreFocus", () => { + it("should restore focus to the pre-edit active editor", async () => { + // Arrange + const mockEditor = { + document: { + uri: { + fsPath: "/path/to/original.ts", + toString: () => "file:///path/to/original.ts", + }, + }, + } + mockVisibleTextEditors.push(mockEditor) + + // Act + await PostEditBehaviorUtils.restoreFocus(mockEditor as any) + + // Assert + expect(mockShowTextDocument).toHaveBeenCalledWith(mockEditor.document, { + preview: false, + preserveFocus: false, + }) + }) + + it("should not restore focus when no pre-edit editor is provided", async () => { + // Act + await PostEditBehaviorUtils.restoreFocus(undefined) + + // Assert + expect(mockShowTextDocument).not.toHaveBeenCalled() + }) + + it("should handle errors gracefully when restoring focus", async () => { + // Arrange + const mockEditor = { + document: { + uri: { + fsPath: "/path/to/original.ts", + toString: () => "file:///path/to/original.ts", + }, + }, + } + mockVisibleTextEditors.push(mockEditor) + mockShowTextDocument.mockRejectedValue(new Error("Failed to show document")) + + // Act & Assert - should not throw (errors are caught internally) + await expect(PostEditBehaviorUtils.restoreFocus(mockEditor as any)).resolves.toBeUndefined() + }) + }) +}) diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 1f56829f7b3..2d4e2a6a169 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -107,6 +107,8 @@ export interface WebviewMessage { | "updateMcpTimeout" | "fuzzyMatchThreshold" | "writeDelayMs" + | "autoCloseRooTabs" + | "autoCloseAllRooTabs" | "diagnosticsEnabled" | "enhancePrompt" | "enhancedPrompt" diff --git a/webview-ui/src/components/settings/FileEditingOptions.tsx b/webview-ui/src/components/settings/FileEditingOptions.tsx new file mode 100644 index 00000000000..d50957b5957 --- /dev/null +++ b/webview-ui/src/components/settings/FileEditingOptions.tsx @@ -0,0 +1,63 @@ +import { HTMLAttributes } from "react" +import { FileEdit } from "lucide-react" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" + +import { SetCachedStateField } from "./types" +import { SectionHeader } from "./SectionHeader" +import { Section } from "./Section" + +type FileEditingOptionsProps = HTMLAttributes & { + autoCloseRooTabs?: boolean + autoCloseAllRooTabs?: boolean + setCachedStateField: SetCachedStateField<"autoCloseRooTabs" | "autoCloseAllRooTabs"> +} + +export const FileEditingOptions = ({ + autoCloseRooTabs, + autoCloseAllRooTabs, + setCachedStateField, + ...props +}: FileEditingOptionsProps) => { + const { t } = useAppTranslation() + + return ( +
+ +
+ +
{t("settings:sections.fileEditing")}
+
+
+ +
+
+
+ setCachedStateField("autoCloseRooTabs", e.target.checked)} + data-testid="auto-close-roo-tabs-checkbox"> + {t("settings:fileEditing.autoCloseRooTabs.label")} + +
+ {t("settings:fileEditing.autoCloseRooTabs.description")} +
+
+ +
+ setCachedStateField("autoCloseAllRooTabs", e.target.checked)} + data-testid="auto-close-all-roo-tabs-checkbox"> + {t("settings:fileEditing.autoCloseAllRooTabs.label")} + +
+ {t("settings:fileEditing.autoCloseAllRooTabs.description")} +
+
+
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 517c1c159d8..56f614e3227 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -22,6 +22,7 @@ import { Globe, Info, MessageSquare, + FileEdit, LucideIcon, } from "lucide-react" @@ -65,6 +66,7 @@ import { LanguageSettings } from "./LanguageSettings" import { About } from "./About" import { Section } from "./Section" import PromptsSettings from "./PromptsSettings" +import { FileEditingOptions } from "./FileEditingOptions" import { cn } from "@/lib/utils" export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden" @@ -81,6 +83,7 @@ export interface SettingsViewRef { const sectionNames = [ "providers", "autoApprove", + "fileEditing", "browser", "checkpoints", "notifications", @@ -177,6 +180,8 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowFollowupQuestions, alwaysAllowUpdateTodoList, followupAutoApproveTimeoutMs, + autoCloseRooTabs, + autoCloseAllRooTabs, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -300,6 +305,8 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled }) vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 }) vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs }) + vscode.postMessage({ type: "autoCloseRooTabs", bool: autoCloseRooTabs }) + vscode.postMessage({ type: "autoCloseAllRooTabs", bool: autoCloseAllRooTabs }) vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 }) vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 }) vscode.postMessage({ type: "terminalOutputCharacterLimit", value: terminalOutputCharacterLimit ?? 50000 }) @@ -404,6 +411,7 @@ const SettingsView = forwardRef(({ onDone, t () => [ { id: "providers", icon: Webhook }, { id: "autoApprove", icon: CheckCheck }, + { id: "fileEditing", icon: FileEdit }, { id: "browser", icon: SquareMousePointer }, { id: "checkpoints", icon: GitBranch }, { id: "notifications", icon: Bell }, @@ -623,6 +631,26 @@ const SettingsView = forwardRef(({ onDone, t /> )} + {/* File Editing Section */} + {activeTab === "fileEditing" && ( +
+ +
+ +
{t("settings:sections.fileEditing")}
+
+
+ +
+ +
+
+ )} + {/* Browser Section */} {activeTab === "browser" && ( void + autoCloseRooTabs?: boolean + setAutoCloseRooTabs: (value: boolean) => void + autoCloseAllRooTabs?: boolean + setAutoCloseAllRooTabs: (value: boolean) => void } export const ExtensionStateContext = createContext(undefined) @@ -474,6 +478,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setAlwaysAllowUpdateTodoList: (value) => { setState((prevState) => ({ ...prevState, alwaysAllowUpdateTodoList: value })) }, + autoCloseRooTabs: (state as any).autoCloseRooTabs, + setAutoCloseRooTabs: (value) => { + setState((prevState) => ({ ...prevState, autoCloseRooTabs: value }) as any) + }, + autoCloseAllRooTabs: (state as any).autoCloseAllRooTabs, + setAutoCloseAllRooTabs: (value) => { + setState((prevState) => ({ ...prevState, autoCloseAllRooTabs: value }) as any) + }, } return {children} From d7d9baded49141203b248ae713b23aa6da8c0f8a Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 21 Jul 2025 14:40:08 +0000 Subject: [PATCH 2/2] fix: correct tab closing logic for autoCloseRooTabs setting - Fixed logic to only close the edited file when autoCloseRooTabs is true but autoCloseAllRooTabs is false - Updated tests to properly mock vscode.window.tabGroups.close() method - Added isDirty property to test tab mocks --- src/integrations/editor/DiffViewProvider.ts | 2 +- .../editor/PostEditBehaviorUtils.ts | 60 +++++++++---------- .../__tests__/PostEditBehaviorUtils.spec.ts | 49 +++++++-------- 3 files changed, 56 insertions(+), 55 deletions(-) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index fb3907c3f4b..0a5e5a6f14e 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -473,7 +473,7 @@ export class DiffViewProvider { .map((tab) => vscode.window.tabGroups.close(tab).then( () => undefined, - (err) => { + (err: any) => { console.error(`Failed to close diff tab ${tab.label}`, err) }, ), diff --git a/src/integrations/editor/PostEditBehaviorUtils.ts b/src/integrations/editor/PostEditBehaviorUtils.ts index 50f4549e743..b1ab25d49cd 100644 --- a/src/integrations/editor/PostEditBehaviorUtils.ts +++ b/src/integrations/editor/PostEditBehaviorUtils.ts @@ -77,8 +77,18 @@ export class PostEditBehaviorUtils { if (tab.input instanceof vscode.TabInputText) { const tabPath = tab.input.uri.fsPath - // Skip the currently edited file to avoid closing it immediately - if (editedFilePath && arePathsEqual(tabPath, editedFilePath)) { + // If only autoCloseRooTabs is enabled (not autoCloseAllRooTabs), + // only close the edited file tab + if (autoCloseRooTabs && !autoCloseAllRooTabs) { + // Only close if this is the edited file AND it was opened by Roo + if (editedFilePath && arePathsEqual(tabPath, editedFilePath)) { + // Check if this file was opened by Roo + for (const trackedPath of rooOpenedTabs) { + if (arePathsEqual(tabPath, trackedPath)) { + return true + } + } + } return false } @@ -91,16 +101,6 @@ export class PostEditBehaviorUtils { } } } - - // If only autoCloseRooTabs is enabled, close tabs that Roo opened (not pre-existing) - if (autoCloseRooTabs && !autoCloseAllRooTabs) { - // This requires the tab to be in our tracked set - for (const trackedPath of rooOpenedTabs) { - if (arePathsEqual(tabPath, trackedPath)) { - return true - } - } - } } return false @@ -115,32 +115,32 @@ export class PostEditBehaviorUtils { preEditActiveEditor: vscode.TextEditor | undefined, editedFilePath?: string, ): Promise { - // Try to restore focus to the pre-edit active editor if it still exists - if (preEditActiveEditor) { - const stillExists = vscode.window.visibleTextEditors.some( - (editor) => editor.document.uri.toString() === preEditActiveEditor.document.uri.toString(), - ) + try { + // Try to restore focus to the pre-edit active editor if it still exists + if (preEditActiveEditor) { + const stillExists = vscode.window.visibleTextEditors.some( + (editor) => editor.document.uri.toString() === preEditActiveEditor.document.uri.toString(), + ) - if (stillExists) { - await vscode.window.showTextDocument(preEditActiveEditor.document, { - preserveFocus: false, - preview: false, - }) - return + if (stillExists) { + await vscode.window.showTextDocument(preEditActiveEditor.document, { + preserveFocus: false, + preview: false, + }) + return + } } - } - // Otherwise, try to focus on the edited file - if (editedFilePath) { - try { + // Otherwise, try to focus on the edited file + if (editedFilePath) { await vscode.window.showTextDocument(vscode.Uri.file(editedFilePath), { preserveFocus: false, preview: false, }) - } catch (err) { - // File might not exist or be accessible - console.debug(`Could not restore focus to edited file: ${err}`) } + } catch (err) { + // File might not exist or be accessible + console.debug(`Could not restore focus: ${err}`) } } } diff --git a/src/integrations/editor/__tests__/PostEditBehaviorUtils.spec.ts b/src/integrations/editor/__tests__/PostEditBehaviorUtils.spec.ts index f1f2649409c..cbe50b3c6ef 100644 --- a/src/integrations/editor/__tests__/PostEditBehaviorUtils.spec.ts +++ b/src/integrations/editor/__tests__/PostEditBehaviorUtils.spec.ts @@ -9,6 +9,7 @@ vi.mock("vscode", () => ({ window: { tabGroups: { all: [], + close: vi.fn(), }, showTextDocument: vi.fn(), createTextEditorDecorationType: vi.fn(() => ({ @@ -51,6 +52,7 @@ describe("PostEditBehaviorUtils", () => { // Reset mocks vi.mocked(vscode.window).tabGroups = { all: mockTabGroups, + close: vi.fn().mockResolvedValue(true), } as any vi.mocked(vscode.window).showTextDocument = mockShowTextDocument vi.mocked(vscode.window).visibleTextEditors = mockVisibleTextEditors @@ -68,11 +70,11 @@ describe("PostEditBehaviorUtils", () => { tabs: [ { input: { uri: { fsPath: "/path/to/file1.ts" } }, - close: vi.fn(), + isDirty: false, }, { input: { uri: { fsPath: "/path/to/file2.ts" } }, - close: vi.fn(), + isDirty: false, }, ], } @@ -87,8 +89,7 @@ describe("PostEditBehaviorUtils", () => { ) // Assert - expect(mockTabGroup.tabs[0].close).not.toHaveBeenCalled() - expect(mockTabGroup.tabs[1].close).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window).tabGroups.close).not.toHaveBeenCalled() }) it("should close only the edited file tab when autoCloseRooTabs is true and autoCloseAllRooTabs is false", async () => { @@ -99,11 +100,11 @@ describe("PostEditBehaviorUtils", () => { tabs: [ { input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file1.ts")), - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, { input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file2.ts")), - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, ], } @@ -118,8 +119,8 @@ describe("PostEditBehaviorUtils", () => { ) // Assert - expect(mockTabGroup.tabs[0].close).toHaveBeenCalled() - expect(mockTabGroup.tabs[1].close).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window).tabGroups.close).toHaveBeenCalledWith(mockTabGroup.tabs[0]) + expect(vi.mocked(vscode.window).tabGroups.close).not.toHaveBeenCalledWith(mockTabGroup.tabs[1]) }) it("should close all Roo-opened tabs when autoCloseAllRooTabs is true", async () => { @@ -130,15 +131,15 @@ describe("PostEditBehaviorUtils", () => { tabs: [ { input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file1.ts")), - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, { input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file2.ts")), - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, { input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file3.ts")), - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, ], } @@ -153,9 +154,9 @@ describe("PostEditBehaviorUtils", () => { ) // Assert - expect(mockTabGroup.tabs[0].close).toHaveBeenCalled() - expect(mockTabGroup.tabs[1].close).toHaveBeenCalled() - expect(mockTabGroup.tabs[2].close).not.toHaveBeenCalled() // file3 was not opened by Roo + expect(vi.mocked(vscode.window).tabGroups.close).toHaveBeenCalledWith(mockTabGroup.tabs[0]) + expect(vi.mocked(vscode.window).tabGroups.close).toHaveBeenCalledWith(mockTabGroup.tabs[1]) + expect(vi.mocked(vscode.window).tabGroups.close).not.toHaveBeenCalledWith(mockTabGroup.tabs[2]) // file3 was not opened by Roo }) it("should not close tabs that were not opened by Roo", async () => { @@ -166,11 +167,11 @@ describe("PostEditBehaviorUtils", () => { tabs: [ { input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file1.ts")), - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, { input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file2.ts")), // Not in rooOpenedTabs - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, ], } @@ -185,8 +186,8 @@ describe("PostEditBehaviorUtils", () => { ) // Assert - expect(mockTabGroup.tabs[0].close).toHaveBeenCalled() - expect(mockTabGroup.tabs[1].close).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window).tabGroups.close).toHaveBeenCalledWith(mockTabGroup.tabs[0]) + expect(vi.mocked(vscode.window).tabGroups.close).not.toHaveBeenCalledWith(mockTabGroup.tabs[1]) }) it("should handle tabs without URI input gracefully", async () => { @@ -196,15 +197,15 @@ describe("PostEditBehaviorUtils", () => { tabs: [ { input: new (vi.mocked(vscode).TabInputText)(vscode.Uri.file("/path/to/file1.ts")), - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, { input: {}, // No URI - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, { input: null, // Null input - close: vi.fn().mockResolvedValue(true), + isDirty: false, }, ], } @@ -219,9 +220,9 @@ describe("PostEditBehaviorUtils", () => { ) // Assert - expect(mockTabGroup.tabs[0].close).toHaveBeenCalled() - expect(mockTabGroup.tabs[1].close).not.toHaveBeenCalled() - expect(mockTabGroup.tabs[2].close).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window).tabGroups.close).toHaveBeenCalledWith(mockTabGroup.tabs[0]) + expect(vi.mocked(vscode.window).tabGroups.close).not.toHaveBeenCalledWith(mockTabGroup.tabs[1]) + expect(vi.mocked(vscode.window).tabGroups.close).not.toHaveBeenCalledWith(mockTabGroup.tabs[2]) }) })