diff --git a/.changeset/large-snakes-suffer.md b/.changeset/large-snakes-suffer.md new file mode 100644 index 0000000000..b215b70adc --- /dev/null +++ b/.changeset/large-snakes-suffer.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Added an editable setting to allow users to preserve focus on the tab they are in while roo code is opening new tabs to do what it has to do. diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 6a0dda1cf2..d10376037a 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -84,8 +84,16 @@ export const globalSettingsSchema = z.object({ terminalCompressProgressBar: z.boolean().optional(), rateLimitSeconds: z.number().optional(), + diffEnabled: z.boolean().optional(), + diffViewAutoFocus: z.boolean().optional(), + autoCloseRooTabs: z.boolean().optional(), + autoCloseAllRooTabs: z.boolean().optional(), + fileBasedEditing: z.boolean().optional(), + openTabsInCorrectGroup: z.boolean().optional(), + openTabsAtEndOfList: z.boolean().optional(), fuzzyMatchThreshold: z.number().optional(), + experiments: experimentsSchema.optional(), codebaseIndexModels: codebaseIndexModelsSchema.optional(), @@ -220,6 +228,9 @@ export const EVALS_SETTINGS: RooCodeSettings = { terminalShellIntegrationDisabled: true, diffEnabled: true, + fileBasedEditing: false, + openTabsInCorrectGroup: false, + openTabsAtEndOfList: false, fuzzyMatchThreshold: 1, enableCheckpoints: false, diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index e940ececd1..d4364b4975 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -55,8 +55,6 @@ export type ProviderSettingsEntry = z.infer const baseProviderSettingsSchema = z.object({ includeMaxTokens: z.boolean().optional(), - diffEnabled: z.boolean().optional(), - fuzzyMatchThreshold: z.number().optional(), modelTemperature: z.number().nullish(), rateLimitSeconds: z.number().optional(), diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 32c0135d3b..c1214ff4e1 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -123,12 +123,6 @@ export class ProviderSettingsManager { isDirty = true } - if (!providerProfiles.migrations.diffSettingsMigrated) { - await this.migrateDiffSettings(providerProfiles) - providerProfiles.migrations.diffSettingsMigrated = true - isDirty = true - } - if (!providerProfiles.migrations.openAiHeadersMigrated) { await this.migrateOpenAiHeaders(providerProfiles) providerProfiles.migrations.openAiHeadersMigrated = true @@ -169,41 +163,6 @@ export class ProviderSettingsManager { } } - private async migrateDiffSettings(providerProfiles: ProviderProfiles) { - try { - let diffEnabled: boolean | undefined - let fuzzyMatchThreshold: number | undefined - - try { - diffEnabled = await this.context.globalState.get("diffEnabled") - fuzzyMatchThreshold = await this.context.globalState.get("fuzzyMatchThreshold") - } catch (error) { - console.error("[MigrateDiffSettings] Error getting global diff settings:", error) - } - - if (diffEnabled === undefined) { - // Failed to get the existing value, use the default. - diffEnabled = true - } - - if (fuzzyMatchThreshold === undefined) { - // Failed to get the existing value, use the default. - fuzzyMatchThreshold = 1.0 - } - - for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { - if (apiConfig.diffEnabled === undefined) { - apiConfig.diffEnabled = diffEnabled - } - if (apiConfig.fuzzyMatchThreshold === undefined) { - apiConfig.fuzzyMatchThreshold = fuzzyMatchThreshold - } - } - } catch (error) { - console.error(`[MigrateDiffSettings] Failed to migrate diff settings:`, error) - } - } - private async migrateOpenAiHeaders(providerProfiles: ProviderProfiles) { try { for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4f0d32c8c1..f560eec8e3 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -48,7 +48,8 @@ import { McpServerManager } from "../../services/mcp/McpServerManager" import { RepoPerTaskCheckpointService } from "../../services/checkpoints" // integrations -import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider" +import { IEditingProvider } from "../../integrations/editor/IEditingProvider" +import { EditingProviderFactory } from "../../integrations/editor/EditingProviderFactory" import { findToolName, formatContentBlockToMarkdown } from "../../integrations/misc/export-markdown" import { RooTerminalProcess } from "../../integrations/terminal/types" import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" @@ -165,7 +166,7 @@ export class Task extends EventEmitter { browserSession: BrowserSession // Editing - diffViewProvider: DiffViewProvider + editingProvider: IEditingProvider diffStrategy?: DiffStrategy diffEnabled: boolean = false fuzzyMatchThreshold: number @@ -253,7 +254,7 @@ export class Task extends EventEmitter { this.consecutiveMistakeLimit = consecutiveMistakeLimit this.providerRef = new WeakRef(provider) this.globalStoragePath = provider.context.globalStorageUri.fsPath - this.diffViewProvider = new DiffViewProvider(this.cwd) + this.editingProvider = EditingProviderFactory.createEditingProvider(this.cwd) this.enableCheckpoints = enableCheckpoints this.rootTask = rootTask @@ -754,6 +755,7 @@ export class Task extends EventEmitter { public async resumePausedTask(lastMessage: string) { // Release this Cline instance from paused state. + this.editingProvider = EditingProviderFactory.createEditingProvider(this.cwd) this.isPaused = false this.emit("taskUnpaused") @@ -1058,8 +1060,8 @@ export class Task extends EventEmitter { try { // If we're not streaming then `abortStream` won't be called - if (this.isStreaming && this.diffViewProvider.isEditing) { - this.diffViewProvider.revertChanges().catch(console.error) + if (this.isStreaming && this.editingProvider.isEditing) { + this.editingProvider.revertChanges().catch(console.error) } } catch (error) { console.error("Error reverting diff changes:", error) @@ -1113,6 +1115,9 @@ export class Task extends EventEmitter { private async initiateTaskLoop(userContent: Anthropic.Messages.ContentBlockParam[]): Promise { // Kicks off the checkpoints initialization process in the background. getCheckpointService(this) + // Lets track if the user is interacting with the editor after we start our task loop. + this.editingProvider.initialize() + this.editingProvider.disableAutoFocusAfterUserInteraction?.() let nextUserContent = userContent let includeFileDetails = true @@ -1153,6 +1158,8 @@ export class Task extends EventEmitter { throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`) } + this.editingProvider = EditingProviderFactory.resetAndCreateNewEditingProvider(this.cwd, this.editingProvider) + if (this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) { const { response, text, images } = await this.ask( "mistake_limit_reached", @@ -1280,8 +1287,8 @@ export class Task extends EventEmitter { } const abortStream = async (cancelReason: ClineApiReqCancelReason, streamingFailedMessage?: string) => { - if (this.diffViewProvider.isEditing) { - await this.diffViewProvider.revertChanges() // closes diff view + if (this.editingProvider.isEditing) { + await this.editingProvider.revertChanges() // closes diff view } // if last message is a partial we need to update and save it @@ -1333,7 +1340,7 @@ export class Task extends EventEmitter { this.presentAssistantMessageLocked = false this.presentAssistantMessageHasPendingUpdates = false - await this.diffViewProvider.reset() + await this.editingProvider.reset() // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate @@ -1716,7 +1723,9 @@ export class Task extends EventEmitter { const contextWindow = modelInfo.contextWindow - const currentProfileId = state?.listApiConfigMeta.find((profile) => profile.name === state?.currentApiConfigName)?.id ?? "default"; + const currentProfileId = + state?.listApiConfigMeta.find((profile) => profile.name === state?.currentApiConfigName)?.id ?? + "default" const truncateResult = await truncateConversationIfNeeded({ messages: this.apiConversationHistory, diff --git a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index e763125d4a..16cc3b08e5 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -34,7 +34,7 @@ describe("applyDiffTool experiment routing", () => { applyDiff: vi.fn(), getProgressStatus: vi.fn(), }, - diffViewProvider: { + editingProvider: { reset: vi.fn(), }, api: { diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index f223d4b0fc..484ca2e75f 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -92,6 +92,8 @@ vi.mock("../../ignore/RooIgnoreController", () => ({ }, })) +const MOCK_VIEW_COLUMN = 1 + describe("writeToFileTool", () => { // Test data const testFilePath = "test/file.txt" @@ -112,7 +114,6 @@ describe("writeToFileTool", () => { const mockCline: any = {} let mockAskApproval: ReturnType let mockHandleError: ReturnType - let mockPushToolResult: ReturnType let mockRemoveClosingTag: ReturnType let toolResult: ToolResponse | undefined @@ -135,7 +136,7 @@ describe("writeToFileTool", () => { mockCline.rooIgnoreController = { validateAccess: vi.fn().mockReturnValue(true), } - mockCline.diffViewProvider = { + mockCline.editingProvider = { editType: undefined, isEditing: false, originalContent: "", @@ -168,6 +169,7 @@ describe("writeToFileTool", () => { } return "Tool result message" }), + resetWithListeners: vi.fn().mockResolvedValue(undefined), } mockCline.api = { getModel: vi.fn().mockReturnValue({ id: "claude-3" }), @@ -179,6 +181,11 @@ describe("writeToFileTool", () => { mockCline.ask = vi.fn().mockResolvedValue(undefined) mockCline.recordToolError = vi.fn() mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") + mockCline.providerRef = { + deref: vi.fn().mockReturnValue({ + getViewColumn: vi.fn().mockReturnValue(MOCK_VIEW_COLUMN), + }), + } mockAskApproval = vi.fn().mockResolvedValue(true) mockHandleError = vi.fn().mockResolvedValue(undefined) @@ -238,7 +245,7 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { accessAllowed: true }) expect(mockCline.rooIgnoreController.validateAccess).toHaveBeenCalledWith(testFilePath) - expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath) + expect(mockCline.editingProvider.open).toHaveBeenCalledWith(testFilePath, MOCK_VIEW_COLUMN) }) }) @@ -247,18 +254,18 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { fileExists: true }) expect(mockedFileExistsAtPath).toHaveBeenCalledWith(absoluteFilePath) - expect(mockCline.diffViewProvider.editType).toBe("modify") + expect(mockCline.editingProvider.editType).toBe("modify") }) it.skipIf(process.platform === "win32")("detects new file and sets editType to create", async () => { await executeWriteFileTool({}, { fileExists: false }) expect(mockedFileExistsAtPath).toHaveBeenCalledWith(absoluteFilePath) - expect(mockCline.diffViewProvider.editType).toBe("create") + expect(mockCline.editingProvider.editType).toBe("create") }) it("uses cached editType without filesystem check", async () => { - mockCline.diffViewProvider.editType = "modify" + mockCline.editingProvider.editType = "modify" await executeWriteFileTool({}) @@ -270,13 +277,13 @@ describe("writeToFileTool", () => { it("removes markdown code block markers from content", async () => { await executeWriteFileTool({ content: testContentWithMarkdown }) - expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith("Line 1\nLine 2", true) + expect(mockCline.editingProvider.update).toHaveBeenCalledWith("Line 1\nLine 2", true) }) it("passes through empty content unchanged", async () => { await executeWriteFileTool({ content: "" }) - expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith("", true) + expect(mockCline.editingProvider.update).toHaveBeenCalledWith("", true) }) it("unescapes HTML entities for non-Claude models", async () => { @@ -304,7 +311,7 @@ describe("writeToFileTool", () => { expect(mockedEveryLineHasLineNumbers).toHaveBeenCalledWith(contentWithLineNumbers) expect(mockedStripLineNumbers).toHaveBeenCalledWith(contentWithLineNumbers) - expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith("line one\nline two", true) + expect(mockCline.editingProvider.update).toHaveBeenCalledWith("line one\nline two", true) }) }) @@ -313,10 +320,10 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}, { fileExists: false }) expect(mockCline.consecutiveMistakeCount).toBe(0) - expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath) - expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, true) + expect(mockCline.editingProvider.open).toHaveBeenCalledWith(testFilePath, MOCK_VIEW_COLUMN) + expect(mockCline.editingProvider.update).toHaveBeenCalledWith(testContent, true) expect(mockAskApproval).toHaveBeenCalled() - expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockCline.editingProvider.saveChanges).toHaveBeenCalled() expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") expect(mockCline.didEditFile).toBe(true) }) @@ -341,21 +348,21 @@ describe("writeToFileTool", () => { it("returns early when path is missing in partial block", async () => { await executeWriteFileTool({ path: undefined }, { isPartial: true }) - expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.editingProvider.open).not.toHaveBeenCalled() }) it("returns early when content is undefined in partial block", async () => { await executeWriteFileTool({ content: undefined }, { isPartial: true }) - expect(mockCline.diffViewProvider.open).not.toHaveBeenCalled() + expect(mockCline.editingProvider.open).not.toHaveBeenCalled() }) it("streams content updates during partial execution", async () => { await executeWriteFileTool({}, { isPartial: true }) expect(mockCline.ask).toHaveBeenCalled() - expect(mockCline.diffViewProvider.open).toHaveBeenCalledWith(testFilePath) - expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, false) + expect(mockCline.editingProvider.open).toHaveBeenCalledWith(testFilePath, MOCK_VIEW_COLUMN) + expect(mockCline.editingProvider.update).toHaveBeenCalledWith(testContent, false) }) }) @@ -365,19 +372,19 @@ describe("writeToFileTool", () => { await executeWriteFileTool({}) - expect(mockCline.diffViewProvider.revertChanges).toHaveBeenCalled() - expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.editingProvider.revertChanges).toHaveBeenCalled() + expect(mockCline.editingProvider.saveChanges).not.toHaveBeenCalled() }) it("reports user edits with diff feedback", async () => { const userEditsValue = "- old line\n+ new line" - mockCline.diffViewProvider.saveChanges.mockResolvedValue({ + mockCline.editingProvider.saveChanges.mockResolvedValue({ newProblemsMessage: " with warnings", userEdits: userEditsValue, finalContent: "modified content", }) // Manually set the property on the mock instance because the original saveChanges is not called - mockCline.diffViewProvider.userEdits = userEditsValue + mockCline.editingProvider.userEdits = userEditsValue await executeWriteFileTool({}, { fileExists: true }) @@ -390,21 +397,21 @@ describe("writeToFileTool", () => { describe("error handling", () => { it("handles general file operation errors", async () => { - mockCline.diffViewProvider.open.mockRejectedValue(new Error("General error")) + mockCline.editingProvider.open.mockRejectedValue(new Error("General error")) await executeWriteFileTool({}) expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) - expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.editingProvider.resetWithListeners).toHaveBeenCalled() }) it("handles partial streaming errors", async () => { - mockCline.diffViewProvider.open.mockRejectedValue(new Error("Open failed")) + mockCline.editingProvider.open.mockRejectedValue(new Error("Open failed")) await executeWriteFileTool({}, { isPartial: true }) expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) - expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.editingProvider.resetWithListeners).toHaveBeenCalled() }) }) }) diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index f5b4ab7dd3..4cadb55895 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -11,6 +11,7 @@ import { formatResponse } from "../prompts/responses" import { fileExistsAtPath } from "../../utils/fs" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { unescapeHtmlEntities } from "../../utils/text-normalization" +import { ViewColumn } from "vscode" export async function applyDiffToolLegacy( cline: Task, @@ -142,10 +143,12 @@ export async function applyDiffToolLegacy( cline.consecutiveMistakeCountForApplyDiff.delete(relPath) // Show diff view before asking for approval - cline.diffViewProvider.editType = "modify" - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(diffResult.content, true) - cline.diffViewProvider.scrollToFirstDiff() + cline.editingProvider.editType = "modify" + const clineRef = cline.providerRef.deref() + const viewColumn = clineRef?.getViewColumn() ?? ViewColumn.Active + await cline.editingProvider.open(relPath, viewColumn) + await cline.editingProvider.update(diffResult.content, true) + cline.editingProvider.scrollToFirstDiff() // Check if file is write-protected const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false @@ -165,12 +168,12 @@ export async function applyDiffToolLegacy( const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) if (!didApprove) { - await cline.diffViewProvider.revertChanges() // Cline likely handles closing the diff view + await cline.editingProvider.revertChanges() // Cline likely handles closing the diff view return } // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges() + await cline.editingProvider.saveChanges() // Track file edit operation if (relPath) { @@ -186,7 +189,7 @@ export async function applyDiffToolLegacy( } // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + const message = await cline.editingProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) if (partFailHint) { pushToolResult(partFailHint + message) @@ -194,13 +197,13 @@ export async function applyDiffToolLegacy( pushToolResult(message) } - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() return } } catch (error) { await handleError("applying diff", error) - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() return } } diff --git a/src/core/tools/insertContentTool.ts b/src/core/tools/insertContentTool.ts index af8d91713f..682244f314 100644 --- a/src/core/tools/insertContentTool.ts +++ b/src/core/tools/insertContentTool.ts @@ -10,6 +10,7 @@ import { ClineSayTool } from "../../shared/ExtensionMessage" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { insertGroups } from "../diff/insert-groups" +import { ViewColumn } from "vscode" export async function insertContentTool( cline: Task, @@ -93,8 +94,8 @@ export async function insertContentTool( // Read the file const fileContent = await fs.readFile(absolutePath, "utf8") - cline.diffViewProvider.editType = "modify" - cline.diffViewProvider.originalContent = fileContent + cline.editingProvider.editType = "modify" + cline.editingProvider.originalContent = fileContent const lines = fileContent.split("\n") const updatedContent = insertGroups(lines, [ @@ -105,12 +106,14 @@ export async function insertContentTool( ]).join("\n") // Show changes in diff view - if (!cline.diffViewProvider.isEditing) { + if (!cline.editingProvider.isEditing) { await cline.ask("tool", JSON.stringify(sharedMessageProps), true).catch(() => {}) // First open with original content - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(fileContent, false) - cline.diffViewProvider.scrollToFirstDiff() + const clineRef = cline.providerRef.deref() + const viewColumn = clineRef?.getViewColumn() ?? ViewColumn.Active + await cline.editingProvider.open(relPath, viewColumn) + await cline.editingProvider.update(fileContent, false) + cline.editingProvider.scrollToFirstDiff() await delay(200) } @@ -121,7 +124,7 @@ export async function insertContentTool( return } - await cline.diffViewProvider.update(updatedContent, true) + await cline.editingProvider.update(updatedContent, true) const completeMessage = JSON.stringify({ ...sharedMessageProps, @@ -135,13 +138,13 @@ export async function insertContentTool( .then((response) => response.response === "yesButtonClicked") if (!didApprove) { - await cline.diffViewProvider.revertChanges() + await cline.editingProvider.revertChanges() pushToolResult("Changes were rejected by the user.") return } // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges() + await cline.editingProvider.saveChanges() // Track file edit operation if (relPath) { @@ -151,7 +154,7 @@ export async function insertContentTool( cline.didEditFile = true // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult( + const message = await cline.editingProvider.pushToolWriteResult( cline, cline.cwd, false, // Always false for insert_content @@ -159,9 +162,9 @@ export async function insertContentTool( pushToolResult(message) - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() } catch (error) { handleError("insert content", error) - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() } } diff --git a/src/core/tools/multiApplyDiffTool.ts b/src/core/tools/multiApplyDiffTool.ts index 8057f77949..a2843621be 100644 --- a/src/core/tools/multiApplyDiffTool.ts +++ b/src/core/tools/multiApplyDiffTool.ts @@ -507,10 +507,10 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} cline.consecutiveMistakeCountForApplyDiff.delete(relPath) // Show diff view before asking for approval (only for single file or after batch approval) - cline.diffViewProvider.editType = "modify" - await cline.diffViewProvider.open(relPath) - await cline.diffViewProvider.update(originalContent!, true) - cline.diffViewProvider.scrollToFirstDiff() + cline.editingProvider.editType = "modify" + await cline.editingProvider.open(relPath) + await cline.editingProvider.update(originalContent!, true) + cline.editingProvider.scrollToFirstDiff() // For batch operations, we've already gotten approval const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false @@ -547,13 +547,13 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} } if (!didApprove) { - await cline.diffViewProvider.revertChanges() + await cline.editingProvider.revertChanges() results.push(`Changes to ${relPath} were not approved by user`) continue } // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges() + await cline.editingProvider.saveChanges() // Track file edit operation await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) @@ -567,7 +567,7 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} } // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + const message = await cline.editingProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) if (partFailHint) { results.push(partFailHint + "\n" + message) @@ -575,7 +575,7 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} results.push(message) } - await cline.diffViewProvider.reset() + await cline.editingProvider.reset() } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) updateOperationResult(relPath, { @@ -601,7 +601,7 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""} return } catch (error) { await handleError("applying diff", error) - await cline.diffViewProvider.reset() + await cline.editingProvider.reset() return } } diff --git a/src/core/tools/searchAndReplaceTool.ts b/src/core/tools/searchAndReplaceTool.ts index 967d5339ba..5ece65e0c3 100644 --- a/src/core/tools/searchAndReplaceTool.ts +++ b/src/core/tools/searchAndReplaceTool.ts @@ -11,6 +11,7 @@ import { ClineSayTool } from "../../shared/ExtensionMessage" import { getReadablePath } from "../../utils/path" import { fileExistsAtPath } from "../../utils/fs" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" +import { ViewColumn } from "vscode" /** * Tool for performing search and replace operations on files @@ -187,27 +188,29 @@ export async function searchAndReplaceTool( } // Initialize diff view - cline.diffViewProvider.editType = "modify" - cline.diffViewProvider.originalContent = fileContent + cline.editingProvider.editType = "modify" + cline.editingProvider.originalContent = fileContent // Generate and validate diff const diff = formatResponse.createPrettyPatch(validRelPath, fileContent, newContent) if (!diff) { pushToolResult(`No changes needed for '${relPath}'`) - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() return } // Show changes in diff view - if (!cline.diffViewProvider.isEditing) { + if (!cline.editingProvider.isEditing) { await cline.ask("tool", JSON.stringify(sharedMessageProps), true).catch(() => {}) - await cline.diffViewProvider.open(validRelPath) - await cline.diffViewProvider.update(fileContent, false) - cline.diffViewProvider.scrollToFirstDiff() + const clineRef = cline.providerRef.deref() + const viewColumn = clineRef?.getViewColumn() ?? ViewColumn.Active + await cline.editingProvider.open(validRelPath, viewColumn) + await cline.editingProvider.update(fileContent, false) + cline.editingProvider.scrollToFirstDiff() await delay(200) } - await cline.diffViewProvider.update(newContent, true) + await cline.editingProvider.update(newContent, true) // Request user approval for changes const completeMessage = JSON.stringify({ @@ -220,14 +223,14 @@ export async function searchAndReplaceTool( .then((response) => response.response === "yesButtonClicked") if (!didApprove) { - await cline.diffViewProvider.revertChanges() + await cline.editingProvider.revertChanges() pushToolResult("Changes were rejected by the user.") - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() return } // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges() + await cline.editingProvider.saveChanges() // Track file edit operation if (relPath) { @@ -237,7 +240,7 @@ export async function searchAndReplaceTool( cline.didEditFile = true // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult( + const message = await cline.editingProvider.pushToolWriteResult( cline, cline.cwd, false, // Always false for search_and_replace @@ -247,10 +250,10 @@ export async function searchAndReplaceTool( // Record successful tool usage and cleanup cline.recordToolUsage("search_and_replace") - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() } catch (error) { handleError("search and replace", error) - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() } } diff --git a/src/core/tools/writeToFileTool.ts b/src/core/tools/writeToFileTool.ts index 84f8ef807e..a9718d61d2 100644 --- a/src/core/tools/writeToFileTool.ts +++ b/src/core/tools/writeToFileTool.ts @@ -36,7 +36,7 @@ export async function writeToFileTool( cline.consecutiveMistakeCount++ cline.recordToolError("write_to_file") pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "path")) - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() return } @@ -44,7 +44,7 @@ export async function writeToFileTool( cline.consecutiveMistakeCount++ cline.recordToolError("write_to_file") pushToolResult(await cline.sayAndCreateMissingParamError("write_to_file", "content")) - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() return } @@ -62,12 +62,12 @@ export async function writeToFileTool( // Check if file exists using cached map or fs.access let fileExists: boolean - if (cline.diffViewProvider.editType !== undefined) { - fileExists = cline.diffViewProvider.editType === "modify" + if (cline.editingProvider.editType !== undefined) { + fileExists = cline.editingProvider.editType === "modify" } else { const absolutePath = path.resolve(cline.cwd, relPath) fileExists = await fileExistsAtPath(absolutePath) - cline.diffViewProvider.editType = fileExists ? "modify" : "create" + cline.editingProvider.editType = fileExists ? "modify" : "create" } // pre-processing newContent for cases where weaker models might add artifacts like markdown codeblock markers (deepseek/llama) or extra escape characters (gemini) @@ -103,13 +103,15 @@ export async function writeToFileTool( await cline.ask("tool", partialMessage, block.partial).catch(() => {}) // update editor - if (!cline.diffViewProvider.isEditing) { + if (!cline.editingProvider.isEditing) { // open the editor and prepare to stream content in - await cline.diffViewProvider.open(relPath) + const clineRef = cline.providerRef.deref() + const viewColumn = clineRef?.getViewColumn() ?? vscode.ViewColumn.Active + await cline.editingProvider.open(relPath, viewColumn) } // editor is open, stream content in - await cline.diffViewProvider.update( + await cline.editingProvider.update( everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, false, ) @@ -142,7 +144,7 @@ export async function writeToFileTool( formatResponse.lineCountTruncationError(actualLineCount, isNewFile, diffStrategyEnabled), ), ) - await cline.diffViewProvider.revertChanges() + await cline.editingProvider.revertChanges() return } @@ -151,25 +153,27 @@ export async function writeToFileTool( // if isEditingFile false, that means we have the full contents of the file already. // it's important to note how cline function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So cline part of the logic will always be called. // in other words, you must always repeat the block.partial logic here - if (!cline.diffViewProvider.isEditing) { + if (!cline.editingProvider.isEditing) { // show gui message before showing edit animation const partialMessage = JSON.stringify(sharedMessageProps) await cline.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, cline shows the edit row before the content is streamed into the editor - await cline.diffViewProvider.open(relPath) + const clineRef = cline.providerRef.deref() + const viewColumn = clineRef?.getViewColumn() ?? vscode.ViewColumn.Active + await cline.editingProvider.open(relPath, viewColumn) } - await cline.diffViewProvider.update( + await cline.editingProvider.update( everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent, true, ) await delay(300) // wait for diff view to update - cline.diffViewProvider.scrollToFirstDiff() + cline.editingProvider.scrollToFirstDiff() // Check for code omissions before proceeding - if (detectCodeOmission(cline.diffViewProvider.originalContent || "", newContent, predictedLineCount)) { + if (detectCodeOmission(cline.editingProvider.originalContent || "", newContent, predictedLineCount)) { if (cline.diffStrategy) { - await cline.diffViewProvider.revertChanges() + await cline.editingProvider.revertChanges() pushToolResult( formatResponse.toolError( @@ -201,19 +205,19 @@ export async function writeToFileTool( ...sharedMessageProps, content: fileExists ? undefined : newContent, diff: fileExists - ? formatResponse.createPrettyPatch(relPath, cline.diffViewProvider.originalContent, newContent) + ? formatResponse.createPrettyPatch(relPath, cline.editingProvider.originalContent, newContent) : undefined, } satisfies ClineSayTool) const didApprove = await askApproval("tool", completeMessage, undefined, isWriteProtected) if (!didApprove) { - await cline.diffViewProvider.revertChanges() + await cline.editingProvider.revertChanges() return } // Call saveChanges to update the DiffViewProvider properties - await cline.diffViewProvider.saveChanges() + await cline.editingProvider.saveChanges() // Track file edit operation if (relPath) { @@ -223,17 +227,17 @@ export async function writeToFileTool( cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request // Get the formatted response message - const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + const message = await cline.editingProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) pushToolResult(message) - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() return } } catch (error) { await handleError("writing file", error) - await cline.diffViewProvider.reset() + await cline.editingProvider.resetWithListeners() return } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d8aca8fbcb..fdd4a0501a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1354,6 +1354,12 @@ export class ClineProvider ttsEnabled, ttsSpeed, diffEnabled, + diffViewAutoFocus, + autoCloseRooTabs, + autoCloseAllRooTabs, + fileBasedEditing, + openTabsInCorrectGroup, + openTabsAtEndOfList, enableCheckpoints, taskHistory, soundVolume, @@ -1448,6 +1454,12 @@ export class ClineProvider ttsEnabled: ttsEnabled ?? false, ttsSpeed: ttsSpeed ?? 1.0, diffEnabled: diffEnabled ?? true, + diffViewAutoFocus: diffViewAutoFocus ?? true, + autoCloseRooTabs: autoCloseRooTabs ?? false, + autoCloseAllRooTabs: autoCloseAllRooTabs ?? false, + fileBasedEditing, + openTabsInCorrectGroup, + openTabsAtEndOfList, enableCheckpoints: enableCheckpoints ?? true, shouldShowAnnouncement: telemetrySetting !== "unset" && lastShownAnnouncementId !== this.latestAnnouncementId, @@ -1611,6 +1623,12 @@ export class ClineProvider ttsEnabled: stateValues.ttsEnabled ?? false, ttsSpeed: stateValues.ttsSpeed ?? 1.0, diffEnabled: stateValues.diffEnabled ?? true, + diffViewAutoFocus: stateValues.diffViewAutoFocus ?? false, + autoCloseRooTabs: stateValues.autoCloseRooTabs ?? false, + autoCloseAllRooTabs: stateValues.autoCloseAllRooTabs ?? false, + fileBasedEditing: stateValues.fileBasedEditing ?? false, + openTabsInCorrectGroup: stateValues.openTabsInCorrectGroup ?? false, + openTabsAtEndOfList: stateValues.openTabsAtEndOfList ?? false, enableCheckpoints: stateValues.enableCheckpoints ?? true, soundVolume: stateValues.soundVolume, browserViewportSize: stateValues.browserViewportSize ?? "900x600", @@ -1827,4 +1845,28 @@ export class ClineProvider ...gitInfo, } } + + // add getter for view + public getViewColumn(): vscode.ViewColumn { + // we can only check tabgroups, as there is no official api to check if there are multiple torn windows + const multipleWindows = vscode.window.tabGroups.all.length > 1 + // if there's only one active window, use vscodes native ability to chose the view column etc without losing focus + if (!multipleWindows) { + // If there are no other windows, return the active view column + return vscode.ViewColumn.Active + } + // If there are multiple windows, we need to check if the view is a WebviewPanel + const isViewPanel = this.view?.viewType === ClineProvider.tabPanelId + if (!isViewPanel) { + // If the view is not a WebviewPanel, return 1. 1 is the default view column of the editor. + // Non default values can only be found in WebviewPanel. + return vscode.ViewColumn.One + } + // If the view is a WebviewPanel, return its viewColumn. + // This property is only set if the webview is in one of the editor view columns. + // Therefore, we can safely return it or default to beside. + const viewColumn = (this.view as vscode.WebviewPanel).viewColumn + // If the view is not a WebviewPanel, return the default view column, which is 1. This + return viewColumn ?? vscode.ViewColumn.Active + } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0ec14ca27e..ef68282de6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -58,6 +58,35 @@ export const webviewMessageHandler = async ( await provider.contextProxy.setValue(key, value) switch (message.type) { + case "diffViewAutoFocus": + const diffViewAutoFocus = message.bool ?? true + await provider.context.globalState.update("diffViewAutoFocus", diffViewAutoFocus) + // Also update workspace settings + const currentConfig = vscode.workspace.getConfiguration("roo-cline") + await currentConfig.update("diffViewAutoFocus", diffViewAutoFocus, vscode.ConfigurationTarget.Global) + await updateGlobalState("diffViewAutoFocus", diffViewAutoFocus) + await provider.postStateToWebview() + break + case "autoCloseRooTabs": + const autoCloseRooTabs = message.bool ?? false + await provider.context.globalState.update("autoCloseRooTabs", autoCloseRooTabs) + // Also update workspace settings + await vscode.workspace + .getConfiguration("roo-cline") + .update("autoCloseRooTabs", autoCloseRooTabs, vscode.ConfigurationTarget.Global) + await updateGlobalState("autoCloseRooTabs", autoCloseRooTabs) + await provider.postStateToWebview() + break + case "autoCloseAllRooTabs": + const autoCloseAllRooTabs = message.bool ?? false + await provider.context.globalState.update("autoCloseAllRooTabs", autoCloseAllRooTabs) + // Also update workspace settings + await vscode.workspace + .getConfiguration("roo-cline") + .update("autoCloseAllRooTabs", autoCloseAllRooTabs, vscode.ConfigurationTarget.Global) + await updateGlobalState("autoCloseAllRooTabs", autoCloseAllRooTabs) + await provider.postStateToWebview() + break case "webviewDidLaunch": // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() @@ -753,9 +782,44 @@ export const webviewMessageHandler = async ( break case "diffEnabled": const diffEnabled = message.bool ?? true + await provider.context.globalState.update("diffEnabled", diffEnabled) + // Also update workspace settings + await vscode.workspace + .getConfiguration("roo-cline") + .update("diffEnabled", diffEnabled, vscode.ConfigurationTarget.Global) await updateGlobalState("diffEnabled", diffEnabled) await provider.postStateToWebview() break + case "fileBasedEditing": + const fileBasedEditing = message.bool ?? false + await provider.context.globalState.update("fileBasedEditing", fileBasedEditing) + // Also update workspace settings + await vscode.workspace + .getConfiguration("roo-cline") + .update("fileBasedEditing", fileBasedEditing, vscode.ConfigurationTarget.Global) + await updateGlobalState("fileBasedEditing", fileBasedEditing) + await provider.postStateToWebview() + break + case "openTabsInCorrectGroup": + const openTabsInCorrectGroup = message.bool ?? false + await provider.context.globalState.update("openTabsInCorrectGroup", openTabsInCorrectGroup) + // Also update workspace settings + await vscode.workspace + .getConfiguration("roo-cline") + .update("openTabsInCorrectGroup", openTabsInCorrectGroup, vscode.ConfigurationTarget.Global) + await updateGlobalState("openTabsInCorrectGroup", openTabsInCorrectGroup) + await provider.postStateToWebview() + break + case "openTabsAtEndOfList": + const openTabsAtEndOfList = message.bool ?? false + await provider.context.globalState.update("openTabsAtEndOfList", openTabsAtEndOfList) + // Also update workspace settings + await vscode.workspace + .getConfiguration("roo-cline") + .update("openTabsAtEndOfList", openTabsAtEndOfList, vscode.ConfigurationTarget.Global) + await updateGlobalState("openTabsAtEndOfList", openTabsAtEndOfList) + await provider.postStateToWebview() + break case "enableCheckpoints": const enableCheckpoints = message.bool ?? true await updateGlobalState("enableCheckpoints", enableCheckpoints) diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 225e076297..ab54b29c41 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode" +import { TextDocument, TextDocumentShowOptions, ViewColumn } from "vscode" import * as path from "path" import * as fs from "fs/promises" import * as diff from "diff" @@ -13,12 +14,24 @@ import { ClineSayTool } from "../../shared/ExtensionMessage" import { Task } from "../../core/task/Task" import { DecorationController } from "./DecorationController" +import { ClineProvider } from "../../core/webview/ClineProvider" +import { UserInteractionProvider } from "./UserInteractionProvider" +import { PostDiffViewBehaviorUtils } from "./PostDiffViewBehaviorUtils" +import { IEditingProvider } from "./IEditingProvider" export const DIFF_VIEW_URI_SCHEME = "cline-diff" export const DIFF_VIEW_LABEL_CHANGES = "Original ↔ Roo's Changes" +interface DiffSettings { + autoFocus: boolean + autoCloseRooTabs: boolean + autoCloseAllRooTabs: boolean + openTabsAtEndOfList: boolean + openTabsInCorrectGroup: boolean +} + // TODO: https://github.com/cline/cline/pull/3354 -export class DiffViewProvider { +export class DiffViewProvider implements IEditingProvider { // Properties to store the results of saveChanges newProblemsMessage?: string userEdits?: string @@ -34,15 +47,156 @@ export class DiffViewProvider { private activeLineController?: DecorationController private streamedLines: string[] = [] private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [] + private rooOpenedTabs: Set = new Set() + private autoApproval: boolean | undefined = undefined + private autoFocus: boolean | undefined = undefined + private autoCloseAllRooTabs: boolean = false // Added new setting + // have to set the default view column to -1 since we need to set it in the initialize method and during initialization the enum ViewColumn is undefined + private viewColumn: ViewColumn | undefined = -1 // ViewColumn.Active + private userInteractionProvider: UserInteractionProvider + private suppressInteractionFlag: boolean = false + private preDiffActiveEditor?: vscode.TextEditor // Store active editor before diff operation + private postDiffBehaviorUtils: PostDiffViewBehaviorUtils + + constructor(private cwd: string) { + this.userInteractionProvider = new UserInteractionProvider({ + onUserInteraction: () => { + this.autoFocus = false + }, + getSuppressFlag: () => this.suppressInteractionFlag, + autoApproval: false, + autoFocus: true, + }) + + // Initialize PostDiffviewBehaviorUtils with initial context + this.postDiffBehaviorUtils = new PostDiffViewBehaviorUtils({ + relPath: this.relPath, + editType: this.editType, + documentWasOpen: this.documentWasOpen, + cwd: this.cwd, + rooOpenedTabs: this.rooOpenedTabs, + preDiffActiveEditor: this.preDiffActiveEditor, + autoCloseAllRooTabs: this.autoCloseAllRooTabs, + }) + } + + public async initialize() { + const provider = ClineProvider.getVisibleInstance() + // If autoApproval is enabled, we want to preserve focus if autoFocus is disabled + // AutoApproval is enabled when the user has set "alwaysAllowWrite" and "autoApprovalEnabled" to true + // AutoFocus is enabled when the user has set "diffView.autoFocus" to true, this is the default. + // If autoFocus is disabled, we want to preserve focus on the diff editor we are working on. + // we have to check for null values for the first initialization + if (this.autoApproval === undefined) { + this.autoApproval = + (provider?.getValue("autoApprovalEnabled") && provider?.getValue("alwaysAllowWrite")) ?? false + } + const settings = await this._readDiffSettings() + this.autoFocus = settings.autoFocus + this.autoCloseAllRooTabs = settings.autoCloseAllRooTabs + // Track currently visible editors and active editor for focus restoration and tab cleanup + this.rooOpenedTabs.clear() + + // Update PostDiffviewBehaviorUtils context with latest values + this.postDiffBehaviorUtils.updateContext({ + autoCloseAllRooTabs: this.autoCloseAllRooTabs, + }) + + // + if (!settings.openTabsInCorrectGroup) { + // If openTabsInCorrectGroup is false, we want to open the diff editor in the active group + this.viewColumn = undefined + } + } + + private async _readDiffSettings(): Promise { + const config = vscode.workspace.getConfiguration("roo-cline") + const autoFocus = config.get("diffViewAutoFocus", true) + const autoCloseRooTabs = config.get("autoCloseRooTabs", false) + const autoCloseAllRooTabs = config.get("autoCloseAllRooTabs", false) + const openTabsAtEndOfList = config.get("openTabsAtEndOfList", false) + const openTabsInCorrectGroup = config.get("openTabsInCorrectGroup", true) + return { autoFocus, autoCloseRooTabs, autoCloseAllRooTabs, openTabsAtEndOfList, openTabsInCorrectGroup } + } + + private async showTextDocumentSafe({ + uri, + textDocument, + options, + }: { + uri?: vscode.Uri + textDocument?: TextDocument + options?: TextDocumentShowOptions + }) { + this.suppressInteractionFlag = true + // If the uri is already open, we want to focus it + if (uri) { + const editor = await vscode.window.showTextDocument(uri, options) + this.suppressInteractionFlag = false + return editor + } + // If the textDocument is already open, we want to focus it + if (textDocument) { + const editor = await vscode.window.showTextDocument(textDocument, options) + this.suppressInteractionFlag = false + return editor + } + // If the textDocument is not open and not able to be opened, we just reset the suppressInteractionFlag + this.suppressInteractionFlag = false + return null + } - constructor(private cwd: string) {} + /** + * Disables auto-focus on the diff editor after user interaction. + * This is to prevent the diff editor from stealing focus when the user interacts with other editors or tabs. + */ + public disableAutoFocusAfterUserInteraction() { + this.userInteractionProvider.updateOptions({ + autoApproval: this.autoApproval ?? false, + autoFocus: this.autoFocus ?? true, + }) + this.userInteractionProvider.enable() + } - async open(relPath: string): Promise { + /** + * Opens a diff editor for the given relative path, optionally in a specific viewColumn. + * @param relPath The relative file path to open. + * @param viewColumn (Optional) The VSCode editor group to open the diff in. + */ + async open(relPath: string, viewColumn: ViewColumn = ViewColumn.Active): Promise { + // Store the pre-diff active editor for potential focus restoration + this.preDiffActiveEditor = vscode.window.activeTextEditor + + const settings = await this._readDiffSettings() // Dynamically read settings + if (settings.openTabsInCorrectGroup) { + this.viewColumn = viewColumn + } + + // Update PostDiffviewBehaviorUtils context with current state + this.postDiffBehaviorUtils.updateContext({ + relPath: relPath, + editType: this.editType, + documentWasOpen: this.documentWasOpen, + preDiffActiveEditor: this.preDiffActiveEditor, + }) + // Update the user interaction provider with current settings + this.userInteractionProvider.updateOptions({ + autoApproval: this.autoApproval ?? false, + autoFocus: this.autoFocus ?? true, + }) + this.disableAutoFocusAfterUserInteraction() + // Set the edit type based on the file existence this.relPath = relPath const fileExists = this.editType === "modify" const absolutePath = path.resolve(this.cwd, relPath) this.isEditing = true + // Track the URI of the actual file that will be part of the diff. + // This ensures that if VS Code opens a tab for it during vscode.diff, + // we can identify it as a "Roo-opened" tab for cleanup. + const fileUriForDiff = vscode.Uri.file(absolutePath) + this.rooOpenedTabs.add(fileUriForDiff.toString()) + // If the file is already open, ensure it's not dirty before getting its // contents. if (fileExists) { @@ -76,32 +230,119 @@ export class DiffViewProvider { // If the file was already open, close it (must happen after showing the // diff view since if it's the only tab the column will close). - this.documentWasOpen = false - - // Close the tab if it's open (it's already saved above). - const tabs = vscode.window.tabGroups.all - .map((tg) => tg.tabs) - .flat() - .filter( - (tab) => tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath), - ) - - for (const tab of tabs) { - if (!tab.isDirty) { - await vscode.window.tabGroups.close(tab) - } - this.documentWasOpen = true - } + this.documentWasOpen = + vscode.window.tabGroups.all + .map((tg) => tg.tabs) + .flat() + .filter( + (tab) => + tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath), + ).length > 0 + + this.postDiffBehaviorUtils.updateContext({ + documentWasOpen: this.documentWasOpen, + }) this.activeDiffEditor = await this.openDiffEditor() this.fadedOverlayController = new DecorationController("fadedOverlay", this.activeDiffEditor) this.activeLineController = new DecorationController("activeLine", this.activeDiffEditor) // Apply faded overlay to all lines initially. this.fadedOverlayController.addLines(0, this.activeDiffEditor.document.lineCount) - this.scrollEditorToLine(0) // Will this crash for new files? + // Scroll to the beginning of the diff editor only if autoFocus is enabled. + if (this.autoFocus) { + this.scrollEditorToLine(0) // Will this crash for new files? + } this.streamedLines = [] } + /** + * Prepares the optimal view column and placement for the diff view. + * For existing open files: Places diff beside the original tab in the same group. + * For new/unopened files: Places at the end of the currently active editor group. + */ + private async prepareDiffViewPlacement(absolutePath: string): Promise { + const { openTabsAtEndOfList, openTabsInCorrectGroup } = await this._readDiffSettings() // Dynamically read settings + if (!openTabsAtEndOfList || !openTabsInCorrectGroup) { + return + } + if (!this.documentWasOpen) { + // focus the last tab in the active group + const activeGroup = vscode.window.tabGroups.activeTabGroup + if (!(activeGroup && activeGroup.tabs.length > 0)) { + return // No active group or no tabs in the active group, nothing to focus + } + const lastTab = activeGroup.tabs[activeGroup.tabs.length - 1] + if (!lastTab.input) { + return // No input for the last tab, nothing to focus + } + // TabInputText | TabInputCustom | TabInputWebview | TabInputNotebook have an URI, so we can focus it + if ( + !( + lastTab.input instanceof vscode.TabInputText || + lastTab.input instanceof vscode.TabInputCustom || + lastTab.input instanceof vscode.TabInputNotebook + ) + ) { + return // Last tab is not a text input, nothing to focus + } + await this.showTextDocumentSafe({ + uri: lastTab.input.uri, + options: { + viewColumn: activeGroup.viewColumn, + preserveFocus: true, + preview: false, + }, + }) + this.viewColumn = activeGroup.viewColumn // Set viewColumn to the active group + return + } + // For existing files that are currently open, find the original tab + const originalTab = this.findTabForFile(absolutePath) + if (originalTab) { + // Find the tab group containing the original tab + const tabGroup = vscode.window.tabGroups.all.find((group) => group.tabs.some((tab) => tab === originalTab)) + + if (tabGroup) { + const viewColumn = this.viewColumn !== ViewColumn.Active ? tabGroup.viewColumn : this.viewColumn + // Ensure the original tab is active within its group to place diff beside it + await this.showTextDocumentSafe({ + uri: vscode.Uri.file(absolutePath), + options: { + viewColumn: viewColumn, + preserveFocus: true, + preview: false, + }, + }) + // Update viewColumn to match the original file's group + this.viewColumn = viewColumn + } + } + // For new files or unopened files, keep the original viewColumn (active group) + // No additional preparation needed as it will default to end of active group + } + + /** + * Finds the VS Code tab for a given file path. + */ + private findTabForFile(absolutePath: string): vscode.Tab | undefined { + return vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .find( + (tab) => tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath), + ) + } + + /** + * Opens a file editor and tracks it as opened by Roo if not already open. + */ + private async showAndTrackEditor(uri: vscode.Uri, options: vscode.TextDocumentShowOptions = {}) { + const editor = await this.showTextDocumentSafe({ uri, options }) + // Always track tabs opened by Roo, regardless of autoCloseTabs setting or if the document was already open. + // The decision to close will be made in closeAllRooOpenedViews based on settings. + this.rooOpenedTabs.add(uri.toString()) + return editor + } + async update(accumulatedContent: string, isFinal: boolean) { if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) { throw new Error("Required values not set") @@ -122,9 +363,11 @@ export class DiffViewProvider { } // Place cursor at the beginning of the diff editor to keep it out of - // the way of the stream animation, but do this without stealing focus - const beginningOfDocument = new vscode.Position(0, 0) - diffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument) + // the way of the stream animation, only if autoFocus is enabled. + if (this.autoFocus) { + const beginningOfDocument = new vscode.Position(0, 0) + diffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument) + } const endLine = accumulatedLines.length // Replace all content up to the current line with accumulated lines. @@ -134,6 +377,12 @@ export class DiffViewProvider { accumulatedLines.slice(0, endLine).join("\n") + (accumulatedLines.length > 0 ? "\n" : "") edit.replace(document.uri, rangeToReplace, this.stripAllBOMs(contentToReplace)) await vscode.workspace.applyEdit(edit) + // If autoFocus is disabled, explicitly clear the selection after applying edits + // to prevent the right pane from gaining cursor focus. + if (!this.autoFocus) { + const beginningOfDocument = new vscode.Position(0, 0) + diffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument) + } // Update decorations. this.activeLineController.setActiveLine(endLine) this.fadedOverlayController.updateOverlayAfterLine(endLine, document.lineCount) @@ -187,8 +436,6 @@ export class DiffViewProvider { if (!this.relPath || !this.newContent || !this.activeDiffEditor) { return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } } - - const absolutePath = path.resolve(this.cwd, this.relPath) const updatedDocument = this.activeDiffEditor.document const editedContent = updatedDocument.getText() @@ -196,9 +443,48 @@ export class DiffViewProvider { await updatedDocument.save() } - await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false, preserveFocus: true }) - await this.closeAllDiffViews() + // Add a small delay to allow the isDirty status to update after saving. + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Explicitly save the document in VS Code's model if it's open and dirty, + // especially for newly created files, to ensure VS Code's internal state is synchronized + // before attempting to close tabs. + if (this.editType === "create" && this.relPath) { + try { + const absolutePath = path.resolve(this.cwd, this.relPath) + for (const doc of vscode.workspace.textDocuments) { + if (doc.uri.scheme === "file" && arePathsEqual(doc.uri.fsPath, absolutePath) && doc.isDirty) { + await doc.save() + // Add another small delay after explicit save for newly created file + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + } catch (saveError) { + console.error("Roo Debug: Error during explicit doc.save() for new file in saveChanges:", saveError) + // Continue execution even if explicit save fails. + } + } + + await this.postDiffBehaviorUtils.closeAllRooOpenedViews(await this._readDiffSettings()) + + // Implement post-diff focus behavior + await this.postDiffBehaviorUtils.handlePostDiffFocus() + // If no auto-close settings are enabled and the document was not open before, + // open the file after the diff is complete. + + const settings = await this._readDiffSettings() // Dynamically read settings + + // If no auto-close settings are enabled and the document was not open before OR it's a new file, + // open the file after the diff is complete. + if ( + !settings.autoCloseRooTabs && + !settings.autoCloseAllRooTabs && + (this.editType === "create" || !this.documentWasOpen) + ) { + const absolutePath = path.resolve(this.cwd, this.relPath!) + await this.showAndTrackEditor(vscode.Uri.file(absolutePath), { preview: false, preserveFocus: true }) + } // 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. @@ -263,6 +549,7 @@ export class DiffViewProvider { /** * Formats a standardized XML response for file write operations * + * @param task The current task context for sending user feedback * @param cwd Current working directory for path resolution * @param isNewFile Whether this is a new file or an existing file being modified * @returns Formatted message and say object for UI feedback @@ -344,7 +631,7 @@ export class DiffViewProvider { await updatedDocument.save() } - await this.closeAllDiffViews() + await this.postDiffBehaviorUtils.closeAllRooOpenedViews(await this._readDiffSettings()) await fs.unlink(absolutePath) // Remove only the directories we created, in reverse order. @@ -369,53 +656,22 @@ export class DiffViewProvider { await updatedDocument.save() if (this.documentWasOpen) { - await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { - preview: false, - preserveFocus: true, - }) + await this.showTextDocumentSafe({ uri: vscode.Uri.file(absolutePath), options: { preview: false } }) } - await this.closeAllDiffViews() + await this.postDiffBehaviorUtils.closeAllRooOpenedViews(await this._readDiffSettings()) } - // Edit is done. - await this.reset() - } - - private async closeAllDiffViews(): Promise { - const closeOps = vscode.window.tabGroups.all - .flatMap((group) => group.tabs) - .filter((tab) => { - // Check for standard diff views with our URI scheme - if ( - tab.input instanceof vscode.TabInputTextDiff && - tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && - !tab.isDirty - ) { - return true - } - - // Also check by tab label for our specific diff views - // This catches cases where the diff view might be created differently - // when files are pre-opened as text documents - if (tab.label.includes(DIFF_VIEW_LABEL_CHANGES) && !tab.isDirty) { - return true - } - - return false - }) - .map((tab) => - vscode.window.tabGroups.close(tab).then( - () => undefined, - (err) => { - console.error(`Failed to close diff tab ${tab.label}`, err) - }, - ), - ) + // Implement post-diff focus behavior + await this.postDiffBehaviorUtils.handlePostDiffFocus() - await Promise.all(closeOps) + // Edit is done. + this.resetWithListeners() } + /** + * Opens the diff editor, optionally in a specific viewColumn. + */ private async openDiffEditor(): Promise { if (!this.relPath) { throw new Error( @@ -423,28 +679,13 @@ export class DiffViewProvider { ) } - const uri = vscode.Uri.file(path.resolve(this.cwd, this.relPath)) - - // If this diff editor is already open (ie if a previous write file was - // interrupted) then we should activate that instead of opening a new - // diff. - const diffTab = vscode.window.tabGroups.all - .flatMap((group) => group.tabs) - .find( - (tab) => - tab.input instanceof vscode.TabInputTextDiff && - tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME && - arePathsEqual(tab.input.modified.fsPath, uri.fsPath), - ) - - if (diffTab && diffTab.input instanceof vscode.TabInputTextDiff) { - const editor = await vscode.window.showTextDocument(diffTab.input.modified, { preserveFocus: true }) - return editor - } + const settings = await this._readDiffSettings() // Dynamically read settings + // right uri = the file path + const rightUri = vscode.Uri.file(path.resolve(this.cwd, this.relPath)) // Open new diff editor. return new Promise((resolve, reject) => { - const fileName = path.basename(uri.fsPath) + const fileName = path.basename(rightUri.fsPath) const fileExists = this.editType === "modify" const DIFF_EDITOR_TIMEOUT = 10_000 // ms @@ -465,67 +706,121 @@ export class DiffViewProvider { cleanup() reject( new Error( - `Failed to open diff editor for ${uri.fsPath} within ${DIFF_EDITOR_TIMEOUT / 1000} seconds. The editor may be blocked or VS Code may be unresponsive.`, + `Failed to open diff editor for ${rightUri.fsPath} within ${DIFF_EDITOR_TIMEOUT / 1000} seconds. The editor may be blocked or VS Code may be unresponsive.`, ), ) }, DIFF_EDITOR_TIMEOUT) - // Listen for document open events - more efficient than scanning all tabs - disposables.push( - vscode.workspace.onDidOpenTextDocument(async (document) => { - if (arePathsEqual(document.uri.fsPath, uri.fsPath)) { - // Wait a tick for the editor to be available - await new Promise((r) => setTimeout(r, 0)) - - // Find the editor for this document - const editor = vscode.window.visibleTextEditors.find((e) => - arePathsEqual(e.document.uri.fsPath, uri.fsPath), + const leftUri = vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${fileName}`).with({ + query: Buffer.from(this.originalContent ?? "").toString("base64"), + }) + const title = `${fileName}: ${fileExists ? DIFF_VIEW_LABEL_CHANGES : "New File"} (Editable)` + const textDocumentShowOptions: TextDocumentShowOptions = { + preview: false, + preserveFocus: !settings.autoFocus, // Use dynamically read autoFocus + viewColumn: this.viewColumn ?? ViewColumn.Beside, // Use the viewColumn set during initialization + } + // set interaction flag to true to prevent autoFocus from being triggered + this.suppressInteractionFlag = true + // Implement improved diff view placement logic + const previousEditor = vscode.window.activeTextEditor + + this.prepareDiffViewPlacement(rightUri.fsPath).then(() => { + const vscodeDiffCommand = () => { + return vscode.commands + .executeCommand("vscode.diff", leftUri, rightUri, title, textDocumentShowOptions) + .then( + async () => { + // set interaction flag to false to allow autoFocus to be triggered + this.suppressInteractionFlag = false + + // Get the active text editor, which should be the diff editor opened by vscode.diff + const diffEditor = vscode.window.visibleTextEditors.find( + (editor) => editor.document.uri.fsPath === rightUri.fsPath, + ) + + // Ensure we have a valid editor and it's the one we expect (the right side of the diff) + if (!diffEditor || !arePathsEqual(diffEditor.document.uri.fsPath, rightUri.fsPath)) { + cleanup() + reject( + new Error( + `Failed to execute diff command for ${rightUri.fsPath}: No active editor found.`, + ), + ) + return + } + + this.activeDiffEditor = diffEditor // Assign to activeDiffEditor + + // Ensure rightUri is tracked even if not explicitly shown again + this.rooOpenedTabs.add(rightUri.toString()) + + // If autoFocus is disabled, explicitly clear the selection to prevent cursor focus. + if (!settings.autoFocus) { + // Use dynamically read autoFocus + // Add a small delay to allow VS Code to potentially set focus first, + // then clear it. + await new Promise((resolve) => setTimeout(resolve, 50)) + const beginningOfDocument = new vscode.Position(0, 0) + diffEditor.selection = new vscode.Selection( + beginningOfDocument, + beginningOfDocument, + ) + } + + // if this happens in a window different from the active one, we need to show the document + if (previousEditor && settings.openTabsInCorrectGroup) { + await this.showTextDocumentSafe({ + textDocument: previousEditor.document, + options: { + preview: false, + preserveFocus: false, + selection: previousEditor.selection, + viewColumn: previousEditor.viewColumn, + }, + }) + } + + cleanup() + resolve(diffEditor) + }, + (err) => { + cleanup() + reject( + new Error(`Failed to execute diff command for ${rightUri.fsPath}: ${err.message}`), + ) + }, ) + } - if (editor) { + if (!settings.openTabsInCorrectGroup) { + vscodeDiffCommand().then( + () => { cleanup() - resolve(editor) - } - } - }), - ) - - // Also listen for visible editor changes as a fallback - disposables.push( - vscode.window.onDidChangeVisibleTextEditors((editors) => { - const editor = editors.find((e) => arePathsEqual(e.document.uri.fsPath, uri.fsPath)) - if (editor) { - cleanup() - resolve(editor) - } - }), - ) - - // Pre-open the file as a text document to ensure it doesn't open in preview mode - // This fixes issues with files that have custom editor associations (like markdown preview) - vscode.window - .showTextDocument(uri, { preview: false, viewColumn: vscode.ViewColumn.Active, preserveFocus: true }) - .then(() => { - // Execute the diff command after ensuring the file is open as text - return vscode.commands.executeCommand( - "vscode.diff", - vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${fileName}`).with({ - query: Buffer.from(this.originalContent ?? "").toString("base64"), - }), - uri, - `${fileName}: ${fileExists ? `${DIFF_VIEW_LABEL_CHANGES}` : "New File"} (Editable)`, - { preserveFocus: true }, + }, + (err) => { + cleanup() + reject(new Error(`Failed to execute diff command for ${rightUri.fsPath}: ${err.message}`)) + }, ) - }) - .then( - () => { - // Command executed successfully, now wait for the editor to appear - }, - (err: any) => { + } else { + this.showTextDocumentSafe({ + uri: rightUri, + options: { + ...textDocumentShowOptions, + viewColumn: this.viewColumn, // Ensure we use the correct view column + }, + }).then(vscodeDiffCommand, (err) => { cleanup() - reject(new Error(`Failed to execute diff command for ${uri.fsPath}: ${err.message}`)) - }, - ) + reject(new Error(`Failed to execute diff command for ${rightUri.fsPath}: ${err.message}`)) + }) + } + + timeoutId = setTimeout(() => { + cleanup() + reject(new Error("Failed to open diff editor, please try again...")) + }, DIFF_EDITOR_TIMEOUT) + }) }) } @@ -541,6 +836,7 @@ export class DiffViewProvider { } scrollToFirstDiff() { + // Scroll to the first diff. if (!this.activeDiffEditor) { return } @@ -580,7 +876,13 @@ export class DiffViewProvider { } async reset(): Promise { - await this.closeAllDiffViews() + // Ensure any diff views opened by this provider are closed to release + // memory. + try { + await this.postDiffBehaviorUtils.closeAllRooOpenedViews(await this._readDiffSettings()) + } catch (error) { + console.error("Error closing diff views", error) + } this.editType = undefined this.isEditing = false this.originalContent = undefined @@ -591,5 +893,12 @@ export class DiffViewProvider { this.activeLineController = undefined this.streamedLines = [] this.preDiagnostics = [] + this.rooOpenedTabs.clear() + this.preDiffActiveEditor = undefined + } + + resetWithListeners() { + this.reset() + this.userInteractionProvider.dispose() } } diff --git a/src/integrations/editor/EditingProviderFactory.ts b/src/integrations/editor/EditingProviderFactory.ts new file mode 100644 index 0000000000..5f1321d3c1 --- /dev/null +++ b/src/integrations/editor/EditingProviderFactory.ts @@ -0,0 +1,46 @@ +import * as vscode from "vscode" +import { DiffViewProvider } from "./DiffViewProvider" +import { FileWriter } from "./FileWriter" +import { IEditingProvider } from "./IEditingProvider" + +/** + * Factory for creating the appropriate editing provider based on user settings + */ +export class EditingProviderFactory { + /** + * Creates an editing provider based on current VSCode settings + * @param cwd The current working directory + * @returns The appropriate editing provider (DiffViewProvider or FileWriter) + */ + static createEditingProvider(cwd: string): IEditingProvider { + const config = vscode.workspace.getConfiguration("roo-cline") + const fileBasedEditing = config.get("fileBasedEditing", false) + + if (fileBasedEditing) { + return new FileWriter(cwd) + } else { + return new DiffViewProvider(cwd) + } + } + + static resetAndCreateNewEditingProvider(cwd: string, editingProvider: IEditingProvider): IEditingProvider { + // Reset the current editing provider + if (editingProvider instanceof DiffViewProvider) { + editingProvider.resetWithListeners() + } else if (editingProvider instanceof FileWriter) { + editingProvider.resetWithListeners() + } + + // Create a new instance of the appropriate provider + return this.createEditingProvider(cwd) + } + + /** + * Checks if file-based editing is currently enabled + * @returns True if file-based editing is enabled, false otherwise + */ + static isFileBasedEditingEnabled(): boolean { + const config = vscode.workspace.getConfiguration("roo-cline") + return config.get("fileBasedEditing", false) + } +} diff --git a/src/integrations/editor/FileWriter.ts b/src/integrations/editor/FileWriter.ts new file mode 100644 index 0000000000..b766d3e74a --- /dev/null +++ b/src/integrations/editor/FileWriter.ts @@ -0,0 +1,332 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import stripBom from "strip-bom" +import { XMLBuilder } from "fast-xml-parser" + +import { createDirectoriesForFile } from "../../utils/fs" +import { getReadablePath } from "../../utils/path" +import { formatResponse } from "../../core/prompts/responses" +import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" +import { ClineSayTool } from "../../shared/ExtensionMessage" +import { Task } from "../../core/task/Task" +import { IEditingProvider } from "./IEditingProvider" + +interface FileWriterSettings { + fileBasedEditing: boolean + openFilesWithoutFocus: boolean + openTabsInCorrectGroup: boolean + openTabsAtEndOfList: boolean +} + +/** + * FileWriter provides direct file-system editing without diff views. + * It mirrors the API of DiffViewProvider for seamless integration. + */ +export class FileWriter implements IEditingProvider { + // Properties to store the results of saveChanges + newProblemsMessage?: string + userEdits?: string + editType?: "create" | "modify" + isEditing = false + originalContent: string | undefined + private createdDirs: string[] = [] + private relPath?: string + private newContent?: string + private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [] + + constructor(private cwd: string) {} + + /** + * Initializes the FileWriter (optional setup) + */ + public async initialize(): Promise { + // No initialization needed for file-based editing + } + + /** + * Reads file writing settings from VSCode configuration + */ + private async _readFileWriterSettings(): Promise { + const config = vscode.workspace.getConfiguration("roo-cline") + const fileBasedEditing = config.get("fileBasedEditing", false) + const openFilesWithoutFocus = config.get("openFilesWithoutFocus", false) + const openTabsInCorrectGroup = config.get("openTabsInCorrectGroup", false) + const openTabsAtEndOfList = config.get("openTabsAtEndOfList", false) + + return { + fileBasedEditing, + openFilesWithoutFocus, + openTabsInCorrectGroup, + openTabsAtEndOfList, + } + } + + /** + * Prepares for editing the given relative path file + * @param relPath The relative file path to prepare for editing + * @param viewColumn Optional view column (ignored by FileWriter) + */ + async open(relPath: string, viewColumn?: any): Promise { + this.relPath = relPath + const absolutePath = path.resolve(this.cwd, relPath) + this.isEditing = true + + // Get diagnostics before editing the file + this.preDiagnostics = vscode.languages.getDiagnostics() + + // Check if file exists to set edit type + try { + this.originalContent = await fs.readFile(absolutePath, "utf-8") + this.editType = "modify" + } catch (error) { + this.originalContent = "" + this.editType = "create" + } + + // For new files, create any necessary directories + if (this.editType === "create") { + this.createdDirs = await createDirectoriesForFile(absolutePath) + } + } + + /** + * Updates the file content (writes directly to file system) + * @param accumulatedContent The content to write + * @param isFinal Whether this is the final update + */ + async update(accumulatedContent: string, isFinal: boolean): Promise { + if (!this.relPath) { + throw new Error("Required values not set") + } + + this.newContent = accumulatedContent + const absolutePath = path.resolve(this.cwd, this.relPath) + + if (isFinal) { + // Preserve empty last line if original content had one + const hasEmptyLastLine = this.originalContent?.endsWith("\n") + + if (hasEmptyLastLine && !accumulatedContent.endsWith("\n")) { + accumulatedContent += "\n" + } + + // Write the final content directly to file + await fs.writeFile(absolutePath, this.stripAllBOMs(accumulatedContent), "utf-8") + } + } + + /** + * Finalizes the file changes and returns diagnostics information + */ + async saveChanges(): Promise<{ + newProblemsMessage: string | undefined + userEdits: string | undefined + finalContent: string | undefined + }> { + if (!this.relPath || !this.newContent) { + return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } + } + + const absolutePath = path.resolve(this.cwd, this.relPath) + + // Read the actual file content to check if it matches what we wrote + const finalContent = await fs.readFile(absolutePath, "utf-8") + + // Get diagnostics after editing to detect any new problems + const postDiagnostics = vscode.languages.getDiagnostics() + + const newProblems = await diagnosticsToProblemsString( + getNewDiagnostics(this.preDiagnostics, postDiagnostics), + [ + vscode.DiagnosticSeverity.Error, // only including errors since warnings can be distracting + ], + this.cwd, + ) + + const newProblemsMessage = + newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : "" + + // In file-based editing, there should be no user edits since we write directly + // But we check if the final content differs from what we intended to write + const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, "\n") + const normalizedFinalContent = finalContent.replace(/\r\n|\n/g, "\n") + + if (normalizedFinalContent !== normalizedNewContent) { + // This shouldn't happen in normal file-based editing, but handle it just in case + const userEdits = formatResponse.createPrettyPatch( + this.relPath.toPosix(), + normalizedNewContent, + normalizedFinalContent, + ) + + this.newProblemsMessage = newProblemsMessage + this.userEdits = userEdits + + return { newProblemsMessage, userEdits, finalContent: normalizedFinalContent } + } else { + this.newProblemsMessage = newProblemsMessage + this.userEdits = undefined + + return { newProblemsMessage, userEdits: undefined, finalContent: normalizedFinalContent } + } + } + + /** + * Formats a standardized XML response for file write operations + * @param task The current task context for sending user feedback + * @param cwd Current working directory for path resolution + * @param isNewFile Whether this is a new file or an existing file being modified + * @returns Formatted message and say object for UI feedback + */ + async pushToolWriteResult(task: Task, cwd: string, isNewFile: boolean): Promise { + if (!this.relPath) { + throw new Error("No file path available in FileWriter") + } + + // Only send user_feedback_diff if userEdits exists (shouldn't happen in file-based editing) + if (this.userEdits) { + // Create say object for UI feedback + const say: ClineSayTool = { + tool: isNewFile ? "newFileCreated" : "editedExistingFile", + path: getReadablePath(cwd, this.relPath), + diff: this.userEdits, + } + + // Send the user feedback + await task.say("user_feedback_diff", JSON.stringify(say)) + } + + // Build XML response + const xmlObj = { + file_write_result: { + path: this.relPath, + operation: isNewFile ? "created" : "modified", + user_edits: this.userEdits ? this.userEdits : undefined, + problems: this.newProblemsMessage || undefined, + notice: { + i: [ + "You do not need to re-read the file, as you have seen all changes", + "Proceed with the task using these changes as the new baseline.", + ...(this.userEdits + ? [ + "If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.", + ] + : []), + ], + }, + }, + } + + const builder = new XMLBuilder({ + format: true, + indentBy: "", + suppressEmptyNode: true, + processEntities: false, + tagValueProcessor: (name, value) => { + if (typeof value === "string") { + // Only escape <, >, and & characters + return value.replace(/&/g, "&").replace(//g, ">") + } + return value + }, + attributeValueProcessor: (name, value) => { + if (typeof value === "string") { + // Only escape <, >, and & characters + return value.replace(/&/g, "&").replace(//g, ">") + } + return value + }, + }) + + return builder.build(xmlObj) + } + + /** + * Reverts changes (deletes new files, restores original content for modified files) + */ + async revertChanges(): Promise { + if (!this.relPath) { + return + } + + const fileExists = this.editType === "modify" + const absolutePath = path.resolve(this.cwd, this.relPath) + + if (!fileExists) { + // Delete the newly created file + try { + await fs.unlink(absolutePath) + } catch (error) { + // File might not exist, ignore error + } + + // Remove only the directories we created, in reverse order + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch (error) { + // Directory might not be empty or not exist, ignore error + } + } + } else { + // Restore original content + await fs.writeFile(absolutePath, this.stripAllBOMs(this.originalContent ?? ""), "utf-8") + } + + // Reset state + this.reset() + } + + /** + * Strips all BOM characters from input string + */ + private stripAllBOMs(input: string): string { + let result = input + let previous + + do { + previous = result + result = stripBom(result) + } while (result !== previous) + + return result + } + + /** + * Resets the FileWriter state + */ + async reset(): Promise { + this.editType = undefined + this.isEditing = false + this.originalContent = undefined + this.newContent = undefined + this.createdDirs = [] + this.relPath = undefined + this.preDiagnostics = [] + this.newProblemsMessage = undefined + this.userEdits = undefined + } + + /** + * Resets the FileWriter state (alias for reset for compatibility) + */ + resetWithListeners(): void { + this.reset() + } + + /** + * Scrolls to first diff (no-op for FileWriter since there's no diff view) + */ + scrollToFirstDiff(): void { + // No-op for file-based editing + return + } + + /** + * Disables auto-focus after user interaction (no-op for FileWriter since there's no diff view) + */ + disableAutoFocusAfterUserInteraction(): void { + // No-op for file-based editing + } +} diff --git a/src/integrations/editor/IEditingProvider.ts b/src/integrations/editor/IEditingProvider.ts new file mode 100644 index 0000000000..764b90820d --- /dev/null +++ b/src/integrations/editor/IEditingProvider.ts @@ -0,0 +1,76 @@ +import { Task } from "../../core/task/Task" + +/** + * Interface for editing providers (DiffViewProvider, FileWriter, etc.) + * This allows tools to work with different editing strategies seamlessly + */ +export interface IEditingProvider { + // Properties to store the results of saveChanges + newProblemsMessage?: string + userEdits?: string + editType?: "create" | "modify" + isEditing: boolean + originalContent: string | undefined + + /** + * Initializes the editing provider + */ + initialize(): Promise + + /** + * Prepares for editing the given relative path file + * @param relPath The relative file path to open/prepare for editing + * @param viewColumn Optional view column for diff-based editing (ignored by file-based editing) + */ + open(relPath: string, viewColumn?: any): Promise + + /** + * Updates the content being edited + * @param content The content to apply + * @param isFinal Whether this is the final update + */ + update(content: string, isFinal: boolean): Promise + + /** + * Finalizes the changes and returns diagnostics information + */ + saveChanges(): Promise<{ + newProblemsMessage: string | undefined + userEdits: string | undefined + finalContent: string | undefined + }> + + /** + * Formats a standardized XML response for file write operations + * @param task The current task context for sending user feedback + * @param cwd Current working directory for path resolution + * @param isNewFile Whether this is a new file or an existing file being modified + * @returns Formatted XML response message + */ + pushToolWriteResult(task: Task, cwd: string, isNewFile: boolean): Promise + + /** + * Reverts changes (cancels the editing operation) + */ + revertChanges(): Promise + + /** + * Resets the provider state + */ + reset(): Promise + + /** + * Resets the provider state with listeners cleanup + */ + resetWithListeners(): void + + /** + * Scrolls to first diff (diff providers only, no-op for file providers) + */ + scrollToFirstDiff(): void + + /** + * Disables auto-focus after user interaction (diff providers only, no-op for file providers) + */ + disableAutoFocusAfterUserInteraction?(): void +} diff --git a/src/integrations/editor/PostDiffViewBehaviorUtils.ts b/src/integrations/editor/PostDiffViewBehaviorUtils.ts new file mode 100644 index 0000000000..9f5b01fc39 --- /dev/null +++ b/src/integrations/editor/PostDiffViewBehaviorUtils.ts @@ -0,0 +1,307 @@ +import * as vscode from "vscode" +import * as path from "path" +import { arePathsEqual } from "../../utils/path" +import { DIFF_VIEW_URI_SCHEME } from "./DiffViewProvider" + +/** + * Interface for diff settings used by PostDiffviewBehaviorUtils + */ +interface DiffSettings { + autoFocus: boolean + autoCloseRooTabs: boolean + autoCloseAllRooTabs: boolean +} + +/** + * Context object containing the state needed for post-diff behavior + */ +interface PostDiffContext { + relPath?: string + editType?: "create" | "modify" + documentWasOpen: boolean + cwd: string + rooOpenedTabs: Set + preDiffActiveEditor?: vscode.TextEditor + autoCloseAllRooTabs: boolean +} + +/** + * Utility class for handling post-diff behavior including tab management and focus restoration. + * This class encapsulates all logic related to what happens after a diff operation is completed. + */ +export class PostDiffViewBehaviorUtils { + private context: PostDiffContext + + constructor(context: PostDiffContext) { + this.context = context + } + + /** + * Updates the context with new values + * @param updates Partial context updates + */ + public updateContext(updates: Partial): void { + this.context = { ...this.context, ...updates } + } + + /** + * Handles post-diff focus behavior. + * Currently defaults to focusing the edited file (Behavior A). + * Future implementation will support configurable focus behavior. + */ + public async handlePostDiffFocus(): Promise { + if (!this.context.relPath) { + return + } + + if (this.context.autoCloseAllRooTabs) { + // Focus on the pre-diff active tab + await this.focusOnPreDiffActiveTab() + return + } + // Focus on the edited file (temporary default) + await this.focusOnEditedFile() + } + + /** + * Focuses on the tab of the file that was just edited. + */ + public async focusOnEditedFile(): Promise { + if (!this.context.relPath) { + return + } + + try { + const absolutePath = path.resolve(this.context.cwd, this.context.relPath) + const fileUri = vscode.Uri.file(absolutePath) + + // Check if the file still exists as a tab + const editedFileTab = this.findTabForFile(absolutePath) + if (editedFileTab) { + // Find the tab group containing the edited file + const tabGroup = vscode.window.tabGroups.all.find((group) => + group.tabs.some((tab) => tab === editedFileTab), + ) + + if (tabGroup) { + // Make the edited file's tab active + await this.showTextDocumentSafe({ + uri: fileUri, + options: { + viewColumn: tabGroup.viewColumn, + preserveFocus: false, + preview: false, + }, + }) + } + } + } catch (error) { + console.error("Roo Debug: Error focusing on edited file:", error) + } + } + + /** + * Restores focus to the tab that was active before the diff operation. + * This method is prepared for future use when configurable focus behavior is implemented. + */ + public async focusOnPreDiffActiveTab(): Promise { + if (!this.context.preDiffActiveEditor || !this.context.preDiffActiveEditor.document) { + return + } + + try { + // Check if the pre-diff active editor is still valid and its document is still open + const isDocumentStillOpen = vscode.workspace.textDocuments.some( + (doc) => doc === this.context.preDiffActiveEditor!.document, + ) + + if (isDocumentStillOpen) { + // Restore focus to the pre-diff active editor + await vscode.window.showTextDocument(this.context.preDiffActiveEditor.document.uri, { + viewColumn: this.context.preDiffActiveEditor.viewColumn, + preserveFocus: false, + preview: false, + }) + } + } catch (error) { + console.error("Roo Debug: Error restoring focus to pre-diff active tab:", error) + } + } + + /** + * Determines whether a tab should be closed based on the diff settings and tab characteristics. + * @param tab The VSCode tab to evaluate + * @param settings The diff settings containing auto-close preferences + * @returns True if the tab should be closed, false otherwise + */ + public tabToCloseFilter(tab: vscode.Tab, settings: DiffSettings): boolean { + // Always close DiffView tabs opened by Roo + if (tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME) { + return true + } + + let isRooOpenedTextTab = false + if (tab.input instanceof vscode.TabInputText) { + const currentTabUri = (tab.input as vscode.TabInputText).uri + for (const openedUriString of this.context.rooOpenedTabs) { + try { + const previouslyOpenedUri = vscode.Uri.parse(openedUriString, true) // true for strict parsing + if (currentTabUri.scheme === "file" && previouslyOpenedUri.scheme === "file") { + if (arePathsEqual(currentTabUri.fsPath, previouslyOpenedUri.fsPath)) { + isRooOpenedTextTab = true + break + } + } else { + if (currentTabUri.toString() === previouslyOpenedUri.toString()) { + isRooOpenedTextTab = true + break + } + } + } catch (e) { + // Log parsing error if necessary, or ignore if a URI in rooOpenedTabs is malformed + console.error(`Roo Debug: Error parsing URI from rooOpenedTabs: ${openedUriString}`, e) + } + } + } + + if (!isRooOpenedTextTab) { + return false // Not a text tab or not identified as opened by Roo + } + + // Haken 2 (settings.autoCloseAllRooTabs) - takes precedence + if (settings.autoCloseAllRooTabs) { + // This implies Haken 1 is also effectively on + return true // Close all Roo-opened text tabs + } + + // Only Haken 1 (settings.autoCloseRooTabs) is on, Haken 2 is off + if (settings.autoCloseRooTabs) { + const tabUriFsPath = (tab.input as vscode.TabInputText).uri.fsPath + const absolutePathDiffedFile = this.context.relPath + ? path.resolve(this.context.cwd, this.context.relPath) + : null + + // Guard against null absolutePathDiffedFile if relPath is somehow not set + if (!absolutePathDiffedFile) { + // If we don't know the main diffed file, but Haken 1 is on, + // it's safer to close any tab Roo opened to avoid leaving extras. + return true + } + + const isMainDiffedFileTab = arePathsEqual(tabUriFsPath, absolutePathDiffedFile) + + if (this.context.editType === "create" && isMainDiffedFileTab) { + return true // Case: New file, Haken 1 is on -> Close its tab. + } + + if (this.context.editType === "modify" && isMainDiffedFileTab) { + return !this.context.documentWasOpen + } + + // If the tab is for a file OTHER than the main diffedFile, but was opened by Roo + if (!isMainDiffedFileTab) { + // This covers scenarios where Roo might open auxiliary files (though less common for single diff). + // If Haken 1 is on, these should also be closed. + return true + } + } + return false // Default: do not close if no above condition met + } + + /** + * Closes a single tab with error handling and fresh reference lookup. + * @param tab The tab to close + */ + public async closeTab(tab: vscode.Tab): Promise { + // If a tab has made it through the filter, it means one of the auto-close settings + // (autoCloseTabs or autoCloseAllRooTabs) is active and the conditions for closing + // this specific tab are met. Therefore, we should always bypass the dirty check. + // const bypassDirtyCheck = true; // This is implicitly true now. + + // Attempt to find the freshest reference to the tab before closing, + // as the original 'tab' object from the initial flatMap might be stale. + const tabInputToClose = tab.input + const freshTabToClose = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .find((t) => t.input === tabInputToClose) + + if (freshTabToClose) { + try { + await vscode.window.tabGroups.close(freshTabToClose, true) // true to bypass dirty check implicitly + } catch (closeError) { + console.error(`Roo Debug CloseLoop: Error closing tab "${freshTabToClose.label}":`, closeError) + } + } else { + // This case should ideally not happen if the tab was in the filtered list. + // It might indicate the tab was closed by another means or its input changed. + console.warn( + `Roo Debug CloseLoop: Tab "${tab.label}" (input: ${JSON.stringify(tab.input)}) intended for closure was not found in the current tab list.`, + ) + // Fallback: Try to close the original tab reference if the fresh one isn't found, + // though this is less likely to succeed if it's genuinely stale. + try { + console.log(`Roo Debug CloseLoop: Attempting to close original (stale?) tab "${tab.label}"`) + await vscode.window.tabGroups.close(tab, true) + } catch (fallbackCloseError) { + console.error( + `Roo Debug CloseLoop: Error closing original tab reference for "${tab.label}":`, + fallbackCloseError, + ) + } + } + } + + /** + * Closes all tabs that were opened by Roo based on the current settings. + * @param settings The diff settings to use for determining which tabs to close + */ + public async closeAllRooOpenedViews(settings: DiffSettings): Promise { + const closeOps = vscode.window.tabGroups.all + .flatMap((tg) => tg.tabs) + .filter((tab) => this.tabToCloseFilter(tab, settings)) + .map((tab) => this.closeTab(tab)) + + await Promise.all(closeOps) + } + + /** + * Finds the VS Code tab for a given file path. + * @param absolutePath The absolute path to the file + * @returns The tab if found, undefined otherwise + */ + private findTabForFile(absolutePath: string): vscode.Tab | undefined { + return vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .find( + (tab) => tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath), + ) + } + + /** + * Safely shows a text document with error handling. + * @param params Parameters for showing the document + * @returns The text editor or null if failed + */ + private async showTextDocumentSafe({ + uri, + textDocument, + options, + }: { + uri?: vscode.Uri + textDocument?: vscode.TextDocument + options?: vscode.TextDocumentShowOptions + }): Promise { + // If the uri is already open, we want to focus it + if (uri) { + const editor = await vscode.window.showTextDocument(uri, options) + return editor + } + // If the textDocument is already open, we want to focus it + if (textDocument) { + const editor = await vscode.window.showTextDocument(textDocument, options) + return editor + } + // If the textDocument is not open and not able to be opened, we just return null + return null + } +} diff --git a/src/integrations/editor/UserInteractionProvider.ts b/src/integrations/editor/UserInteractionProvider.ts new file mode 100644 index 0000000000..f2170574cf --- /dev/null +++ b/src/integrations/editor/UserInteractionProvider.ts @@ -0,0 +1,121 @@ +import * as vscode from "vscode" + +/** + * Options for configuring the UserInteractionProvider + */ +interface UserInteractionOptions { + /** + * Callback invoked when user interaction is detected + */ + onUserInteraction: () => void + /** + * Function to check if interaction should be suppressed + */ + getSuppressFlag: () => boolean + /** + * Whether auto approval is enabled + */ + autoApproval: boolean + /** + * Whether auto focus is enabled + */ + autoFocus: boolean +} + +/** + * Manages user interaction listeners for the diff view provider. + * Handles detection of user interactions with text editors, tabs, and tab groups + * to disable auto-focus behavior when appropriate. + */ +export class UserInteractionProvider { + private userInteractionListeners: vscode.Disposable[] = [] + private options: UserInteractionOptions + + constructor(options: UserInteractionOptions) { + this.options = options + } + + /** + * Updates the options for the provider + */ + updateOptions(options: Partial): void { + this.options = { ...this.options, ...options } + } + + /** + * Enables user interaction listeners to detect when auto-focus should be disabled. + * Only sets up listeners if auto approval and auto focus are both enabled. + */ + enable(): void { + this.resetListeners() + + // If auto approval is disabled or auto focus is disabled, we don't need to add listeners + if (!this.options.autoApproval || !this.options.autoFocus) { + return + } + + // Set up listeners for various user interactions + const changeTextEditorSelectionListener = vscode.window.onDidChangeTextEditorSelection((_e) => { + // If the change was done programmatically, we don't want to suppress focus + if (this.options.getSuppressFlag()) { + return + } + // Consider this a "user interaction" + this.options.onUserInteraction() + this.resetListeners() + }, this) + + const changeActiveTextEditorListener = vscode.window.onDidChangeActiveTextEditor((editor) => { + // If the change was done programmatically, or if there is no editor, we don't want to suppress focus + if (this.options.getSuppressFlag() || !editor) { + return + } + // Consider this a "user interaction" + this.options.onUserInteraction() + this.resetListeners() + }, this) + + const changeTabListener = vscode.window.tabGroups.onDidChangeTabs((_e) => { + // If the change was done programmatically, we don't want to suppress focus + if (this.options.getSuppressFlag()) { + return + } + // Consider this a "user interaction" + this.options.onUserInteraction() + this.resetListeners() + }, this) + + const changeTabGroupListener = vscode.window.tabGroups.onDidChangeTabGroups((_e) => { + // If the change was done programmatically, we don't want to suppress focus + if (this.options.getSuppressFlag()) { + return + } + // Consider this a "user interaction" + this.options.onUserInteraction() + this.resetListeners() + }, this) + + this.userInteractionListeners.push( + changeTextEditorSelectionListener, + changeActiveTextEditorListener, + changeTabListener, + changeTabGroupListener, + ) + } + + /** + * Resets (removes) all user interaction listeners to prevent memory leaks. + * This is called when the diff editor is closed or when user interaction is detected. + */ + private resetListeners(): void { + this.userInteractionListeners.forEach((listener) => listener.dispose()) + this.userInteractionListeners = [] + } + + /** + * Disposes of all listeners and cleans up resources. + */ + dispose(): void { + this.resetListeners() + } +} diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index ad1950345b..0fe079e4e0 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1,6 +1,6 @@ -import { DiffViewProvider, DIFF_VIEW_URI_SCHEME, DIFF_VIEW_LABEL_CHANGES } from "../DiffViewProvider" +import { DIFF_VIEW_LABEL_CHANGES, DiffViewProvider } from "../DiffViewProvider" import * as vscode from "vscode" -import * as path from "path" +import { ViewColumn } from "vscode" // Mock fs/promises vi.mock("fs/promises", () => ({ @@ -28,16 +28,44 @@ vi.mock("vscode", () => ({ fs: { stat: vi.fn(), }, + // mock vscode.workspace.getConfiguration("roo-cline").get("diffViewAutoFocus", true) + getConfiguration: vi.fn(() => ({ + get: vi.fn((key: string) => { + if (key === "diffViewAutoFocus") return false + if (key === "autoCloseRooTabs") return true + if (key === "autoCloseAllRooTabs") return false + if (key === "openTabsAtEndOfList") return false + if (key === "openTabsInCorrectGroup") return true + return undefined + }), + })), }, window: { createTextEditorDecorationType: vi.fn(), showTextDocument: vi.fn(), onDidChangeVisibleTextEditors: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), tabGroups: { all: [], close: vi.fn(), + onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTabGroups: vi.fn(() => ({ dispose: vi.fn() })), }, visibleTextEditors: [], + onDidChangeActiveTextEditor: vi.fn(), + activeTextEditor: { + document: { + uri: { fsPath: "/mock/cwd/test.md" }, + getText: vi.fn(), + lineCount: 10, + }, + selection: { + active: { line: 0, character: 0 }, + anchor: { line: 0, character: 0 }, + }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + }, }, commands: { executeCommand: vi.fn(), @@ -85,6 +113,21 @@ vi.mock("../DecorationController", () => ({ })), })) +// mock cline diffViewProvider +vi.mock("../../../core/webview/ClineProvider", () => ({ + __esModule: true, + ClineProvider: { + // This is the inner ClineProvider object/class + getVisibleInstance: vi.fn(() => ({ + getValue: vi.fn((key: string) => { + if (key === "autoApprovalEnabled") return true + if (key === "alwaysAllowWrite") return true + return undefined + }), + })), + }, +})) + describe("DiffViewProvider", () => { let diffViewProvider: DiffViewProvider const mockCwd = "/mock/cwd" @@ -100,10 +143,10 @@ describe("DiffViewProvider", () => { diffViewProvider = new DiffViewProvider(mockCwd) // Mock the necessary properties and methods - ;(diffViewProvider as any).relPath = "test.txt" + ;(diffViewProvider as any).relPath = "test.md" ;(diffViewProvider as any).activeDiffEditor = { document: { - uri: { fsPath: `${mockCwd}/test.txt` }, + uri: { fsPath: `${mockCwd}/test.md` }, getText: vi.fn(), lineCount: 10, }, @@ -176,6 +219,9 @@ describe("DiffViewProvider", () => { // Mock showTextDocument to track when it's called vi.mocked(vscode.window.showTextDocument).mockImplementation(async (uri, options) => { callOrder.push("showTextDocument") + if (Object.keys(uri).length > 0) { + return mockEditor as any + } expect(options).toEqual({ preview: false, viewColumn: vscode.ViewColumn.Active, preserveFocus: true }) return mockEditor as any }) @@ -203,10 +249,10 @@ describe("DiffViewProvider", () => { ;(diffViewProvider as any).editType = "modify" // Execute open - await diffViewProvider.open("test.md") + await diffViewProvider.open("test.md", ViewColumn.Active) // Verify that showTextDocument was called before executeCommand - expect(callOrder).toEqual(["showTextDocument", "executeCommand"]) + expect(callOrder).toEqual(["showTextDocument", "executeCommand", "showTextDocument"]) // Verify that showTextDocument was called with preview: false and preserveFocus: true expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -220,7 +266,7 @@ describe("DiffViewProvider", () => { expect.any(Object), expect.any(Object), `test.md: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, - { preserveFocus: true }, + { preserveFocus: true, preview: false, viewColumn: ViewColumn.Active }, ) }) @@ -238,93 +284,167 @@ describe("DiffViewProvider", () => { ;(diffViewProvider as any).editType = "modify" // Try to open and expect rejection - await expect(diffViewProvider.open("test.md")).rejects.toThrow( + await expect(diffViewProvider.open("test.md", ViewColumn.Active)).rejects.toThrow( "Failed to execute diff command for /mock/cwd/test.md: Cannot open file", ) }) }) - describe("closeAllDiffViews method", () => { - it("should close diff views including those identified by label", async () => { - // Mock tab groups with various types of tabs - const mockTabs = [ - // Normal diff view - { - input: { - constructor: { name: "TabInputTextDiff" }, - original: { scheme: DIFF_VIEW_URI_SCHEME }, - modified: { fsPath: "/test/file1.ts" }, - }, - label: `file1.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, - isDirty: false, + it("should properly initialize UserInteractionProvider", () => { + expect(diffViewProvider).toBeDefined() + expect((diffViewProvider as any).userInteractionProvider).toBeDefined() + }) + + it("should update UserInteractionProvider options when disabling auto focus", async () => { + await diffViewProvider.initialize() + + // Mock the diffViewProvider's enable method to verify it's called + const enableSpy = vi.spyOn((diffViewProvider as any).userInteractionProvider, "enable") + + diffViewProvider.disableAutoFocusAfterUserInteraction() + + expect(enableSpy).toHaveBeenCalled() + }) + + describe("preserve focus config", () => { + it("should pass preserveFocus: false when autoFocus is true", async () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "diffViewAutoFocus") return true + if (key === "autoCloseRooTabs") return true + if (key === "autoCloseAllRooTabs") return false + return undefined + }), + } + ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig) + + const mockEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.md` }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, }, - // Diff view identified by label (for pre-opened files) - { - input: { - constructor: { name: "TabInputTextDiff" }, - original: { scheme: "file" }, // Different scheme due to pre-opening - modified: { fsPath: "/test/file2.md" }, - }, - label: `file2.md: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, - isDirty: false, + selection: { + active: { line: 0, character: 0 }, + anchor: { line: 0, character: 0 }, }, - // Regular file tab (should not be closed) - { - input: { - constructor: { name: "TabInputText" }, - uri: { fsPath: "/test/file3.js" }, - }, - label: "file3.js", - isDirty: false, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + + // Mock showTextDocument to track when it's called + vi.mocked(vscode.window.showTextDocument).mockImplementation(async (uri, options) => { + if (Object.keys(uri).length > 0) { + return mockEditor as any + } + expect(options).toEqual({ preview: false, preserveFocus: false, viewColumn: vscode.ViewColumn.Active }) + return mockEditor as any + }) + + // Mock executeCommand to track when it's called + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + expect(command).toBe("vscode.diff") + return undefined + }) + + // Mock workspace.onDidOpenTextDocument to trigger immediately + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + // Trigger the callback immediately with the document + setTimeout(() => { + callback({ uri: { fsPath: `${mockCwd}/test.md` } } as any) + }, 0) + return { dispose: vi.fn() } + }) + + const executeCommand = vscode.commands.executeCommand as any + executeCommand.mockResolvedValue(undefined) + + await diffViewProvider.initialize() + + const promise = (diffViewProvider as any).openDiffEditor() + + await promise.catch((error: any) => { + // This is expected to fail because the editor is not activated, we just want to test the command + console.error("Error:", error) + }) + + expect(executeCommand).toHaveBeenCalledWith( + "vscode.diff", + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ preserveFocus: false }), + ) + }) + + it("should pass preserveFocus: true when autoFocus is false", async () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "diffViewAutoFocus") return false + if (key === "autoCloseRooTabs") return true + if (key === "autoCloseAllRooTabs") return false + return undefined + }), + } + ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockConfig) + + const mockEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.md` }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, }, - // Dirty diff view (should not be closed) - { - input: { - constructor: { name: "TabInputTextDiff" }, - original: { scheme: DIFF_VIEW_URI_SCHEME }, - modified: { fsPath: "/test/file4.ts" }, - }, - label: `file4.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, - isDirty: true, + selection: { + active: { line: 0, character: 0 }, + anchor: { line: 0, character: 0 }, }, - ] + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } - // Make tabs appear as TabInputTextDiff instances - mockTabs.forEach((tab) => { - if (tab.input.constructor.name === "TabInputTextDiff") { - Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + // Mock showTextDocument to track when it's called + vi.mocked(vscode.window.showTextDocument).mockImplementation(async (uri, options) => { + if (Object.keys(uri).length > 0) { + return mockEditor as any } + expect(options).toEqual({ preview: false, preserveFocus: false, viewColumn: vscode.ViewColumn.Active }) + return mockEditor as any }) - // Mock the tabGroups getter - Object.defineProperty(vscode.window.tabGroups, "all", { - get: () => [ - { - tabs: mockTabs as any, - }, - ], - configurable: true, + // Mock executeCommand to track when it's called + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + expect(command).toBe("vscode.diff") + return undefined }) - const closedTabs: any[] = [] - vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { - closedTabs.push(tab) - return Promise.resolve(true) + // Mock workspace.onDidOpenTextDocument to trigger immediately + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + // Trigger the callback immediately with the document + setTimeout(() => { + callback({ uri: { fsPath: `${mockCwd}/test.md` } } as any) + }, 0) + return { dispose: vi.fn() } }) - // Execute closeAllDiffViews - await (diffViewProvider as any).closeAllDiffViews() + const executeCommand = vscode.commands.executeCommand as any + executeCommand.mockResolvedValue(undefined) + + await diffViewProvider.initialize() - // Verify that only the appropriate tabs were closed - expect(closedTabs).toHaveLength(2) - expect(closedTabs[0].label).toBe(`file1.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`) - expect(closedTabs[1].label).toBe(`file2.md: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`) + const promise = (diffViewProvider as any).openDiffEditor() - // Verify that the regular file and dirty diff were not closed - expect(closedTabs.find((t) => t.label === "file3.js")).toBeUndefined() - expect( - closedTabs.find((t) => t.label === `file4.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)` && t.isDirty), - ).toBeUndefined() + await promise.catch((error: any) => { + // This is expected to fail because the editor is not activated, we just want to test the command + console.error("Error:", error) + }) + + expect(executeCommand).toHaveBeenCalledWith( + "vscode.diff", + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ preserveFocus: true }), + ) }) }) }) diff --git a/src/integrations/editor/__tests__/EditingProviderFactory.spec.ts b/src/integrations/editor/__tests__/EditingProviderFactory.spec.ts new file mode 100644 index 0000000000..54bd9c7b85 --- /dev/null +++ b/src/integrations/editor/__tests__/EditingProviderFactory.spec.ts @@ -0,0 +1,91 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" + +import { EditingProviderFactory } from "../EditingProviderFactory" +import { DiffViewProvider } from "../DiffViewProvider" +import { FileWriter } from "../FileWriter" + +// Mock VSCode API +const mockGet = vi.fn() +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ + get: mockGet, + })), + }, +})) + +// Mock the providers +vi.mock("../DiffViewProvider", () => ({ + DiffViewProvider: vi.fn(), +})) + +vi.mock("../FileWriter", () => ({ + FileWriter: vi.fn(), +})) + +describe("EditingProviderFactory", () => { + const mockCwd = "/test/cwd" + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("createEditingProvider", () => { + it("should create FileWriter when fileBasedEditing is enabled", () => { + mockGet.mockReturnValue(true) + + const provider = EditingProviderFactory.createEditingProvider(mockCwd) + + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("roo-cline") + expect(mockGet).toHaveBeenCalledWith("fileBasedEditing", false) + expect(FileWriter).toHaveBeenCalledWith(mockCwd) + expect(DiffViewProvider).not.toHaveBeenCalled() + }) + + it("should create DiffViewProvider when fileBasedEditing is disabled", () => { + mockGet.mockReturnValue(false) + + const provider = EditingProviderFactory.createEditingProvider(mockCwd) + + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("roo-cline") + expect(mockGet).toHaveBeenCalledWith("fileBasedEditing", false) + expect(DiffViewProvider).toHaveBeenCalledWith(mockCwd) + expect(FileWriter).not.toHaveBeenCalled() + }) + + it("should create DiffViewProvider when fileBasedEditing is undefined", () => { + mockGet.mockReturnValue(undefined) + + const provider = EditingProviderFactory.createEditingProvider(mockCwd) + + expect(DiffViewProvider).toHaveBeenCalledWith(mockCwd) + expect(FileWriter).not.toHaveBeenCalled() + }) + }) + + describe("isFileBasedEditingEnabled", () => { + it("should return true when fileBasedEditing is enabled", () => { + mockGet.mockReturnValue(true) + + const result = EditingProviderFactory.isFileBasedEditingEnabled() + + expect(result).toBe(true) + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("roo-cline") + expect(mockGet).toHaveBeenCalledWith("fileBasedEditing", false) + }) + + it("should return false when fileBasedEditing is disabled", () => { + mockGet.mockReturnValue(false) + + const result = EditingProviderFactory.isFileBasedEditingEnabled() + + expect(result).toBe(false) + }) + + }) +}) diff --git a/src/integrations/editor/__tests__/FileWriter.spec.ts b/src/integrations/editor/__tests__/FileWriter.spec.ts new file mode 100644 index 0000000000..3d7f3854e9 --- /dev/null +++ b/src/integrations/editor/__tests__/FileWriter.spec.ts @@ -0,0 +1,235 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" +import * as fs from "fs/promises" +import * as path from "path" + +import { FileWriter } from "../FileWriter" +import { Task } from "../../../core/task/Task" + +// Mock VSCode API +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + })), + }, + languages: { + getDiagnostics: vi.fn(() => []), + }, + DiagnosticSeverity: { + Error: 0, + }, +})) + +// Mock fs module +vi.mock("fs/promises", () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + rmdir: vi.fn(), +})) + +// Mock other dependencies +vi.mock("../../../utils/fs", () => ({ + createDirectoriesForFile: vi.fn(() => Promise.resolve([])), +})) + +vi.mock("../../diagnostics", () => ({ + diagnosticsToProblemsString: vi.fn(() => Promise.resolve("")), + getNewDiagnostics: vi.fn(() => []), +})) + +vi.mock("../../../utils/path", () => ({ + getReadablePath: vi.fn((cwd, relPath) => relPath), +})) + +vi.mock("../../../core/prompts/responses", () => ({ + formatResponse: { + createPrettyPatch: vi.fn(() => "mock-diff"), + }, +})) + +vi.mock("strip-bom", () => ({ + default: vi.fn((content) => content), +})) + +describe("FileWriter", () => { + let fileWriter: FileWriter + const mockCwd = "/test/cwd" + const mockTask = {} as Task + + beforeEach(() => { + vi.clearAllMocks() + fileWriter = new FileWriter(mockCwd) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("initialize", () => { + it("should initialize without errors", async () => { + await expect(fileWriter.initialize()).resolves.toBeUndefined() + }) + }) + + describe("open", () => { + it("should open an existing file for modification", async () => { + const mockContent = "existing content" + vi.mocked(fs.readFile).mockResolvedValue(mockContent) + + await fileWriter.open("test.txt") + + expect(fileWriter.editType).toBe("modify") + expect(fileWriter.originalContent).toBe(mockContent) + expect(fileWriter.isEditing).toBe(true) + }) + + it("should open a new file for creation", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found")) + + await fileWriter.open("new-file.txt") + + expect(fileWriter.editType).toBe("create") + expect(fileWriter.originalContent).toBe("") + expect(fileWriter.isEditing).toBe(true) + }) + + it("should handle viewColumn parameter (ignored)", async () => { + vi.mocked(fs.readFile).mockResolvedValue("content") + + await expect(fileWriter.open("test.txt", "SomeViewColumn")).resolves.toBeUndefined() + }) + }) + + describe("update", () => { + beforeEach(async () => { + vi.mocked(fs.readFile).mockResolvedValue("original content") + await fileWriter.open("test.txt") + }) + + it("should write final content to file", async () => { + const content = "new content" + + await fileWriter.update(content, true) + + expect(fs.writeFile).toHaveBeenCalledWith(path.resolve(mockCwd, "test.txt"), content, "utf-8") + }) + + it("should preserve empty last line if original content had one", async () => { + fileWriter.originalContent = "content\n" + const content = "new content" + + await fileWriter.update(content, true) + + expect(fs.writeFile).toHaveBeenCalledWith(path.resolve(mockCwd, "test.txt"), "new content\n", "utf-8") + }) + + it("should not write to file if not final", async () => { + await fileWriter.update("content", false) + + expect(fs.writeFile).not.toHaveBeenCalled() + }) + }) + + describe("saveChanges", () => { + beforeEach(async () => { + vi.mocked(fs.readFile).mockResolvedValue("original content") + await fileWriter.open("test.txt") + await fileWriter.update("new content", true) + }) + + it("should return save results", async () => { + vi.mocked(fs.readFile).mockResolvedValue("new content") + + const result = await fileWriter.saveChanges() + + expect(result).toEqual({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + }) + }) + + describe("pushToolWriteResult", () => { + beforeEach(async () => { + vi.mocked(fs.readFile).mockResolvedValue("content") + await fileWriter.open("test.txt") + }) + + it("should return formatted XML response", async () => { + const result = await fileWriter.pushToolWriteResult(mockTask, mockCwd, false) + + expect(result).toContain("") + expect(result).toContain("test.txt") + expect(result).toContain("modified") + }) + + it("should handle new file creation", async () => { + const result = await fileWriter.pushToolWriteResult(mockTask, mockCwd, true) + + expect(result).toContain("created") + }) + }) + + describe("revertChanges", () => { + it("should delete new file and directories", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found")) + await fileWriter.open("new-file.txt") + + await fileWriter.revertChanges() + + expect(fs.unlink).toHaveBeenCalled() + }) + + it("should restore original content for existing file", async () => { + const originalContent = "original content" + vi.mocked(fs.readFile).mockResolvedValue(originalContent) + await fileWriter.open("existing-file.txt") + + await fileWriter.revertChanges() + + expect(fs.writeFile).toHaveBeenCalledWith( + path.resolve(mockCwd, "existing-file.txt"), + originalContent, + "utf-8", + ) + }) + }) + + describe("scrollToFirstDiff", () => { + it("should be a no-op for file-based editing", () => { + expect(() => fileWriter.scrollToFirstDiff()).not.toThrow() + }) + }) + + describe("disableAutoFocusAfterUserInteraction", () => { + it("should be a no-op for file-based editing", () => { + expect(() => fileWriter.disableAutoFocusAfterUserInteraction()).not.toThrow() + }) + }) + + describe("reset", () => { + it("should reset all state", async () => { + vi.mocked(fs.readFile).mockResolvedValue("content") + await fileWriter.open("test.txt") + + await fileWriter.reset() + + expect(fileWriter.editType).toBeUndefined() + expect(fileWriter.isEditing).toBe(false) + expect(fileWriter.originalContent).toBeUndefined() + }) + }) + + describe("resetWithListeners", () => { + it("should call reset", async () => { + const resetSpy = vi.spyOn(fileWriter, "reset") + + fileWriter.resetWithListeners() + + expect(resetSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/src/integrations/editor/__tests__/PostDiffviewBehaviorUtils.spec.ts b/src/integrations/editor/__tests__/PostDiffviewBehaviorUtils.spec.ts new file mode 100644 index 0000000000..0f38350a1f --- /dev/null +++ b/src/integrations/editor/__tests__/PostDiffviewBehaviorUtils.spec.ts @@ -0,0 +1,251 @@ +// npx vitest run src/integrations/editor/__tests__/PostDiffViewBehaviorUtils.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" +import { PostDiffViewBehaviorUtils } from "../PostDiffViewBehaviorUtils" +import { DIFF_VIEW_URI_SCHEME } from "../DiffViewProvider" + +// Mock vscode +vi.mock("vscode", () => ({ + window: { + tabGroups: { + all: [], + close: vi.fn(), + }, + showTextDocument: vi.fn(), + createTextEditorDecorationType: vi.fn(), + }, + workspace: { + textDocuments: [], + }, + TabInputTextDiff: class TabInputTextDiff { + constructor( + public original?: { scheme?: string }, + public modified?: any, + ) {} + }, + TabInputText: class TabInputText { + constructor(public uri: any) {} + }, + Uri: { + parse: vi.fn(), + file: vi.fn(), + }, +})) + +// Mock path utilities +vi.mock("../../../utils/path", () => ({ + arePathsEqual: vi.fn((a: string, b: string) => a === b), +})) + +describe("PostDiffViewBehaviorUtils", () => { + let utils: PostDiffViewBehaviorUtils + let mockContext: any + + beforeEach(() => { + vi.clearAllMocks() + mockContext = { + relPath: "test.txt", + editType: "modify" as const, + documentWasOpen: false, + cwd: "/test", + rooOpenedTabs: new Set(), + preDiffActiveEditor: undefined, + autoCloseAllRooTabs: false, + } + utils = new PostDiffViewBehaviorUtils(mockContext) + }) + + describe("updateContext", () => { + it("should update context with new values", () => { + utils.updateContext({ + relPath: "new-file.txt", + editType: "create", + }) + + // Since context is private, we can test this indirectly by calling a method that uses it + expect(() => utils.handlePostDiffFocus()).not.toThrow() + }) + }) + + describe("tabToCloseFilter", () => { + it("should always close DiffView tabs opened by Roo", () => { + const mockTab = { + input: new (vscode as any).TabInputTextDiff({ scheme: DIFF_VIEW_URI_SCHEME }), + } as vscode.Tab + + const settings = { + autoFocus: true, + autoCloseRooTabs: false, + autoCloseAllRooTabs: false, + } + + const result = utils.tabToCloseFilter(mockTab, settings) + expect(result).toBe(true) + }) + + it("should not close non-Roo opened tabs", () => { + const mockTab = { + input: new (vscode as any).TabInputText({ toString: () => "file:///other.txt" }), + } as vscode.Tab + + const settings = { + autoFocus: true, + autoCloseRooTabs: false, + autoCloseAllRooTabs: false, + } + + const result = utils.tabToCloseFilter(mockTab, settings) + expect(result).toBe(false) + }) + + it("should close all Roo-opened tabs when autoCloseAllRooTabs is true", () => { + mockContext.rooOpenedTabs.add("file:///test.txt") + utils.updateContext(mockContext) + + const mockTab = { + input: new (vscode as any).TabInputText({ + scheme: "file", + fsPath: "/test/test.txt", + toString: () => "file:///test.txt", + }), + } as vscode.Tab + + const settings = { + autoFocus: true, + autoCloseRooTabs: false, + autoCloseAllRooTabs: true, + } + + // Mock Uri.parse to return the expected URI + ;(vscode.Uri.parse as any).mockReturnValue({ + scheme: "file", + fsPath: "/test/test.txt", + toString: () => "file:///test.txt", + }) + + const result = utils.tabToCloseFilter(mockTab, settings) + expect(result).toBe(true) + }) + }) + + describe("handlePostDiffFocus", () => { + it("should return early if no relPath is set", async () => { + mockContext.relPath = undefined + utils.updateContext(mockContext) + + // Should not throw and should complete quickly + await expect(utils.handlePostDiffFocus()).resolves.toBeUndefined() + }) + + it("should focus on pre-diff active tab when autoCloseAllRooTabs is true", async () => { + const mockEditor = { + document: { uri: { toString: () => "file:///test.txt" } }, + viewColumn: 1, + } + mockContext.autoCloseAllRooTabs = true + mockContext.preDiffActiveEditor = mockEditor + utils.updateContext(mockContext) + + // Mock workspace.textDocuments to include the document + ;(vscode.workspace as any).textDocuments = [mockEditor.document] + + const showTextDocumentSpy = vi.spyOn(vscode.window, "showTextDocument") + + await utils.handlePostDiffFocus() + + expect(showTextDocumentSpy).toHaveBeenCalledWith(mockEditor.document.uri, { + viewColumn: mockEditor.viewColumn, + preserveFocus: false, + preview: false, + }) + }) + }) + + describe("closeTab", () => { + it("should close a tab successfully", async () => { + const mockTab = { + input: {}, + label: "test.txt", + } as vscode.Tab + + const closeSpy = vi.spyOn(vscode.window.tabGroups, "close") + closeSpy.mockResolvedValue(true) + + // Mock tabGroups.all to return the same tab + ;(vscode.window.tabGroups as any).all = [ + { + tabs: [mockTab], + }, + ] + + await utils.closeTab(mockTab) + + expect(closeSpy).toHaveBeenCalledWith(mockTab, true) + }) + + it("should handle tab close errors gracefully", async () => { + const mockTab = { + input: {}, + label: "test.txt", + } as vscode.Tab + + const closeSpy = vi.spyOn(vscode.window.tabGroups, "close") + closeSpy.mockRejectedValue(new Error("Close failed")) + + // Mock tabGroups.all to return the same tab + ;(vscode.window.tabGroups as any).all = [ + { + tabs: [mockTab], + }, + ] + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await utils.closeTab(mockTab) + + expect(consoleErrorSpy).toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) + }) + + describe("closeAllRooOpenedViews", () => { + it("should close all tabs that match the filter", async () => { + const mockTab1 = { + input: new (vscode as any).TabInputTextDiff({ scheme: DIFF_VIEW_URI_SCHEME }), + label: "diff-tab", + } as vscode.Tab + + const mockTab2 = { + input: new (vscode as any).TabInputText({ + scheme: "file", + fsPath: "/test/other.txt", + toString: () => "file:///other.txt", + }), + label: "other.txt", + } as vscode.Tab + + // Mock tabGroups.all + ;(vscode.window.tabGroups as any).all = [ + { + tabs: [mockTab1, mockTab2], + }, + ] + + const closeSpy = vi.spyOn(vscode.window.tabGroups, "close") + closeSpy.mockResolvedValue(true) + + const settings = { + autoFocus: true, + autoCloseRooTabs: false, + autoCloseAllRooTabs: false, + } + + await utils.closeAllRooOpenedViews(settings) + + // Should only close the diff tab + expect(closeSpy).toHaveBeenCalledTimes(1) + expect(closeSpy).toHaveBeenCalledWith(mockTab1, true) + }) + }) +}) diff --git a/src/integrations/editor/__tests__/UserInteractionProvider.spec.ts b/src/integrations/editor/__tests__/UserInteractionProvider.spec.ts new file mode 100644 index 0000000000..f69997b0dc --- /dev/null +++ b/src/integrations/editor/__tests__/UserInteractionProvider.spec.ts @@ -0,0 +1,135 @@ +// npx vi src/integrations/editor/__tests__/UserInteractionProvider.test.ts + +import * as vscode from "vscode" +import { UserInteractionProvider } from "../UserInteractionProvider" +import { Mock } from "vitest" + +vi.mock("vscode", () => ({ + window: { + tabGroups: { + onDidChangeTabs: vi.fn(), + onDidChangeTabGroups: vi.fn(), + }, + onDidChangeActiveTextEditor: vi.fn(), + onDidChangeTextEditorSelection: vi.fn(), + }, +})) + +describe("UserInteractionProvider", () => { + let provider: UserInteractionProvider + let mockOnUserInteraction: Mock + let mockGetSuppressFlag: Mock + let mockDisposable: { dispose: Mock } + + beforeEach(() => { + vi.clearAllMocks() + mockOnUserInteraction = vi.fn() + mockGetSuppressFlag = vi.fn().mockReturnValue(false) + mockDisposable = { dispose: vi.fn() } + + // Mock the event listeners to return disposables + ;(vscode.window.onDidChangeTextEditorSelection as any).mockReturnValue(mockDisposable) + ;(vscode.window.onDidChangeActiveTextEditor as any).mockReturnValue(mockDisposable) + ;(vscode.window.tabGroups.onDidChangeTabs as any).mockReturnValue(mockDisposable) + ;(vscode.window.tabGroups.onDidChangeTabGroups as any).mockReturnValue(mockDisposable) + + provider = new UserInteractionProvider({ + onUserInteraction: mockOnUserInteraction, + getSuppressFlag: mockGetSuppressFlag, + autoApproval: true, + autoFocus: true, + }) + }) + + it("should create provider with initial options", () => { + expect(provider).toBeDefined() + }) + + it("should set up listeners when enabled with autoApproval and autoFocus true", () => { + provider.enable() + + expect(vscode.window.onDidChangeTextEditorSelection).toHaveBeenCalled() + expect(vscode.window.onDidChangeActiveTextEditor).toHaveBeenCalled() + expect(vscode.window.tabGroups.onDidChangeTabs).toHaveBeenCalled() + expect(vscode.window.tabGroups.onDidChangeTabGroups).toHaveBeenCalled() + }) + + it("should not set up listeners when autoApproval is false", () => { + provider.updateOptions({ autoApproval: false }) + provider.enable() + + expect(vscode.window.onDidChangeTextEditorSelection).not.toHaveBeenCalled() + expect(vscode.window.onDidChangeActiveTextEditor).not.toHaveBeenCalled() + expect(vscode.window.tabGroups.onDidChangeTabs).not.toHaveBeenCalled() + expect(vscode.window.tabGroups.onDidChangeTabGroups).not.toHaveBeenCalled() + }) + + it("should not set up listeners when autoFocus is false", () => { + provider.updateOptions({ autoFocus: false }) + provider.enable() + + expect(vscode.window.onDidChangeTextEditorSelection).not.toHaveBeenCalled() + expect(vscode.window.onDidChangeActiveTextEditor).not.toHaveBeenCalled() + expect(vscode.window.tabGroups.onDidChangeTabs).not.toHaveBeenCalled() + expect(vscode.window.tabGroups.onDidChangeTabGroups).not.toHaveBeenCalled() + }) + + it("should call onUserInteraction when text editor selection changes", () => { + provider.enable() + + const selectionChangeCallback = (vscode.window.onDidChangeTextEditorSelection as any).mock.calls[0][0] + selectionChangeCallback({}) + + expect(mockOnUserInteraction).toHaveBeenCalled() + }) + + it("should not call onUserInteraction when suppress flag is true", () => { + mockGetSuppressFlag.mockReturnValue(true) + provider.enable() + + const selectionChangeCallback = (vscode.window.onDidChangeTextEditorSelection as any).mock.calls[0][0] + selectionChangeCallback({}) + + expect(mockOnUserInteraction).not.toHaveBeenCalled() + }) + + it("should call onUserInteraction when active text editor changes", () => { + provider.enable() + + const activeEditorChangeCallback = (vscode.window.onDidChangeActiveTextEditor as any).mock.calls[0][0] + activeEditorChangeCallback({ document: { uri: "test" } }) + + expect(mockOnUserInteraction).toHaveBeenCalled() + }) + + it("should not call onUserInteraction when active editor is null", () => { + provider.enable() + + const activeEditorChangeCallback = (vscode.window.onDidChangeActiveTextEditor as any).mock.calls[0][0] + activeEditorChangeCallback(null) + + expect(mockOnUserInteraction).not.toHaveBeenCalled() + }) + + it("should dispose all listeners when dispose is called", () => { + provider.enable() + provider.dispose() + + expect(mockDisposable.dispose).toHaveBeenCalledTimes(4) + }) + + it("should update options correctly", () => { + provider.updateOptions({ autoApproval: false, autoFocus: false }) + provider.enable() + + expect(vscode.window.onDidChangeTextEditorSelection).not.toHaveBeenCalled() + }) + + it("should reset listeners when enable is called multiple times", () => { + provider.enable() + expect(mockDisposable.dispose).toHaveBeenCalledTimes(0) + + provider.enable() + expect(mockDisposable.dispose).toHaveBeenCalledTimes(4) + }) +}) diff --git a/src/package.json b/src/package.json index 30168c951c..0ec41f7df5 100644 --- a/src/package.json +++ b/src/package.json @@ -338,6 +338,41 @@ "type": "string", "default": "", "description": "%settings.autoImportSettingsPath.description%" + }, + "roo-cline.diffEnabled": { + "type": "boolean", + "default": false, + "description": "%settings.diffEnabled.description%" + }, + "roo-cline.diffViewAutoFocus": { + "type": "boolean", + "default": false, + "description": "Automatically focus the diff tab when showing file changes. If false, the diff tab will open in the background." + }, + "roo-cline.autoCloseRooTabs": { + "type": "boolean", + "default": false, + "description": "%roo-cline.autoCloseRooTabs.description%" + }, + "roo-cline.autoCloseAllRooTabs": { + "type": "boolean", + "default": false, + "description": "%roo-cline.autoCloseAllRooTabs.description%" + }, + "roo-cline.fileBasedEditing": { + "type": "boolean", + "default": false, + "description": "%roo-cline.fileBasedEditing.description%" + }, + "roo-cline.openTabsInCorrectGroup": { + "type": "boolean", + "default": false, + "description": "%roo-cline.openTabsInCorrectGroup.description%" + }, + "roo-cline.openTabsAtEndOfList": { + "type": "boolean", + "default": false, + "description": "%roo-cline.openTabsAtEndOfList.description%" } } } diff --git a/src/package.nls.json b/src/package.nls.json index 23885f80be..cb55e62b8d 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -32,5 +32,7 @@ "settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)", "settings.customStoragePath.description": "Custom storage path. Leave empty to use the default location. Supports absolute paths (e.g. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Enable Roo Code quick fixes", - "settings.autoImportSettingsPath.description": "Path to a RooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import." + "settings.autoImportSettingsPath.description": "Path to a RooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import.", + "roo-cline.autoCloseRooTabs.description": "When enabled, Roo will automatically close tabs for files that were newly created or were not already open before Roo modified them. Tabs for files that were already open will remain open.", + "roo-cline.autoCloseAllRooTabs.description": "When enabled (and 'Automatically close newly created or previously unopened Roo tabs' is also enabled), Roo will close all tabs it interacted with during a file modification task, including those that were already open before the task started." } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 9db0889c88..dd75cb1516 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -209,6 +209,12 @@ export type ExtensionState = Pick< | "terminalZdotdir" | "terminalCompressProgressBar" | "diffEnabled" + | "diffViewAutoFocus" + | "autoCloseRooTabs" + | "autoCloseAllRooTabs" + | "fileBasedEditing" + | "openTabsInCorrectGroup" + | "openTabsAtEndOfList" | "fuzzyMatchThreshold" // | "experiments" // Optional in GlobalSettings, required here. | "language" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index a50e30b67e..15d49118f1 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -85,6 +85,12 @@ export interface WebviewMessage { | "ttsSpeed" | "soundVolume" | "diffEnabled" + | "diffViewAutoFocus" + | "autoCloseRooTabs" + | "autoCloseAllRooTabs" + | "fileBasedEditing" + | "openTabsInCorrectGroup" + | "openTabsAtEndOfList" | "enableCheckpoints" | "browserViewportSize" | "screenshotQuality" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 8f6050f4f2..19538d5089 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -61,7 +61,6 @@ import { inputEventTransform, noTransform } from "./transforms" import { ModelInfoView } from "./ModelInfoView" import { ApiErrorMessage } from "./ApiErrorMessage" import { ThinkingBudget } from "./ThinkingBudget" -import { DiffSettingsControl } from "./DiffSettingsControl" import { TemperatureControl } from "./TemperatureControl" import { RateLimitSecondsControl } from "./RateLimitSecondsControl" import { BedrockCustomArn } from "./providers/BedrockCustomArn" @@ -527,11 +526,6 @@ const ApiOptions = ({ {!fromWelcomeView && ( <> - setApiConfigurationField(field, value)} - /> void -} - -export const DiffSettingsControl: React.FC = ({ - diffEnabled = true, - fuzzyMatchThreshold = 1.0, - onChange, -}) => { - const { t } = useAppTranslation() - - const handleDiffEnabledChange = useCallback( - (e: any) => { - onChange("diffEnabled", e.target.checked) - }, - [onChange], - ) - - const handleThresholdChange = useCallback( - (newValue: number[]) => { - onChange("fuzzyMatchThreshold", newValue[0]) - }, - [onChange], - ) - - return ( -
-
- - {t("settings:advanced.diff.label")} - -
- {t("settings:advanced.diff.description")} -
-
- - {diffEnabled && ( -
-
- -
- - {Math.round(fuzzyMatchThreshold * 100)}% -
-
- {t("settings:advanced.diff.matchPrecision.description")} -
-
-
- )} -
- ) -} diff --git a/webview-ui/src/components/settings/FileEditingOptions.tsx b/webview-ui/src/components/settings/FileEditingOptions.tsx new file mode 100644 index 0000000000..f94f240175 --- /dev/null +++ b/webview-ui/src/components/settings/FileEditingOptions.tsx @@ -0,0 +1,452 @@ +import React, { useCallback } from "react" +import { Slider } from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import { SectionHeader } from "@/components/settings/SectionHeader" +import { Section } from "@/components/settings/Section" +import { FileDiff, ScanSearch, Settings2 } from "lucide-react" + +type FileEditingOptionsField = + | "diffEnabled" + | "fuzzyMatchThreshold" + | "diffViewAutoFocus" + | "autoCloseRooTabs" + | "autoCloseAllRooTabs" + | "fileBasedEditing" + | "openTabsInCorrectGroup" + | "openTabsAtEndOfList" + +interface FileEditingOptionsProps { + diffEnabled?: boolean + diffViewAutoFocus?: boolean + autoCloseRooTabs?: boolean + autoCloseAllRooTabs?: boolean + fuzzyMatchThreshold?: number + fileBasedEditing?: boolean + openTabsInCorrectGroup?: boolean + openTabsAtEndOfList?: boolean + onChange: (field: FileEditingOptionsField, value: any) => void +} + +interface DiffCheckAutoFocusControlProps { + diffViewAutoFocus: boolean + disabled: boolean + onChange: (e: any) => void +} + +interface DiffCheckAutoCloseControlProps { + autoCloseRooTabs: boolean + disabled: boolean + onChange: (e: any) => void +} + +interface DiffCheckAutoCloseAllControlProps { + autoCloseAllRooTabs: boolean + disabled: boolean + onChange: (e: any) => void +} + +interface DiffPrecisionMatchControlProps { + fuzzyMatchThreshold: number + disabled: boolean + onValueChange: (newValue: number[]) => void +} + +interface FileBasedEditingControlProps { + fileBasedEditing: boolean + onChange: (e: any) => void +} + +interface OpenTabsInCorrectGroupControlProps { + openTabsInCorrectGroup: boolean + disabled: boolean + onChange: (e: any) => void +} + +interface OpenTabsAtEndOfListControlProps { + openTabsAtEndOfList: boolean + disabled: boolean + onChange: (e: any) => void +} + +/** + * Control for diff view auto-focus setting + */ +const DiffViewAutoFocusControl: React.FC = ({ + diffViewAutoFocus, + disabled, + onChange, +}) => { + const { t } = useAppTranslation() + return ( +
+ + {t("settings:advanced.diff.autoFocus.label")} + +
+ {t("settings:advanced.diff.autoFocus.description")} +
+
+ ) +} + +/** + * Control for auto-closing Roo tabs setting + */ +const DiffViewAutoCloseControl: React.FC = ({ + autoCloseRooTabs, + disabled, + onChange, +}) => { + const { t } = useAppTranslation() + return ( +
+ + {t("settings:advanced.diff.autoClose.label")} + +
+ {t("settings:advanced.diff.autoClose.description")} +
+
+ ) +} + +/** + * Control for auto-closing all Roo tabs setting + */ +const DiffViewAutoCloseAllControl: React.FC = ({ + autoCloseAllRooTabs, + disabled, + onChange, +}) => { + const { t } = useAppTranslation() + return ( +
+ + {t("settings:advanced.diff.autoCloseAll.label")} + +
+ {t("settings:advanced.diff.autoCloseAll.description")} +
+
+ ) +} + +/** + * Control for diff precision match threshold + */ +const DiffPrecisionMatchControl: React.FC = ({ + fuzzyMatchThreshold, + disabled, + onValueChange, +}) => { + const { t } = useAppTranslation() + return ( +
+
+ +
+ + {Math.round(fuzzyMatchThreshold * 100)}% +
+
+ {t("settings:advanced.diff.matchPrecision.description")} +
+
+
+ ) +} + +/** + * Control for enabling file-based editing mode + */ +const FileBasedEditingControl: React.FC = ({ fileBasedEditing, onChange }) => { + const { t } = useAppTranslation() + return ( +
+ + {t("settings:advanced.fileEditing.fileBasedEditing.label")} + +
+ {t("settings:advanced.fileEditing.fileBasedEditing.description")} +
+
+ ) +} + +/** + * Control for opening tabs in correct tab group + */ +const OpenTabsInCorrectGroupControl: React.FC = ({ + openTabsInCorrectGroup, + disabled, + onChange, +}) => { + const { t } = useAppTranslation() + return ( +
+ + {t("settings:advanced.fileEditing.correctTabGroup.label")} + +
+ {t("settings:advanced.fileEditing.correctTabGroup.description")} +
+
+ ) +} + +/** + * Control for opening tabs at end of tab list + */ +const OpenTabsAtEndOfListControl: React.FC = ({ + openTabsAtEndOfList, + disabled, + onChange, +}) => { + const { t } = useAppTranslation() + return ( +
+ + {t("settings:advanced.fileEditing.endOfTabList.label")} + +
+ {t("settings:advanced.fileEditing.endOfTabList.description")} +
+
+ ) +} + +/** + * File editing options control component with mutual exclusivity logic + */ +export const FileEditingOptions: React.FC = ({ + diffEnabled = true, + diffViewAutoFocus = false, + autoCloseRooTabs = false, + autoCloseAllRooTabs = false, + fuzzyMatchThreshold = 1.0, + fileBasedEditing = false, + openTabsInCorrectGroup = true, + openTabsAtEndOfList = false, + onChange, +}) => { + const { t } = useAppTranslation() + + // When file-based editing is enabled, diff settings should be disabled + const isDiffDisabled = fileBasedEditing + // When file-based editing is enabled, other tab behavior toggles should be disabled + const otherTogglesDisabled = fileBasedEditing + const isCorrectControlGroupSettingDisabled = otherTogglesDisabled + const isEndOfListSettingDisabled = otherTogglesDisabled || diffViewAutoFocus || !openTabsInCorrectGroup + + const resetAllButFileBasedEditing = useCallback(() => { + onChange("diffEnabled", false) + onChange("diffViewAutoFocus", false) + onChange("autoCloseRooTabs", false) + onChange("autoCloseAllRooTabs", false) + onChange("openTabsInCorrectGroup", false) + onChange("openTabsAtEndOfList", false) + onChange("fileBasedEditing", true) + }, [onChange]) + + const handleDiffEnabledChange = useCallback( + (e: any) => { + onChange("diffEnabled", e.target.checked) + // if diffEnabled is checked, uncheck fileBasedEditing + if (e.target.checked) { + onChange("fileBasedEditing", false) + onChange("openTabsInCorrectGroup", true) + } + }, + [onChange], + ) + + const handleThresholdChange = useCallback( + (newValue: number[]) => { + onChange("fuzzyMatchThreshold", newValue[0]) + }, + [onChange], + ) + + const handleDiffViewAutoFocusChange = useCallback( + (e: any) => { + onChange("diffViewAutoFocus", e.target.checked) + // If diffViewAutoFocus is unchecked, also uncheck openTabsInCorrectGroup and openTabsAtEndOfList + if (e.target.checked) { + onChange("openTabsAtEndOfList", false) + } + }, + [onChange], + ) + + const handleAutoCloseRooTabsChange = useCallback( + (e: any) => { + onChange("autoCloseRooTabs", e.target.checked) + // If autoCloseRooTabs is unchecked, also uncheck autoCloseAllRooTabs + if (!e.target.checked) { + onChange("autoCloseAllRooTabs", false) + } + }, + [onChange], + ) + + const handleAutoCloseAllRooTabsChange = useCallback( + (e: any) => { + onChange("autoCloseAllRooTabs", e.target.checked) + }, + [onChange], + ) + + const handleFileBasedEditingChange = useCallback( + (e: any) => { + if (e.target.checked) { + // if we enable file-based editing, we reset all other settings + resetAllButFileBasedEditing() + } else { + // if we disable file-based editing, we reset only the file-based editing setting and set diffEnabled to true + onChange("fileBasedEditing", false) + } + }, + [onChange, resetAllButFileBasedEditing], + ) + + const handleOpenTabsInCorrectGroupChange = useCallback( + (e: any) => { + onChange("openTabsInCorrectGroup", e.target.checked) + // If openTabsInCorrectGroup is unchecked, also uncheck openTabsAtEndOfList + if (!e.target.checked) { + onChange("openTabsAtEndOfList", false) + } + }, + [onChange], + ) + + const handleOpenTabsAtEndOfListChange = useCallback( + (e: any) => { + onChange("openTabsAtEndOfList", e.target.checked) + }, + [onChange], + ) + + return ( +
+ {/* File editing options section */} + +
+ +
{t("settings:sections.editingType")}
+
+
+ +
+
+ + {t("settings:advanced.diff.label")} + +
+ {t("settings:advanced.diff.description")} +
+
+
+ +
+
+ + {/* Diff settings section */} + +
+ +
{t("settings:sections.diffSettings")}
+
+
+ +
+ {/* Diff settings section */} +
+ {diffEnabled && !isDiffDisabled && ( + <> + + + + + )} + {isDiffDisabled && ( +
+ {t("settings:advanced.fileEditing.exclusivityNotice")} +
+ )} + {!diffEnabled && ( +
+ {t("settings:advanced.diff.disabledNotice")} +
+ )} +
+
+ + {/* DiffView Behavior Preferences */} + +
+ +
{t("settings:sections.diffViewAutoFocusBehavior")}
+
+
+ +
+
+ {!fileBasedEditing && diffEnabled && ( + <> + + + + + )} + + {fileBasedEditing && ( +
+ {t("settings:advanced.fileEditing.exclusivityNotice")} +
+ )} + {!diffEnabled && ( +
+ {t("settings:advanced.diff.disabledNotice")} +
+ )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 3ece8146af..1209663797 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -18,6 +18,7 @@ import { Database, SquareTerminal, FlaskConical, + Pencil, AlertTriangle, Globe, Info, @@ -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" @@ -80,6 +82,7 @@ export interface SettingsViewRef { const sectionNames = [ "providers", + "fileEditing", "autoApprove", "browser", "checkpoints", @@ -139,7 +142,6 @@ const SettingsView = forwardRef(({ onDone, t browserToolEnabled, browserViewportSize, enableCheckpoints, - diffEnabled, experiments, fuzzyMatchThreshold, maxOpenTabsContext, @@ -176,6 +178,13 @@ const SettingsView = forwardRef(({ onDone, t profileThresholds, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, + diffEnabled, + diffViewAutoFocus, + autoCloseRooTabs, + autoCloseAllRooTabs, + fileBasedEditing, + openTabsInCorrectGroup, + openTabsAtEndOfList, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -284,6 +293,12 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "ttsSpeed", value: ttsSpeed }) vscode.postMessage({ type: "soundVolume", value: soundVolume }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) + vscode.postMessage({ type: "diffViewAutoFocus", bool: diffViewAutoFocus }) + vscode.postMessage({ type: "autoCloseRooTabs", bool: autoCloseRooTabs }) + vscode.postMessage({ type: "autoCloseAllRooTabs", bool: autoCloseAllRooTabs }) + vscode.postMessage({ type: "fileBasedEditing", bool: fileBasedEditing }) + vscode.postMessage({ type: "openTabsInCorrectGroup", bool: openTabsInCorrectGroup }) + vscode.postMessage({ type: "openTabsAtEndOfList", bool: openTabsAtEndOfList }) vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints }) vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize }) vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost }) @@ -394,6 +409,7 @@ const SettingsView = forwardRef(({ onDone, t const sections: { id: SectionName; icon: LucideIcon }[] = useMemo( () => [ { id: "providers", icon: Webhook }, + { id: "fileEditing", icon: Pencil }, { id: "autoApprove", icon: CheckCheck }, { id: "browser", icon: SquareMousePointer }, { id: "checkpoints", icon: GitBranch }, @@ -589,6 +605,30 @@ const SettingsView = forwardRef(({ onDone, t )} + {/* File Editing Section */} + {activeTab === "fileEditing" && ( +
+ +
+ +
{t("settings:sections.fileEditing")}
+
+
+ + +
+ )} + {/* Auto-Approve Section */} {activeTab === "autoApprove" && ( ({ })) // Mock DiffSettingsControl for tests -vi.mock("../DiffSettingsControl", () => ({ +vi.mock("../FileEditingOptions", () => ({ DiffSettingsControl: ({ diffEnabled, fuzzyMatchThreshold, onChange }: any) => (