diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 37c6eecee7..f78d12ddf8 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -12,6 +12,7 @@ export const experimentIds = [ "preventFocusDisruption", "imageGeneration", "runSlashCommand", + "filesChangedOverview", ] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -28,6 +29,7 @@ export const experimentsSchema = z.object({ preventFocusDisruption: z.boolean().optional(), imageGeneration: z.boolean().optional(), runSlashCommand: z.boolean().optional(), + filesChangedOverview: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/packages/types/src/file-changes.ts b/packages/types/src/file-changes.ts new file mode 100644 index 0000000000..b9f8d4e481 --- /dev/null +++ b/packages/types/src/file-changes.ts @@ -0,0 +1,21 @@ +export type FileChangeType = "create" | "delete" | "edit" + +export interface FileChange { + uri: string + type: FileChangeType + // Note: Checkpoint hashes are for backend use, but can be included + fromCheckpoint: string + toCheckpoint: string + // Line count information for display + linesAdded?: number + linesRemoved?: number +} + +/** + * Represents the set of file changes for the webview. + * The `files` property is an array for easy serialization. + */ +export interface FileChangeset { + baseCheckpoint: string + files: FileChange[] +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7a7d5059eb..7acc5990f2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -24,3 +24,4 @@ export * from "./type-fu.js" export * from "./vscode.js" export * from "./providers/index.js" +export * from "./file-changes.js" diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index c39854f697..8efb21acf7 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -27,6 +27,9 @@ vi.mock("vscode", () => ({ dispose: vi.fn(), }), onDidChangeWorkspaceFolders: vi.fn(), + onDidCloseTextDocument: vi.fn().mockReturnValue({ + dispose: vi.fn(), + }), }, languages: { registerCodeActionsProvider: vi.fn(), diff --git a/src/core/context-tracking/FileContextTracker.ts b/src/core/context-tracking/FileContextTracker.ts index 5741b62cfc..26a112c112 100644 --- a/src/core/context-tracking/FileContextTracker.ts +++ b/src/core/context-tracking/FileContextTracker.ts @@ -8,6 +8,7 @@ import fs from "fs/promises" import { ContextProxy } from "../config/ContextProxy" import type { FileMetadataEntry, RecordSource, TaskMetadata } from "./FileContextTrackerTypes" import { ClineProvider } from "../webview/ClineProvider" +import { EventEmitter } from "events" // This class is responsible for tracking file operations that may result in stale context. // If a user modifies a file outside of Roo, the context may become stale and need to be updated. @@ -20,7 +21,7 @@ import { ClineProvider } from "../webview/ClineProvider" // This class is responsible for tracking file operations. // If the full contents of a file are passed to Roo via a tool, mention, or edit, the file is marked as active. // If a file is modified outside of Roo, we detect and track this change to prevent stale context. -export class FileContextTracker { +export class FileContextTracker extends EventEmitter { readonly taskId: string private providerRef: WeakRef @@ -31,6 +32,7 @@ export class FileContextTracker { private checkpointPossibleFiles = new Set() constructor(provider: ClineProvider, taskId: string) { + super() this.providerRef = new WeakRef(provider) this.taskId = taskId } @@ -183,6 +185,8 @@ export class FileContextTracker { newEntry.roo_edit_date = now this.checkpointPossibleFiles.add(filePath) this.markFileAsEditedByRoo(filePath) + // Emit event for Files Changed Overview + this.emit("roo_edited", filePath) break // read_tool/file_mentioned: Roo has read the file via a tool or file mention diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2dd9e55c0b..437990dfc9 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -112,6 +112,7 @@ import { processUserContentMentions } from "../mentions/processUserContentMentio import { getMessagesSinceLastSummary, summarizeConversation } from "../condense" import { Gpt5Metadata, ClineMessageWithMetadata } from "./types" import { MessageQueueService } from "../message-queue/MessageQueueService" +import { TaskFilesChangedState } from "../../services/files-changed/TaskFilesChangedState" import { AutoApprovalHandler } from "./AutoApprovalHandler" @@ -277,6 +278,9 @@ export class Task extends EventEmitter implements TaskLike { public readonly messageQueueService: MessageQueueService private messageQueueStateChangedHandler: (() => void) | undefined + // Files Changed Overview state + private filesChangedState?: TaskFilesChangedState + // Streaming isWaitingForFirstChunk = false isStreaming = false @@ -1598,6 +1602,12 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error disposing file context tracker:", error) } + try { + this.disposeFilesChangedState() + } catch (error) { + console.error("Error disposing Files Changed state:", error) + } + try { // If we're not streaming then `abortStream` won't be called. if (this.isStreaming && this.diffViewProvider.isEditing) { @@ -2936,4 +2946,22 @@ export class Task extends EventEmitter implements TaskLike { console.error(`[Task] Queue processing error:`, e) } } + + // Files Changed Overview helpers + + public ensureFilesChangedState(): TaskFilesChangedState { + if (!this.filesChangedState) { + this.filesChangedState = new TaskFilesChangedState() + } + return this.filesChangedState + } + + public getFilesChangedState(): TaskFilesChangedState | undefined { + return this.filesChangedState + } + + public disposeFilesChangedState(): void { + this.filesChangedState?.dispose() + this.filesChangedState = undefined + } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2c20d0939c..25819ed3b5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -51,7 +51,7 @@ import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" import type { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage" import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes" -import { experimentDefault } from "../../shared/experiments" +import { experimentDefault, EXPERIMENT_IDS } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" import { WebviewMessage } from "../../shared/WebviewMessage" import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels" @@ -94,6 +94,7 @@ import type { ClineMessage } from "@roo-code/types" import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" import { getNonce } from "./getNonce" import { getUri } from "./getUri" +import { FilesChangedMessageHandler } from "../../services/files-changed/FilesChangedMessageHandler" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -137,6 +138,9 @@ export class ClineProvider private taskCreationCallback: (task: Task) => void private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined + private lastCheckpointByTaskId: Map = new Map() + // Files Changed handler + private filesChangedHandler: FilesChangedMessageHandler private recentTasksCache?: string[] private pendingOperations: Map = new Map() @@ -178,6 +182,9 @@ export class ClineProvider await this.postStateToWebview() }) + // Initialize Files Changed handler + this.filesChangedHandler = new FilesChangedMessageHandler(this) + // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) .then((hub) => { @@ -482,12 +489,16 @@ export class ClineProvider // This is used when a subtask is finished and the parent task needs to be // resumed. async finishSubTask(lastMessage: string) { - // Remove the last cline instance from the stack (this is the finished - // subtask). + const childTask = this.getCurrentTask() + const parentFromStack = this.clineStack.length > 1 ? this.clineStack[this.clineStack.length - 2] : undefined + await this.filesChangedHandler.handleChildTaskCompletion(childTask, parentFromStack) + + const previousTask = this.getCurrentTask() await this.removeClineFromStack() - // Resume the last cline instance in the stack (if it exists - this is - // the 'parent' calling task). - await this.getCurrentTask()?.completeSubtask(lastMessage) + const parentTask = this.getCurrentTask() + + await parentTask?.completeSubtask(lastMessage) + await this.filesChangedHandler.applyExperimentsToTask(parentTask) } // Pending Edit Operations Management @@ -589,6 +600,7 @@ export class ClineProvider } this.clearWebviewResources() + this.filesChangedHandler?.dispose(this.getCurrentTask()) // Clean up cloud service event listener if (CloudService.hasInstance()) { @@ -846,6 +858,8 @@ export class ClineProvider } public async createTaskWithHistoryItem(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) { + // Capture current task before removal for potential FCO state transfer + const previousTask = this.getCurrentTask() await this.removeClineFromStack() // If the history item has a saved mode, restore it and its associated API configuration. @@ -920,10 +934,17 @@ export class ClineProvider await this.addClineToStack(task) + if (previousTask && previousTask.taskId === task.taskId) { + this.filesChangedHandler.transferStateBetweenTasks(previousTask, task) + } + this.log( `[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) + // Initialize Files Changed state for this task if setting is enabled + await this.filesChangedHandler.applyExperimentsToTask(task) + // Check if there's a pending edit after checkpoint restoration const operationId = `task-${task.taskId}` const pendingEdit = this.getPendingEditOperation(operationId) @@ -1150,8 +1171,14 @@ export class ClineProvider * @param webview A reference to the extension webview */ private setWebviewMessageListener(webview: vscode.Webview) { - const onReceiveMessage = async (message: WebviewMessage) => - webviewMessageHandler(this, message, this.marketplaceManager) + const onReceiveMessage = async (message: WebviewMessage) => { + // Route Files Changed Overview messages first + if (this.filesChangedHandler.shouldHandleMessage(message)) { + await this.filesChangedHandler.handleMessage(message) + return + } + await webviewMessageHandler(this, message, this.marketplaceManager) + } const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) this.webviewDisposables.push(messageDisposable) @@ -2226,6 +2253,23 @@ export class ClineProvider return this.contextProxy.getValue(key) } + // FilesChanged Message Handler access + public getFilesChangedHandler(): FilesChangedMessageHandler { + return this.filesChangedHandler + } + + // Track last checkpoint per task for delta-based FilesChanged updates + public setLastCheckpointForTask(taskId: string, commitHash: string) { + this.lastCheckpointByTaskId.set(taskId, commitHash) + } + + /** + * Check if a message should be handled by Files Changed service + */ + public getLastCheckpointForTask(taskId: string): string | undefined { + return this.lastCheckpointByTaskId.get(taskId) + } + public async setValue(key: K, value: RooCodeSettings[K]) { await this.contextProxy.setValue(key, value) } @@ -2570,6 +2614,9 @@ export class ClineProvider `[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) + // Initialize Files Changed state for this task if setting is enabled + await this.filesChangedHandler.applyExperimentsToTask(task) + return task } @@ -2594,6 +2641,9 @@ export class ClineProvider // Capture the current instance to detect if rehydrate already occurred elsewhere const originalInstanceId = task.instanceId + // Capture FCO state before task disposal (task.abortTask() will dispose it) + const fcoState = task.getFilesChangedState() + // Begin abort (non-blocking) task.abortTask() @@ -2638,6 +2688,20 @@ export class ClineProvider // Clears task again, so we need to abortTask manually above. await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask }) + + // Restore FCO state to the new task if we captured it + if (fcoState) { + const newTask = this.getCurrentTask() + if (newTask && newTask.taskId === task.taskId) { + const newTaskState = newTask.ensureFilesChangedState() + newTaskState.cloneFrom(fcoState) + // Ensure the restored task is not waiting (prevents clearFilesChangedDisplay) + newTaskState.setWaiting(false) + console.log(`[cancelTask] restored FCO state to recreated task ${newTask.taskId}.${newTask.instanceId}`) + // Re-trigger FCO display since applyExperimentsToTask may have cleared it + await this.filesChangedHandler.applyExperimentsToTask(newTask) + } + } } // Clear the current task without treating it as a subtask. diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bcc9d544c2..b456b4d070 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -152,6 +152,7 @@ vi.mock("vscode", () => ({ showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), }, workspace: { getConfiguration: vi.fn().mockReturnValue({ @@ -188,6 +189,10 @@ vi.mock("../../../api", () => ({ buildApiHandler: vi.fn(), })) +vi.mock("../../checkpoints", () => ({ + getCheckpointService: vi.fn(async () => ({})), +})) + vi.mock("../../prompts/system", () => ({ SYSTEM_PROMPT: vi.fn().mockImplementation(async () => "mocked system prompt"), codeMode: "code", diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 29aefcaeba..ab0ba8213e 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -27,6 +27,7 @@ vi.mock("vscode", () => ({ showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), }, workspace: { getConfiguration: vi.fn().mockReturnValue({ @@ -148,6 +149,10 @@ vi.mock("../../prompts/system", () => ({ codeMode: "code", })) +vi.mock("../../checkpoints", () => ({ + getCheckpointService: vi.fn(async () => ({})), +})) + vi.mock("../../../api/providers/fetchers/modelCache", () => ({ getModels: vi.fn().mockResolvedValue({}), flushModels: vi.fn(), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index af5f9925c3..323ffae1ba 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -33,7 +33,7 @@ import { checkoutRestorePayloadSchema, } from "../../shared/WebviewMessage" import { checkExistKey } from "../../shared/checkExistApiConfig" -import { experimentDefault } from "../../shared/experiments" +import { experimentDefault, EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" import { openFile } from "../../integrations/misc/open-file" import { openImage, saveImage } from "../../integrations/misc/image-handler" @@ -1958,6 +1958,21 @@ export const webviewMessageHandler = async ( await updateGlobalState("experiments", updatedExperiments) + // Simple delegation to FilesChanged handler for universal baseline management + try { + const currentTask = provider.getCurrentTask() + if (currentTask?.taskId) { + await provider + .getFilesChangedHandler() + .handleExperimentToggle( + experiments.isEnabled(updatedExperiments, EXPERIMENT_IDS.FILES_CHANGED_OVERVIEW), + currentTask, + ) + } + } catch (error) { + provider.log(`FilesChanged: Error handling experiment toggle: ${error}`) + } + await provider.postStateToWebview() break } diff --git a/src/extension.ts b/src/extension.ts index 5db0996ad6..e3048947d5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,6 +28,7 @@ import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManager } from "./services/code-index/manager" import { MdmService } from "./services/mdm/MdmService" +import { FcoTextDocumentContentProvider } from "./services/files-changed/FcoTextDocumentContentProvider" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" import { API } from "./extension/api" @@ -253,6 +254,13 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.registerTextDocumentContentProvider(DIFF_VIEW_URI_SCHEME, diffContentProvider), ) + // Register FCO (Files Changed Overview) diff content provider for performance optimization + const fcoContentProvider = FcoTextDocumentContentProvider.getInstance() + context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider("fco-diff", fcoContentProvider)) + + // Register automatic cleanup listener for when diff documents are closed + context.subscriptions.push(fcoContentProvider.registerCloseListener()) + context.subscriptions.push(vscode.window.registerUriHandler({ handleUri })) // Register code actions provider. diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index e68a7cfea1..dee1cab316 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -44,6 +44,11 @@ export abstract class ShadowCheckpointService extends EventEmitter { return this._checkpoints.slice() } + // Provide current checkpoint for consumers (e.g., FilesChanged enable flow) + public getCurrentCheckpoint(): string | undefined { + return this._checkpoints.length > 0 ? this._checkpoints[this._checkpoints.length - 1] : this.baseHash + } + constructor(taskId: string, checkpointsDir: string, workspaceDir: string, log: (message: string) => void) { super() @@ -338,6 +343,73 @@ export abstract class ShadowCheckpointService extends EventEmitter { return result } + public async getDiffStats({ + from, + to, + }: { + from?: string + to?: string + }): Promise> { + if (!this.git) { + throw new Error("Shadow git repo not initialized") + } + + if (!from) { + from = (await this.git.raw(["rev-list", "--max-parents=0", "HEAD"])).trim() + } + + const summary = to ? await this.git.diffSummary([`${from}..${to}`]) : await this.git.diffSummary([from]) + + const map: Record = {} + for (const f of summary.files) { + const anyFile = f as any + const name: string = anyFile.file ?? anyFile.path ?? "" + const insertions: number = typeof anyFile.insertions === "number" ? anyFile.insertions : 0 + const deletions: number = typeof anyFile.deletions === "number" ? anyFile.deletions : 0 + if (name) { + map[name] = { insertions, deletions } + } + } + + return map + } + + /** Restore a single file from a specific checkpoint using git */ + public async restoreFileFromCheckpoint(commitHash: string, relativePath: string): Promise { + if (!this.git) { + throw new Error("Shadow git repo not initialized") + } + + // Normalize path for git usage + const rel = relativePath.replace(/\\/g, "/") + try { + await this.git.raw(["checkout", commitHash, "--", rel]) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + // If the file did not exist at this commit, delete it from workspace + if (/did not match any file|unknown path/i.test(message)) { + const abs = path.join(this.workspaceDir, relativePath) + try { + await fs.unlink(abs) + } catch (e: any) { + if (e?.code !== "ENOENT") throw e + } + } else { + throw error + } + } + } + + // Minimal helper used by Files Changed Overview to fetch file content + public async getContent(commitHash: string, filePath: string): Promise { + if (!this.git) { + throw new Error("Shadow git repo not initialized") + } + + const relPath = path.relative(this.workspaceDir, filePath) + return this.git.show([`${commitHash}:${relPath}`]) + } + /** * EventEmitter */ diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 622a90f39a..ec64e2bb1c 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -824,5 +824,108 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") }) }) + + describe(`${klass.name}#getContent and file rejection workflow`, () => { + it("should delete newly created files when getContent throws 'does not exist' error", async () => { + // Test the complete workflow: create file -> checkpoint -> reject file -> verify deletion + // This tests the integration between ShadowCheckpointService and FilesChanged file rejection + + // 1. Create a new file that didn't exist in the base checkpoint + const newFile = path.join(service.workspaceDir, "newly-created.txt") + await fs.writeFile(newFile, "This file was created by LLM") + + // Verify file exists + expect(await fs.readFile(newFile, "utf-8")).toBe("This file was created by LLM") + + // 2. Save a checkpoint containing the new file + const commit = await service.saveCheckpoint("Add newly created file") + expect(commit?.commit).toBeTruthy() + + // 3. Verify the diff shows the new file + const changes = await service.getDiff({ to: commit!.commit }) + const newFileChange = changes.find((c) => c.paths.relative === "newly-created.txt") + expect(newFileChange).toBeDefined() + expect(newFileChange?.content.before).toBe("") + expect(newFileChange?.content.after).toBe("This file was created by LLM") + + // 4. Simulate FilesChanged file rejection: try to get content from baseHash (should throw) + // This simulates what FilesChangedMessageHandler.revertFileToCheckpoint() does + await expect(service.getContent(service.baseHash!, newFile)).rejects.toThrow( + /does not exist|exists on disk, but not in/, + ) + + // 5. Since getContent threw an error, simulate the deletion logic from FilesChangedMessageHandler + // In real FilesChanged, this would be handled by FilesChangedMessageHandler.revertFileToCheckpoint() + try { + await service.getContent(service.baseHash!, newFile) + } catch (error) { + // File didn't exist in previous checkpoint, so delete it + const errorMessage = error instanceof Error ? error.message : String(error) + if ( + errorMessage.includes("exists on disk, but not in") || + errorMessage.includes("does not exist") + ) { + await fs.unlink(newFile) + } + } + + // 6. Verify the file was deleted + await expect(fs.readFile(newFile, "utf-8")).rejects.toThrow("ENOENT") + }) + + it("should restore file content when getContent succeeds for modified files", async () => { + // Test the complete workflow: modify file -> checkpoint -> reject file -> verify restoration + // This tests the integration between ShadowCheckpointService and FilesChanged file rejection for existing files + + // 1. Modify the existing test file + const originalContent = await fs.readFile(testFile, "utf-8") + expect(originalContent).toBe("Hello, world!") + + await fs.writeFile(testFile, "Modified by LLM") + expect(await fs.readFile(testFile, "utf-8")).toBe("Modified by LLM") + + // 2. Save a checkpoint containing the modification + const commit = await service.saveCheckpoint("Modify existing file") + expect(commit?.commit).toBeTruthy() + + // 3. Verify the diff shows the modification + const changes = await service.getDiff({ to: commit!.commit }) + const modifiedFileChange = changes.find((c) => c.paths.relative === "test.txt") + expect(modifiedFileChange).toBeDefined() + expect(modifiedFileChange?.content.before).toBe("Hello, world!") + expect(modifiedFileChange?.content.after).toBe("Modified by LLM") + + // 4. Simulate FilesChanged file rejection: get original content from baseHash + // This simulates what FilesChangedMessageHandler.revertFileToCheckpoint() does + const previousContent = await service.getContent(service.baseHash!, testFile) + expect(previousContent).toBe("Hello, world!") + + // 5. Simulate the restoration logic from FilesChangedMessageHandler + // In real FilesChanged, this would be handled by FilesChangedMessageHandler.revertFileToCheckpoint() + await fs.writeFile(testFile, previousContent, "utf8") + + // 6. Verify the file was restored to its original content + expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") + }) + + it("should handle getContent with absolute vs relative paths correctly", async () => { + // Test that getContent works with both absolute and relative paths + // This ensures FilesChangedMessageHandler path handling is compatible with ShadowCheckpointService + + const originalContent = await fs.readFile(testFile, "utf-8") + + // Test with absolute path + const absoluteContent = await service.getContent(service.baseHash!, testFile) + expect(absoluteContent).toBe(originalContent) + + // Test with relative path + const relativePath = path.relative(service.workspaceDir, testFile) + const relativeContent = await service.getContent( + service.baseHash!, + path.join(service.workspaceDir, relativePath), + ) + expect(relativeContent).toBe(originalContent) + }) + }) }, ) diff --git a/src/services/files-changed/FcoTextDocumentContentProvider.ts b/src/services/files-changed/FcoTextDocumentContentProvider.ts new file mode 100644 index 0000000000..e6836d3d67 --- /dev/null +++ b/src/services/files-changed/FcoTextDocumentContentProvider.ts @@ -0,0 +1,173 @@ +import * as vscode from "vscode" + +/** + * Dedicated TextDocumentContentProvider for Files Changed Overview (FCO) diff viewing. + * Eliminates base64 encoding in query strings by storing content in memory and serving it on-demand. + */ +export class FcoTextDocumentContentProvider implements vscode.TextDocumentContentProvider { + private contentStore = new Map() + private fileToUriMapping = new Map() + private static instance: FcoTextDocumentContentProvider + + private normalizeKey(rawKey: string): string { + return rawKey.startsWith("/") ? rawKey.slice(1) : rawKey + } + + static getInstance(): FcoTextDocumentContentProvider { + if (!this.instance) { + this.instance = new FcoTextDocumentContentProvider() + } + return this.instance + } + + /** + * Provides text document content for FCO diff URIs. + * Called by VS Code when it needs the actual content for a URI. + */ + provideTextDocumentContent(uri: vscode.Uri): string { + const key = this.normalizeKey(uri.path) + const content = this.contentStore.get(key) + if (!content) { + return "" + } + return content + } + + /** + * Stores before/after content for a diff session and returns clean URIs. + * Uses content-based stable IDs to prevent duplicate diffs for same content. + * No base64 encoding - content is stored in memory and served on-demand. + */ + storeDiffContent( + beforeContent: string, + afterContent: string, + filePath?: string, + ): { beforeUri: string; afterUri: string } { + // Create stable ID based on file path and content hash to prevent duplicates + const contentHash = this.hashContent(beforeContent + afterContent + (filePath || "")) + const beforeKey = this.normalizeKey(`before-${contentHash}`) + const afterKey = this.normalizeKey(`after-${contentHash}`) + + // Check if already exists - reuse existing URIs to prevent duplicate diffs + if (this.contentStore.has(beforeKey)) { + const beforeUri = `fco-diff:${beforeKey}` + const afterUri = `fco-diff:${afterKey}` + + // Update file mapping in case filePath changed + if (filePath) { + this.fileToUriMapping.set(filePath, { beforeUri, afterUri }) + } + + return { beforeUri, afterUri } + } + + // Store new content in memory + this.contentStore.set(beforeKey, beforeContent) + this.contentStore.set(afterKey, afterContent) + + // Return clean URIs without any base64 content + const beforeUri = `fco-diff:${beforeKey}` + const afterUri = `fco-diff:${afterKey}` + + // Track file path to URI mapping for cleanup + if (filePath) { + this.fileToUriMapping.set(filePath, { beforeUri, afterUri }) + } + + return { beforeUri, afterUri } + } + + /** + * Get URIs for a specific file path (for diff tab management) + */ + getUrisForFile(filePath: string): { beforeUri: string; afterUri: string } | undefined { + return this.fileToUriMapping.get(filePath) + } + + /** + * Clean up all content associated with a specific file path + */ + cleanupFile(filePath: string): void { + const uris = this.fileToUriMapping.get(filePath) + if (uris) { + this.cleanup([uris.beforeUri, uris.afterUri]) + this.fileToUriMapping.delete(filePath) + } + } + + /** + * Cleanup stored content to prevent memory leaks. + * Should be called when diff tabs are closed. + */ + cleanup(uris: string[]): void { + uris.forEach((uri) => { + const key = this.normalizeKey(uri.replace("fco-diff:", "")) + this.contentStore.delete(key) + }) + } + + /** + * Create a stable hash from content for consistent IDs + */ + private hashContent(content: string): string { + // Simple hash for stable IDs - ensures same content gets same ID + let hash = 0 + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32-bit integer + } + return Math.abs(hash).toString(36) + } + + /** + * Get total number of stored content items (for debugging/monitoring) + */ + getStoredContentCount(): number { + return this.contentStore.size + } + + /** + * Clear all stored content (for testing or cleanup) + */ + clearAll(): void { + this.contentStore.clear() + this.fileToUriMapping.clear() + } + + /** + * Register a listener to automatically clean up content when diff documents are closed. + * Should be called during extension activation. + */ + registerCloseListener(): vscode.Disposable { + return vscode.workspace.onDidCloseTextDocument((document) => { + // Only handle fco-diff scheme documents + if (document.uri.scheme === "fco-diff") { + this.cleanupByUri(document.uri.toString()) + } + }) + } + + /** + * Clean up content for a specific URI. + * Called when a diff document is closed to prevent memory leaks. + */ + private cleanupByUri(uriString: string): void { + const key = this.normalizeKey(uriString.replace("fco-diff:", "")) + this.contentStore.delete(key) + + // Also clean up any file mappings that reference this URI + for (const [filePath, uris] of this.fileToUriMapping.entries()) { + if (uris.beforeUri === uriString || uris.afterUri === uriString) { + // If both before and after URIs are being removed, delete the mapping + const beforeKey = uris.beforeUri.replace("fco-diff:", "") + const afterKey = uris.afterUri.replace("fco-diff:", "") + + if (!this.contentStore.has(beforeKey) && !this.contentStore.has(afterKey)) { + this.fileToUriMapping.delete(filePath) + } + break + } + } + } +} diff --git a/src/services/files-changed/FilesChangedManager.ts b/src/services/files-changed/FilesChangedManager.ts new file mode 100644 index 0000000000..6488da3d41 --- /dev/null +++ b/src/services/files-changed/FilesChangedManager.ts @@ -0,0 +1,96 @@ +import { FileChange, FileChangeset } from "@roo-code/types" +import type { FileContextTracker } from "../../core/context-tracking/FileContextTracker" + +/** + * Minimal in-memory store for Files Changed Overview state. + * Entries are added/updated one at a time based on the latest diff event. + */ +export class FilesChangedManager { + private baseCheckpoint: string + private files = new Map() + + constructor(baseCheckpoint: string = "HEAD") { + this.baseCheckpoint = baseCheckpoint + } + + public getChanges(): FileChangeset { + return { + baseCheckpoint: this.baseCheckpoint, + files: Array.from(this.files.values()), + } + } + + /** + * For compatibility with existing handler flow. Since we only ever add + * `roo_edited` entries, just return the current changeset. + */ + public async getLLMOnlyChanges(_taskId: string, _fileContextTracker: FileContextTracker): Promise { + return this.getChanges() + } + + public getFileChange(uri: string): FileChange | undefined { + return this.files.get(uri) + } + + public upsertFile(change: FileChange): void { + this.files.set(change.uri, change) + } + + public removeFile(uri: string): void { + this.files.delete(uri) + } + + public acceptChange(uri: string): void { + this.removeFile(uri) + } + + public rejectChange(uri: string): void { + this.removeFile(uri) + } + + public acceptAll(): void { + this.clearFiles() + } + + public rejectAll(): void { + this.clearFiles() + } + + public setBaseline(checkpoint: string): void { + this.baseCheckpoint = checkpoint + } + + public reset(checkpoint: string): void { + this.baseCheckpoint = checkpoint + this.clearFiles() + } + + public clearFiles(): void { + this.files.clear() + } + + public dispose(): void { + this.clearFiles() + } +} + +// Export the error types for backward compatibility +export enum FileChangeErrorType { + PERSISTENCE_FAILED = "PERSISTENCE_FAILED", + FILE_NOT_FOUND = "FILE_NOT_FOUND", + PERMISSION_DENIED = "PERMISSION_DENIED", + DISK_FULL = "DISK_FULL", + GENERIC_ERROR = "GENERIC_ERROR", +} + +export class FileChangeError extends Error { + constructor( + public type: FileChangeErrorType, + public uri?: string, + message?: string, + public originalError?: Error, + ) { + super(message) + this.name = "FileChangeError" + } +} diff --git a/src/services/files-changed/FilesChangedMessageHandler.ts b/src/services/files-changed/FilesChangedMessageHandler.ts new file mode 100644 index 0000000000..a7f56fd2f1 --- /dev/null +++ b/src/services/files-changed/FilesChangedMessageHandler.ts @@ -0,0 +1,1181 @@ +import * as vscode from "vscode" +import * as path from "path" +import { WebviewMessage } from "../../shared/WebviewMessage" +import type { FileChange, FileChangeType } from "@roo-code/types" +import { FilesChangedManager } from "./FilesChangedManager" +import type { TaskFilesChangedState } from "./TaskFilesChangedState" +import { FcoTextDocumentContentProvider } from "./FcoTextDocumentContentProvider" +import { ClineProvider } from "../../core/webview/ClineProvider" +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import { getCheckpointService } from "../../core/checkpoints" +import type { Task } from "../../core/task/Task" +// No experiments migration handler needed anymore; FilesChanged is managed via updateExperimental in webviewMessageHandler + +/** + * Handles FilesChanged-specific webview messages that were previously scattered throughout ClineProvider + */ +export class FilesChangedMessageHandler { + private isEnabled = false + private checkpointEventListener?: (event: any) => void + private trackerListener?: (filePath: string) => void + private trackerDebounce?: ReturnType + private activeTask?: Task + private pendingFiles = new Set() + private burstCount = 0 + private lastEditTime = 0 + + constructor(private provider: ClineProvider) {} + + private getState(task: Task | undefined): TaskFilesChangedState | undefined { + return task?.getFilesChangedState() + } + + private ensureState(task: Task | undefined): TaskFilesChangedState | undefined { + return task?.ensureFilesChangedState() + } + + private isWaitingForTask(task: Task | undefined): boolean { + const state = this.getState(task) + if (!state) { + return false + } + return state.shouldWaitForNextCheckpoint() + } + + private markWaitingForTask(task: Task | undefined, waiting: boolean): void { + const state = waiting ? this.ensureState(task) : this.getState(task) + state?.setWaiting(waiting) + } + + private clearQueuedChildFiles(task: Task | undefined): void { + const state = this.getState(task) + state?.clearQueuedChildUris() + } + + public transferStateBetweenTasks(sourceTask: Task | undefined, targetTask: Task | undefined): void { + if (!sourceTask || !targetTask) { + return + } + if (sourceTask.taskId !== targetTask.taskId) { + return + } + const sourceState = sourceTask.getFilesChangedState?.() + if (!sourceState) { + return + } + const targetState = targetTask.ensureFilesChangedState?.() + if (!targetState) { + return + } + + targetState.cloneFrom(sourceState) + sourceTask.disposeFilesChangedState?.() + } + + private queuePendingUri(task: Task | undefined, uri: string): void { + if (!task) { + return + } + const state = this.ensureState(task)! + state.queueChildUris([uri]) + } + + private async drainQueuedUris(task: Task | undefined, manager?: FilesChangedManager): Promise { + const state = this.getState(task) + if (!state) { + return + } + const pendingUris = state.takeQueuedChildUris() + if (pendingUris.length === 0) { + return + } + const effectiveManager = manager ?? this.ensureManager(task) + if (!effectiveManager) { + return + } + + const baseline = + effectiveManager.getChanges().baseCheckpoint || + task?.checkpointService?.baseHash || + task?.checkpointService?.getCurrentCheckpoint?.() + + if (!baseline) { + // Put URIs back in queue if no baseline available yet + state.queueChildUris(pendingUris) + return + } + + // Process each subtask file individually using the same logic as normal roo_edited events + for (const uri of pendingUris) { + try { + await this.refreshEditedFile(task, uri) + } catch (error) { + // Ignore queued file processing errors + } + } + } + + private async handleFileEdited(task: Task | undefined, filePath: string): Promise { + if (!task || !this.isEnabled) { + return + } + + if (filePath === "*") { + await this.refreshAllFromBaseline(task) + return + } + + if (this.isWaitingForTask(task)) { + this.queuePendingUri(task, filePath) + return + } + + await this.refreshEditedFile(task, filePath) + } + + /** + * Process batch of files that were edited during debounce period + * More efficient than individual file processing during edit bursts + */ + private async handleFileEditBatch(task: Task | undefined): Promise { + if (!task || !this.isEnabled) { + return + } + + // Take all pending files and clear the set + const filesToProcess = Array.from(this.pendingFiles) + this.pendingFiles.clear() + + if (filesToProcess.length === 0) { + return + } + + // Handle wildcard - if any file is "*", do full refresh + if (filesToProcess.includes("*")) { + await this.refreshAllFromBaseline(task) + return + } + + // If waiting for task, queue all pending files + if (this.isWaitingForTask(task)) { + for (const filePath of filesToProcess) { + this.queuePendingUri(task, filePath) + } + return + } + + // Batch process all files together + await this.refreshEditedFilesBatch(task, filesToProcess) + } + + private getManager(task: Task | undefined): FilesChangedManager | undefined { + return this.getState(task)?.getManager() + } + + private ensureManager(task: Task | undefined): FilesChangedManager | undefined { + return this.ensureState(task)?.ensureManager() + } + + private resolveTask(task?: Task): Task | undefined { + if (task) { + return task + } + if (this.activeTask) { + return this.activeTask + } + return this.provider.getCurrentTask() as Task | undefined + } + + /** + * Universal FilesChanged enable/disable handler - ALWAYS waits for next checkpoint when enabled + */ + public async handleExperimentToggle(enabled: boolean, task: Task | undefined): Promise { + if (enabled === this.isEnabled) { + return + } + + if (enabled) { + if (task && !(await this.initializeCheckpointService(task))) { + this.isEnabled = false + return + } + + this.isEnabled = true + this.markWaitingForTask(task, true) + this.clearFilesChangedDisplay() + await this.attachToTask(task) + this.replayTaskChanges(task) + } else { + this.isEnabled = false + const targetTask = task ?? this.activeTask ?? (this.provider.getCurrentTask() as Task | undefined) + if (targetTask) { + this.markWaitingForTask(targetTask, false) + this.clearQueuedChildFiles(targetTask) + targetTask.disposeFilesChangedState() + } + await this.attachToTask(undefined) + this.clearFilesChangedDisplay() + } + } + + /** + * Dispose listeners when provider is torn down + */ + public dispose(task?: Task): void { + const target = task ?? this.activeTask ?? (this.provider.getCurrentTask() as Task | undefined) + if (target) { + this.removeCheckpointListener(target) + this.removeTrackerListener(target) + const state = this.getState(target) + state?.setWaiting(false) + state?.clearQueuedChildUris() + target.disposeFilesChangedState() + } + this.activeTask = undefined + this.clearTrackerDebounce() + // Clear any pending files to prevent memory leaks + this.pendingFiles.clear() + } + + private async attachToTask(task: Task | undefined): Promise { + const state = this.getState(task) + if (this.activeTask === task && !(state && state.hasQueuedChildUris())) { + // If we're already attached and no queued changes, still post current state if enabled + const manager = this.getManager(task) + if (this.isEnabled && manager && !this.isWaitingForTask(task)) { + this.postChanges(manager) + } + return + } + + if (this.activeTask) { + this.removeCheckpointListener(this.activeTask) + this.removeTrackerListener(this.activeTask) + } + + this.activeTask = task + if (!task || !this.isEnabled) { + return + } + + if (task?.checkpointService) { + this.setupCheckpointListener(task) + } + if (task?.fileContextTracker) { + this.setupTrackerListener(task) + } + + let manager = this.getManager(task) + if (!manager) { + manager = this.ensureManager(task) + const baseline = manager?.getChanges().baseCheckpoint + if (!baseline || baseline === "HEAD") { + this.markWaitingForTask(task, true) + } + } + + if (this.isWaitingForTask(task)) { + this.clearFilesChangedDisplay() + return + } + + manager = manager ?? this.ensureManager(task) + if (!manager) { + return + } + + const stateWithManager = state ?? this.getState(task) + if (stateWithManager?.hasQueuedChildUris() && !this.isWaitingForTask(task)) { + await this.drainQueuedUris(task, manager) + } + + this.postChanges(manager) + if (manager.getChanges().baseCheckpoint && manager.getChanges().baseCheckpoint !== "HEAD") { + this.markWaitingForTask(task, false) + } + } + + /** + * Clear FilesChanged display in webview + */ + private clearFilesChangedDisplay(): void { + this.provider.postMessageToWebview({ + type: "filesChanged", + filesChanged: null, + }) + } + + /** + * Set up checkpoint event listener for universal baseline management + */ + private setupCheckpointListener(task: Task): void { + this.removeCheckpointListener(task) + this.checkpointEventListener = async (event: any) => { + if (!this.isEnabled) { + return + } + const state = this.getState(task) + const waiting = this.isWaitingForTask(task) || state?.hasQueuedChildUris() + if (!waiting) { + return + } + + try { + const manager = this.getManager(task) ?? this.ensureManager(task) + if (!manager) { + return + } + + const baseline = event?.fromHash ?? event?.toHash + const hadQueued = state?.hasQueuedChildUris() ?? false + const hasExistingFiles = manager.getChanges().files.length > 0 + + if (baseline) { + if (hasExistingFiles && hadQueued) { + // Adding child files to existing parent files - preserve existing files + manager.setBaseline(baseline) + } else { + // Starting fresh or no existing files - clear is appropriate + manager.reset(baseline) + } + } + this.markWaitingForTask(task, false) + + if (hadQueued) { + await this.drainQueuedUris(task, manager) + } + this.postChanges(manager) + } catch (error) { + this.provider.log(`FilesChanged: Failed to process checkpoint: ${error}`) + } + } + if (task?.checkpointService?.on) { + task.checkpointService.on("checkpoint", this.checkpointEventListener) + } + } + + /** + * Remove checkpoint event listener + */ + private removeCheckpointListener(task: Task | undefined): void { + if (this.checkpointEventListener && task?.checkpointService?.off) { + task.checkpointService.off("checkpoint", this.checkpointEventListener) + } + this.checkpointEventListener = undefined + } + + private setupTrackerListener(task: Task): void { + this.removeTrackerListener(task) + const listeningTask = task + this.trackerListener = (filePath?: string) => { + if (!this.isEnabled) { + return + } + if (typeof filePath !== "string" || filePath.length === 0) { + return + } + + // Add file to pending batch + this.pendingFiles.add(filePath) + + if (this.trackerDebounce) { + clearTimeout(this.trackerDebounce) + } + + // Burst detection for adaptive debouncing + const now = Date.now() + if (now - this.lastEditTime < 1000) { + this.burstCount++ + } else { + this.burstCount = 0 + } + this.lastEditTime = now + + // Adaptive timing: longer delay during bursts to batch more files + const debounceMs = this.burstCount > 3 ? 1000 : 500 + + this.trackerDebounce = setTimeout(async () => { + try { + await this.handleFileEditBatch(listeningTask) + } catch (error) { + // Batch refresh fallback is handled + } + }, debounceMs) + } + if (task?.fileContextTracker?.on) { + task.fileContextTracker.on("roo_edited", this.trackerListener) + } + } + + private removeTrackerListener(task: Task | undefined): void { + if (this.trackerListener && task?.fileContextTracker?.off) { + task.fileContextTracker.off("roo_edited", this.trackerListener) + } + this.trackerListener = undefined + this.clearTrackerDebounce() + } + + private clearTrackerDebounce(): void { + if (this.trackerDebounce) { + clearTimeout(this.trackerDebounce) + this.trackerDebounce = undefined + } + // Clear pending files when clearing debounce to prevent stale batches + this.pendingFiles.clear() + } + + private replayTaskChanges(task: Task | undefined): void { + if (!this.isEnabled || !task) { + return + } + const manager = this.getManager(task) + if (!manager) { + return + } + const changes = manager.getChanges() + if (changes.files.length > 0) { + this.markWaitingForTask(task, false) + this.postChanges(manager) + } + } + + /** + * Check if a message should be handled by FilesChanged + */ + public shouldHandleMessage(message: WebviewMessage): boolean { + const fcoMessageTypes = [ + "webviewReady", + "viewDiff", + "acceptFileChange", + "rejectFileChange", + "acceptAllFileChanges", + "rejectAllFileChanges", + "filesChangedRequest", + "filesChangedBaselineUpdate", + ] + + return fcoMessageTypes.includes(message.type) + } + + /** + * Handle FilesChanged-specific messages + */ + public async handleMessage(message: WebviewMessage): Promise { + const task = this.provider.getCurrentTask() as Task | undefined + + switch (message.type) { + case "webviewReady": { + // Initialize FilesChanged state from settings if not already done + await this.initializeFilesChangedFromSettings() + + const waiting = this.isWaitingForTask(task) + if (this.isEnabled && !waiting) { + const manager = this.getManager(task) ?? this.ensureManager(task) + if (manager) { + this.postChanges(manager) + } + } else if (waiting) { + this.clearFilesChangedDisplay() + } + break + } + + case "viewDiff": { + await this.handleViewDiff(message, task) + break + } + + case "acceptFileChange": { + await this.handleAcceptFileChange(message) + break + } + + case "rejectFileChange": { + await this.handleRejectFileChange(message) + break + } + + case "acceptAllFileChanges": { + await this.handleAcceptAllFileChanges() + break + } + + case "rejectAllFileChanges": { + await this.handleRejectAllFileChanges(message) + break + } + + case "filesChangedRequest": { + await this.handleFilesChangedRequest(message, task) + break + } + + case "filesChangedBaselineUpdate": { + await this.handleFilesChangedBaselineUpdate(message, task) + break + } + } + } + + private async handleViewDiff(message: WebviewMessage, task: Task | undefined): Promise { + const diffFilesChangedManager = this.getManager(task) + if (message.uri && diffFilesChangedManager && task?.checkpointService) { + // Get the file change information + const changeset = diffFilesChangedManager.getChanges() + const fileChange = changeset.files.find((f: any) => f.uri === message.uri) + + if (fileChange) { + try { + // Handle HEAD_WORKING as a special case - it's a UI identifier, not a git reference + let actualFromCheckpoint = fileChange.fromCheckpoint + if (fileChange.fromCheckpoint === "HEAD_WORKING") { + // When fromCheckpoint is HEAD_WORKING, use HEAD as the git baseline + actualFromCheckpoint = "HEAD" + } + + let actualToCheckpoint: string | undefined = fileChange.toCheckpoint + if (fileChange.toCheckpoint === "HEAD_WORKING") { + // When toCheckpoint is HEAD_WORKING, omit the 'to' parameter for working tree diff + actualToCheckpoint = undefined + } + // Get the specific file content from both checkpoints + const diffArgs = actualToCheckpoint + ? { from: actualFromCheckpoint, to: actualToCheckpoint } + : { from: actualFromCheckpoint } + const changes = await task.checkpointService.getDiff(diffArgs) + + // Find the specific file in the changes + const fileChangeData = changes.find((change: any) => change.paths.relative === message.uri) + + if (fileChangeData) { + await this.showFileDiff(message.uri, fileChangeData) + } else { + vscode.window.showInformationMessage(`No changes found for ${message.uri}`) + } + } catch (error) { + this.provider.log(`FilesChanged: Failed to open diff: ${error}`) + vscode.window.showErrorMessage(`Failed to open diff for ${message.uri}: ${error.message}`) + } + } else { + vscode.window.showInformationMessage(`File change not found for ${message.uri}`) + } + } else { + vscode.window.showErrorMessage("Unable to view diff - missing required dependencies") + } + } + + private async showFileDiff(uri: string, fileChangeData: any): Promise { + const beforeContent = fileChangeData.content.before || "" + const afterContent = fileChangeData.content.after || "" + + try { + // Use dedicated FCO content provider - eliminates base64 encoding in query strings! + const fcoProvider = FcoTextDocumentContentProvider.getInstance() + const { beforeUri, afterUri } = fcoProvider.storeDiffContent(beforeContent, afterContent, uri) + + await vscode.commands.executeCommand( + "vscode.diff", + vscode.Uri.parse(beforeUri), + vscode.Uri.parse(afterUri), + `${uri}: Before ↔ After`, + { preview: false }, + ) + } catch (fileError) { + vscode.window.showErrorMessage( + `Failed to open diff view: ${fileError instanceof Error ? fileError.message : String(fileError)}`, + ) + } + } + + /** + * Closes diff tabs for a specific file and cleans up stored content + */ + private async closeDiffTabsForFile(filePath: string): Promise { + const fcoProvider = FcoTextDocumentContentProvider.getInstance() + const uris = fcoProvider.getUrisForFile(filePath) + + if (!uris) return false + + try { + // Find and close diff tabs + const allTabs = vscode.window.tabGroups.all.flatMap((group) => group.tabs) + const diffTabsToClose: vscode.Tab[] = [] + + for (const tab of allTabs) { + if (tab.input instanceof vscode.TabInputTextDiff) { + const originalScheme = tab.input.original?.scheme + const modifiedScheme = tab.input.modified?.scheme + + // Check if this is an FCO diff tab + if (originalScheme === "fco-diff" || modifiedScheme === "fco-diff") { + const originalPath = tab.input.original?.path + const modifiedPath = tab.input.modified?.path + + // Extract hash from URI path to match against our stored URIs + const beforeHash = uris.beforeUri.split(":")[1] + const afterHash = uris.afterUri.split(":")[1] + + if (originalPath?.includes(beforeHash) || modifiedPath?.includes(afterHash)) { + diffTabsToClose.push(tab) + } + } + } + } + + // Close matching diff tabs + for (const tab of diffTabsToClose) { + try { + await vscode.window.tabGroups.close(tab) + } catch (error) { + // Ignore tab closing errors + } + } + + // Clean up stored content + fcoProvider.cleanupFile(filePath) + + return diffTabsToClose.length > 0 + } catch (error) { + // Ignore cleanup errors + } + + return false + } + + private async handleAcceptFileChange(message: WebviewMessage): Promise { + const diffWasOpen = message.uri ? await this.closeDiffTabsForFile(message.uri) : false + const task = this.resolveTask() + const manager = this.getManager(task) ?? this.ensureManager(task) + if (!manager || !message.uri) { + return + } + + // Diff cleanup handled above; only open the file if the diff was shown + + // Accept the change + manager.acceptChange(message.uri) + this.postChanges(manager) + + // Open the modified file for user to see the accepted changes + try { + if (!task?.cwd) { + return + } + // Resolve relative path to absolute path within workspace + const absolutePath = path.resolve(task.cwd, message.uri) + const fileUri = vscode.Uri.file(absolutePath) + if (diffWasOpen) { + await vscode.window.showTextDocument(fileUri, { preview: false }) + } + } catch (error) { + // Ignore file open failures + } + } + + private async handleRejectFileChange(message: WebviewMessage): Promise { + const diffWasOpen = message.uri ? await this.closeDiffTabsForFile(message.uri) : false + const task = this.resolveTask() + const manager = this.getManager(task) ?? this.ensureManager(task) + if (!message.uri || !manager) { + return + } + + const currentTask = task + const checkpointService = currentTask?.checkpointService + if (!currentTask || !checkpointService) { + return + } + + try { + const fileChange = manager.getFileChange(message.uri) + if (fileChange) { + await this.revertFileToCheckpoint(message.uri, fileChange.fromCheckpoint, checkpointService) + } + + manager.rejectChange(message.uri) + this.postChanges(manager) + + // Open the reverted file (if it still exists after reject) + try { + // Resolve relative path to absolute path within workspace + const absolutePath = path.resolve(currentTask.cwd, message.uri) + const fileUri = vscode.Uri.file(absolutePath) + if (diffWasOpen) { + await vscode.window.showTextDocument(fileUri, { preview: false }) + } + } catch (error) { + // File may have been deleted after reject + } + + currentTask.fileContextTracker?.emit?.("user_edited", message.uri) + } catch (error) { + this.provider.log(`FilesChanged: Error during reject: ${error}`) + // Still clean up diff tabs and UI even if revert failed + manager.rejectChange(message.uri) + this.postChanges(manager) + } + } + + private async handleAcceptAllFileChanges(): Promise { + const task = this.resolveTask() + const manager = this.getManager(task) ?? this.ensureManager(task) + const checkpointService = task?.checkpointService + const nextBaseline = checkpointService?.getCurrentCheckpoint?.() + + manager?.acceptAll() + this.prepareForNextCheckpoint(task, nextBaseline, manager) + } + + private async handleRejectAllFileChanges(message: WebviewMessage): Promise { + const task = this.resolveTask() + const manager = this.getManager(task) ?? this.ensureManager(task) + if (!manager) { + return + } + + const currentTask = task + const checkpointService = currentTask?.checkpointService + if (!currentTask || !checkpointService) { + return + } + + try { + const changeset = manager.getChanges() + const specifiedUris = Array.isArray(message.uris) ? new Set(message.uris as string[]) : undefined + const filesToReject = specifiedUris + ? changeset.files.filter((file: any) => specifiedUris.has(file.uri)) + : changeset.files + if (specifiedUris && filesToReject.length === 0) { + return + } + + const isPartialReject = specifiedUris !== undefined && filesToReject.length < changeset.files.length + + for (const fileChange of filesToReject) { + try { + await this.revertFileToCheckpoint(fileChange.uri, fileChange.fromCheckpoint, checkpointService) + } catch (error) { + // Ignore individual file revert failures + } + } + + if (isPartialReject) { + for (const fileChange of filesToReject) { + manager.rejectChange(fileChange.uri) + } + this.postChanges(manager) + } else { + manager.rejectAll() + this.prepareForNextCheckpoint(currentTask, undefined, manager) + } + } catch (error) { + this.provider.log(`FilesChanged: Failed to reject all changes: ${error}`) + + const changeset = manager.getChanges() + const specifiedUris = Array.isArray(message.uris) ? new Set(message.uris as string[]) : undefined + const filesToReject = specifiedUris + ? changeset.files.filter((file: any) => specifiedUris.has(file.uri)) + : changeset.files + if (specifiedUris && filesToReject.length === 0) { + return + } + const isPartialReject = specifiedUris !== undefined && filesToReject.length < changeset.files.length + + if (isPartialReject) { + for (const fileChange of filesToReject) { + manager.rejectChange(fileChange.uri) + } + this.postChanges(manager) + } else { + manager.rejectAll() + this.prepareForNextCheckpoint(currentTask, undefined, manager) + } + } + } + + private async handleFilesChangedRequest(_message: WebviewMessage, inputTask: Task | undefined): Promise { + try { + const task = this.resolveTask(inputTask) + const manager = this.getManager(task) ?? this.ensureManager(task) + if (!manager) { + return + } + this.postChanges(manager) + } catch (error) { + // Error handling files changed request + } + } + + private async handleFilesChangedBaselineUpdate( + message: WebviewMessage, + inputTask: Task | undefined, + ): Promise { + try { + if (!message.baseline) { + return + } + const task = this.resolveTask(inputTask) + const manager = this.getManager(task) ?? this.ensureManager(task) + if (manager) { + this.prepareForNextCheckpoint(task, message.baseline, manager) + } + } catch (error) { + // Failed to update baseline + } + } + + // Legacy filesChangedEnabled pathway removed; FilesChanged is toggled via updateExperimental in webviewMessageHandler + + /** + * Initialize FilesChanged state from global experiments settings + * This ensures the handler state matches saved settings on startup + */ + public async initializeFilesChangedFromSettings(): Promise { + await this.applyExperimentsToTask(this.provider.getCurrentTask() as Task | undefined) + } + + /** + * Safely initialize checkpoint service with error logging + */ + private async initializeCheckpointService(task: Task | undefined): Promise { + if (!task) { + return false + } + + try { + await getCheckpointService(task) + return true + } catch (error) { + this.provider.log(`FilesChanged: Failed to initialize checkpoint service: ${error}`) + return false + } + } + + public async applyExperimentsToTask(task: Task | undefined): Promise { + const state = await this.provider.getState() + const shouldBeEnabled = experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.FILES_CHANGED_OVERVIEW) + if (!task) { + if (this.isEnabled) { + await this.attachToTask(undefined) + } + return + } + if (shouldBeEnabled !== this.isEnabled) { + await this.handleExperimentToggle(shouldBeEnabled, task) + return + } + if (!shouldBeEnabled) { + return + } + + if (!(await this.initializeCheckpointService(task))) { + return + } + + // Only reattach if we're not already attached to this task, or if there are pending child files + const taskState = this.getState(task) + const needsReattach = this.activeTask !== task || (taskState && taskState.hasQueuedChildUris()) + + if (needsReattach) { + await this.attachToTask(task) + this.replayTaskChanges(task) + } else { + this.replayTaskChanges(task) + } + } + + /** + * Revert a specific file to its content at a specific checkpoint + */ + private async revertFileToCheckpoint( + relativeFilePath: string, + fromCheckpoint: string, + checkpointService: any, + ): Promise { + if (!checkpointService?.restoreFileFromCheckpoint) { + throw new Error("Checkpoint service does not support per-file restore") + } + await checkpointService.restoreFileFromCheckpoint(fromCheckpoint, relativeFilePath) + } + + private async refreshEditedFile(task: Task | undefined, filePath: string): Promise { + if (!this.isEnabled) { + return + } + + if (this.isWaitingForTask(task)) { + if (task && filePath !== "*") { + this.queuePendingUri(task, filePath) + } + return + } + + if (filePath === "*") { + await this.refreshAllFromBaseline(task) + return + } + + const checkpointService = task?.checkpointService + if (!checkpointService) { + return + } + + const manager = this.getManager(task) ?? this.ensureManager(task) + if (!manager) { + return + } + + const baseline = manager.getChanges().baseCheckpoint || checkpointService.baseHash + if (!baseline) { + return + } + + try { + const diffs = (await checkpointService.getDiff({ from: baseline })) || [] + const stats = await checkpointService.getDiffStats({ from: baseline }).catch(() => undefined) + const change = diffs.find((entry: any) => entry.paths.relative === filePath) + + if (!change) { + manager.removeFile(filePath) + } else { + const stat = stats?.[filePath] + const mapped = this.mapDiffToFileChange(change, baseline, stat) + manager.upsertFile(mapped) + } + + this.postChanges(manager) + } catch (error) { + // If we get "bad object" errors, reset FCO state to wait for next checkpoint + if ( + error && + typeof error === "object" && + "message" in error && + typeof (error as any).message === "string" && + (error as any).message.includes("fatal: bad object") + ) { + this.provider.log(`FilesChanged: Detected invalid baseline, resetting to wait for next checkpoint`) + this.markWaitingForTask(task, true) + this.clearFilesChangedDisplay() + } + } + } + + /** + * Efficiently process multiple files in a single batch operation + * Avoids multiple getDiff/getDiffStats calls during edit bursts + */ + private async refreshEditedFilesBatch(task: Task | undefined, filePaths: string[]): Promise { + if (!this.isEnabled || filePaths.length === 0) { + return + } + + const checkpointService = task?.checkpointService + if (!checkpointService) { + return + } + + const manager = this.getManager(task) ?? this.ensureManager(task) + if (!manager) { + return + } + + const baseline = manager.getChanges().baseCheckpoint || checkpointService.baseHash + if (!baseline) { + return + } + + try { + // Single getDiff call for all files - much more efficient than individual calls + const diffs = (await checkpointService.getDiff({ from: baseline })) || [] + const stats = await checkpointService.getDiffStats({ from: baseline }).catch(() => undefined) + + // Process each file in our batch + for (const filePath of filePaths) { + const change = diffs.find((entry: any) => entry.paths.relative === filePath) + + if (!change) { + manager.removeFile(filePath) + } else { + const stat = stats?.[filePath] + const mapped = this.mapDiffToFileChange(change, baseline, stat) + manager.upsertFile(mapped) + } + } + // Single UI update for entire batch + this.postChanges(manager) + } catch (error) { + this.provider.log(`FilesChanged: Failed to batch refresh files: ${error}`) + + // Fallback to individual processing if batch fails + for (const filePath of filePaths) { + try { + await this.refreshEditedFile(task, filePath) + } catch (individualError) { + // Individual refresh errors handled in refreshEditedFile + } + } + } + } + + private async refreshAllFromBaseline(task: Task | undefined, existingManager?: FilesChangedManager): Promise { + if (!this.isEnabled || this.isWaitingForTask(task)) { + return + } + + const checkpointService = task?.checkpointService + if (!checkpointService) { + return + } + + const manager = existingManager ?? this.getManager(task) ?? this.ensureManager(task) + if (!manager) { + return + } + + const baseline = manager.getChanges().baseCheckpoint || checkpointService.baseHash + if (!baseline) { + return + } + + try { + const diffs = (await checkpointService.getDiff({ from: baseline })) || [] + const stats = await checkpointService.getDiffStats({ from: baseline }).catch(() => undefined) + + manager.clearFiles() + for (const change of diffs) { + const stat = stats?.[change.paths.relative] + manager.upsertFile(this.mapDiffToFileChange(change, baseline, stat)) + } + + this.postChanges(manager) + } catch (error) { + // Failed to refresh changes from baseline + } + } + + private mapDiffToFileChange( + change: any, + baseline: string, + stat?: { insertions?: number; deletions?: number; added?: number; removed?: number }, + ): FileChange { + const type = (change.paths.newFile ? "create" : change.paths.deletedFile ? "delete" : "edit") as FileChangeType + + // ALWAYS prioritize git diff stats when available - they are the most reliable source + let linesAdded = stat?.insertions ?? stat?.added ?? 0 + let linesRemoved = stat?.deletions ?? stat?.removed ?? 0 + + // Only use lightweight content parsing for edge cases where git stats are completely missing + // This eliminates expensive diffLines fallback for normal operations + if ( + stat === undefined || + (stat.insertions === undefined && + stat.deletions === undefined && + stat.added === undefined && + stat.removed === undefined) + ) { + if (type === "create") { + const after = change.content?.after || "" + linesAdded = after === "" ? 0 : after.split("\n").length + linesRemoved = 0 + } else if (type === "delete") { + const before = change.content?.before || "" + linesAdded = 0 + linesRemoved = before === "" ? 0 : before.split("\n").length + } + // For edits with completely missing stats, accept 0/0 rather than expensive parsing + // Git stats being 0/0 is often correct (whitespace-only changes, etc.) + } + + return { + uri: change.paths.relative, + type, + fromCheckpoint: baseline, + toCheckpoint: "HEAD_WORKING", + linesAdded, + linesRemoved, + } + } + + private postChanges(manager: FilesChangedManager): void { + const changeset = manager.getChanges() + const payload = changeset.files.length > 0 ? changeset : null + this.provider.postMessageToWebview({ type: "filesChanged", filesChanged: payload }) + } + + private prepareForNextCheckpoint( + task: Task | undefined, + baselineHint?: string, + resolvedManager?: FilesChangedManager, + ): void { + const manager = + resolvedManager ?? this.getManager(task) ?? (baselineHint ? this.ensureManager(task) : undefined) + if (manager) { + if (baselineHint) { + manager.reset(baselineHint) + } else { + manager.clearFiles() + } + } + this.markWaitingForTask(task, true) + this.clearFilesChangedDisplay() + const suffix = baselineHint ? ` (${baselineHint})` : "" + } + + public async handleChildTaskCompletion(childTask: Task | undefined, parentTask: Task | undefined): Promise { + if (!childTask) { + return + } + const childState = childTask.getFilesChangedState?.() + const parentState = parentTask?.getFilesChangedState?.() + let pendingUris = childState?.collectCurrentFileUris() ?? [] + + // Fallback: If no files tracked but child has checkpoint service, query it directly + // This handles cases where roo_edited events were missed but actual file changes occurred + if (pendingUris.length === 0 && childTask.checkpointService) { + try { + const fallbackBaseline = + childState?.getManager()?.getChanges().baseCheckpoint || + childTask.checkpointService.baseHash || + childTask.checkpointService.getCurrentCheckpoint?.() + + if (fallbackBaseline) { + const checkpointDiff = await childTask.checkpointService.getDiff({ from: fallbackBaseline }) + if (checkpointDiff && checkpointDiff.length > 0) { + pendingUris = checkpointDiff.map((diff: any) => diff.paths.relative) + } + } + } catch (error) { + this.provider.log(`FilesChanged: Fallback failed for subtask ${childTask.taskId}: ${error}`) + } + } + + // Only dispose child state if it's different from parent state (avoid test edge case) + if (childState && childState !== parentState) { + childTask.disposeFilesChangedState?.() + } + + if (pendingUris.length > 0) { + this.queueChildFiles(parentTask, childTask.taskId, pendingUris) + } + } + + /** + * Queue child FCO files to be processed when parent establishes baseline + */ + public queueChildFiles(parentTask: Task | undefined, childTaskId: string, childFileUris: string[]): void { + if (!parentTask || !childTaskId || childFileUris.length === 0) { + return + } + + if (!this.isEnabled) { + return + } + + const state = this.ensureState(parentTask)! + state.queueChildUris(childFileUris) + + if (!this.isWaitingForTask(parentTask)) { + void this.drainQueuedUris(parentTask) + } + } +} diff --git a/src/services/files-changed/TaskFilesChangedState.ts b/src/services/files-changed/TaskFilesChangedState.ts new file mode 100644 index 0000000000..a09e0f1eaa --- /dev/null +++ b/src/services/files-changed/TaskFilesChangedState.ts @@ -0,0 +1,92 @@ +import { FilesChangedManager } from "./FilesChangedManager" + +export class TaskFilesChangedState { + private manager?: FilesChangedManager + private queuedChildUris = new Set() + private waitingForCheckpoint = false + + public getManager(): FilesChangedManager | undefined { + return this.manager + } + + public ensureManager(): FilesChangedManager { + if (!this.manager) { + this.manager = new FilesChangedManager("HEAD") + } + return this.manager + } + + public dispose(): void { + this.manager?.dispose() + this.manager = undefined + this.queuedChildUris.clear() + this.waitingForCheckpoint = false + } + + public collectCurrentFileUris(): string[] { + if (!this.manager) { + return [] + } + return this.manager.getChanges().files.map((file) => file.uri) + } + + public queueChildUris(uris: string[]): void { + if (uris.length === 0) { + return + } + for (const uri of uris) { + this.queuedChildUris.add(uri) + } + } + + public hasQueuedChildUris(): boolean { + return this.queuedChildUris.size > 0 + } + + public takeQueuedChildUris(): string[] { + if (this.queuedChildUris.size === 0) { + return [] + } + const uris = Array.from(this.queuedChildUris) + this.queuedChildUris.clear() + return uris + } + + public clearQueuedChildUris(): void { + this.queuedChildUris.clear() + } + + public cloneFrom(source: TaskFilesChangedState): void { + if (source === this) { + return + } + + const sourceManager = source.getManager() + if (sourceManager) { + const changes = sourceManager.getChanges() + this.manager?.dispose() + this.manager = new FilesChangedManager(changes.baseCheckpoint ?? "HEAD") + for (const fileChange of changes.files) { + this.manager.upsertFile({ ...fileChange }) + } + } else { + this.manager?.dispose() + this.manager = undefined + } + + this.queuedChildUris = new Set(source.queuedChildUris) + this.waitingForCheckpoint = source.waitingForCheckpoint + } + + public setWaiting(waiting: boolean): void { + this.waitingForCheckpoint = waiting + } + + public isWaiting(): boolean { + return this.waitingForCheckpoint + } + + public shouldWaitForNextCheckpoint(): boolean { + return this.waitingForCheckpoint + } +} diff --git a/src/services/files-changed/__tests__/FcoTextDocumentContentProvider.test.ts b/src/services/files-changed/__tests__/FcoTextDocumentContentProvider.test.ts new file mode 100644 index 0000000000..5591404a8d --- /dev/null +++ b/src/services/files-changed/__tests__/FcoTextDocumentContentProvider.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { FcoTextDocumentContentProvider } from "../FcoTextDocumentContentProvider" + +// Mock VS Code API +vi.mock("vscode", () => ({ + workspace: { + onDidCloseTextDocument: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + window: { + tabGroups: { + all: [], + }, + }, + Uri: { + parse: vi.fn((str: string) => ({ + scheme: str.split(":")[0], + path: str.split(":")[1] || "", + toString: () => str, + })), + }, +})) + +describe("FcoTextDocumentContentProvider", () => { + let provider: FcoTextDocumentContentProvider + + beforeEach(() => { + // Reset singleton instance + ;(FcoTextDocumentContentProvider as any).instance = undefined + provider = FcoTextDocumentContentProvider.getInstance() + provider.clearAll() + vi.clearAllMocks() + }) + + describe("Core Functionality", () => { + it("stores and retrieves diff content", () => { + const beforeContent = "line 1\nline 2" + const afterContent = "line 1\nline 2 modified" + + const { beforeUri, afterUri } = provider.storeDiffContent(beforeContent, afterContent, "test/file.ts") + + expect(beforeUri).toMatch(/^fco-diff:before-/) + expect(afterUri).toMatch(/^fco-diff:after-/) + expect(provider.getStoredContentCount()).toBe(2) + }) + + it("creates different URIs for different file paths", () => { + const content1 = "same content" + const content2 = "same content" + + const result1 = provider.storeDiffContent(content1, content2, "file1.ts") + const result2 = provider.storeDiffContent(content1, content2, "file2.ts") + + // URIs should be different because file paths are different (hash includes file path) + expect(result1.beforeUri).not.toBe(result2.beforeUri) + expect(result1.afterUri).not.toBe(result2.afterUri) + expect(provider.getStoredContentCount()).toBe(4) // Each file gets its own content + }) + + it("provides correct content for URI", () => { + const beforeContent = "original" + const afterContent = "modified" + + const { beforeUri } = provider.storeDiffContent(beforeContent, afterContent) + const uri = { path: beforeUri.replace("fco-diff:", "") } as any + + expect(provider.provideTextDocumentContent(uri)).toBe(beforeContent) + }) + + it("tracks file path to URI mapping", () => { + const { beforeUri, afterUri } = provider.storeDiffContent("before", "after", "test/file.ts") + + const mapping = provider.getUrisForFile("test/file.ts") + expect(mapping).toEqual({ beforeUri, afterUri }) + }) + }) + + describe("Cleanup & Memory Management", () => { + it("cleanupFile removes content and mapping", () => { + const { beforeUri, afterUri } = provider.storeDiffContent("before", "after", "test/file.ts") + + expect(provider.getStoredContentCount()).toBe(2) + expect(provider.getUrisForFile("test/file.ts")).toBeDefined() + + provider.cleanupFile("test/file.ts") + + expect(provider.getStoredContentCount()).toBe(0) + expect(provider.getUrisForFile("test/file.ts")).toBeUndefined() + }) + + it("cleanup removes specific URIs", () => { + const { beforeUri, afterUri } = provider.storeDiffContent("a", "b") + const { beforeUri: beforeUri2 } = provider.storeDiffContent("c", "d") + + expect(provider.getStoredContentCount()).toBe(4) + + provider.cleanup([beforeUri]) + + expect(provider.getStoredContentCount()).toBe(3) + }) + + it("handles multiple files without memory accumulation", () => { + const fileCount = 10 + + // Create multiple diff sessions + for (let i = 0; i < fileCount; i++) { + provider.storeDiffContent(`before ${i}`, `after ${i}`, `file${i}.ts`) + } + + expect(provider.getStoredContentCount()).toBe(fileCount * 2) + + // Clean up individual files + for (let i = 0; i < fileCount; i++) { + provider.cleanupFile(`file${i}.ts`) + } + + // All content should be cleaned up + expect(provider.getStoredContentCount()).toBe(0) + }) + + it("clearAll removes everything", () => { + provider.storeDiffContent("a", "b", "file1.ts") + provider.storeDiffContent("c", "d", "file2.ts") + + expect(provider.getStoredContentCount()).toBe(4) + expect(provider.getUrisForFile("file1.ts")).toBeDefined() + + provider.clearAll() + + expect(provider.getStoredContentCount()).toBe(0) + expect(provider.getUrisForFile("file1.ts")).toBeUndefined() + }) + }) + + describe("Integration", () => { + it("registerCloseListener returns disposable and registers with VS Code", async () => { + const vscode = await import("vscode") + const disposable = provider.registerCloseListener() + + expect(disposable).toEqual({ dispose: expect.any(Function) }) + expect(vscode.workspace.onDidCloseTextDocument).toHaveBeenCalledWith(expect.any(Function)) + }) + + it("singleton pattern works correctly", () => { + const instance1 = FcoTextDocumentContentProvider.getInstance() + const instance2 = FcoTextDocumentContentProvider.getInstance() + + expect(instance1).toBe(instance2) + }) + }) + + describe("Edge Cases", () => { + it("handles cleanup of non-existent content gracefully", () => { + expect(() => provider.cleanupFile("nonexistent.ts")).not.toThrow() + expect(() => provider.cleanup(["fco-diff:nonexistent"])).not.toThrow() + }) + + it("handles storeDiffContent without file path", () => { + const result = provider.storeDiffContent("before", "after") + + expect(result.beforeUri).toMatch(/^fco-diff:before-/) + expect(result.afterUri).toMatch(/^fco-diff:after-/) + expect(provider.getStoredContentCount()).toBe(2) + }) + + it("returns empty string for non-existent URI", () => { + const uri = { path: "non-existent-key" } as any + expect(provider.provideTextDocumentContent(uri)).toBe("") + }) + + it("returns undefined for unmapped file", () => { + const mapping = provider.getUrisForFile("nonexistent.ts") + expect(mapping).toBeUndefined() + }) + }) +}) diff --git a/src/services/files-changed/__tests__/FilesChangedManager.test.ts b/src/services/files-changed/__tests__/FilesChangedManager.test.ts new file mode 100644 index 0000000000..ae23b49b10 --- /dev/null +++ b/src/services/files-changed/__tests__/FilesChangedManager.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { FilesChangedManager } from "../FilesChangedManager" + +const mkChange = (overrides: Partial["files"][number]> = {}) => ({ + uri: "app/foo.ts", + type: "edit" as const, + fromCheckpoint: "base-A", + toCheckpoint: "HEAD_WORKING", + linesAdded: 3, + linesRemoved: 1, + ...overrides, +}) + +describe("FilesChangedManager", () => { + let manager: FilesChangedManager + + beforeEach(() => { + manager = new FilesChangedManager("base-A") + }) + + it("stores file changes via upsert", () => { + manager.upsertFile(mkChange()) + manager.upsertFile(mkChange({ uri: "app/bar.ts" })) + + const snapshot = manager.getChanges() + expect(snapshot.baseCheckpoint).toBe("base-A") + expect(snapshot.files.map((f) => f.uri)).toEqual(["app/foo.ts", "app/bar.ts"]) + }) + + it("removes files when accepted", () => { + manager.upsertFile(mkChange()) + manager.acceptChange("app/foo.ts") + expect(manager.getChanges().files).toHaveLength(0) + }) + + it("removes files when rejected", () => { + manager.upsertFile(mkChange()) + manager.rejectChange("app/foo.ts") + expect(manager.getChanges().files).toHaveLength(0) + }) + + it("clears all files when acceptAll is called", () => { + manager.upsertFile(mkChange()) + manager.upsertFile(mkChange({ uri: "app/bar.ts" })) + manager.acceptAll() + expect(manager.getChanges().files).toHaveLength(0) + }) + + it("resets baseline and clears files when reset is called", () => { + manager.upsertFile(mkChange()) + manager.reset("commit-123") + const snapshot = manager.getChanges() + expect(snapshot.baseCheckpoint).toBe("commit-123") + expect(snapshot.files).toHaveLength(0) + }) + + it("getLLMOnlyChanges mirrors current changeset", async () => { + manager.upsertFile(mkChange()) + const filtered = await manager.getLLMOnlyChanges("task-1", {} as any) + expect(filtered.files.map((f) => f.uri)).toEqual(["app/foo.ts"]) + }) +}) diff --git a/src/services/files-changed/__tests__/FilesChangedMessageHandler.test.ts b/src/services/files-changed/__tests__/FilesChangedMessageHandler.test.ts new file mode 100644 index 0000000000..f6d184fdfe --- /dev/null +++ b/src/services/files-changed/__tests__/FilesChangedMessageHandler.test.ts @@ -0,0 +1,791 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest" +import { EventEmitter } from "events" +import { FilesChangedMessageHandler } from "../FilesChangedMessageHandler" +import { FilesChangedManager } from "../FilesChangedManager" +import { TaskFilesChangedState } from "../TaskFilesChangedState" +import type { Task } from "../../../core/task/Task" +import { getCheckpointService } from "../../../core/checkpoints" + +vi.mock("../../../core/checkpoints", () => ({ + getCheckpointService: vi.fn(async () => ({})), +})) + +class MockCheckpointService extends EventEmitter { + baseHash = "base-A" + getCurrentCheckpoint = vi.fn().mockReturnValue("commit-B") + getDiff = vi.fn(async () => [ + { + paths: { relative: "app/foo.ts", newFile: false, deletedFile: false }, + content: { before: "console.log(1)\n", after: "console.log(2)\n" }, + }, + ]) + getDiffStats = vi.fn( + async (): Promise> => ({ + "app/foo.ts": { insertions: 1, deletions: 1 }, + }), + ) + restoreFileFromCheckpoint = vi.fn(async () => {}) +} + +class MockFileContextTracker extends EventEmitter { + getTaskMetadata = vi.fn(async () => ({ + files_in_context: [{ path: "app/foo.ts", record_source: "roo_edited" }], + })) +} + +describe("FilesChangedMessageHandler", () => { + let handler: FilesChangedMessageHandler + let checkpointService: MockCheckpointService + let fileContextTracker: MockFileContextTracker + let manager: FilesChangedManager + let taskState: TaskFilesChangedState + let posts: any[] + let provider: any + let currentTask: Task + + beforeEach(() => { + vi.useFakeTimers() + vi.mocked(getCheckpointService).mockReset() + checkpointService = new MockCheckpointService() + vi.mocked(getCheckpointService).mockResolvedValue(checkpointService as any) + fileContextTracker = new MockFileContextTracker() + posts = [] + + taskState = new TaskFilesChangedState() + manager = taskState.ensureManager() + const task = { + taskId: "task-1", + checkpointService, + fileContextTracker, + ensureFilesChangedState: vi.fn(() => taskState), + getFilesChangedState: vi.fn(() => taskState), + disposeFilesChangedState: vi.fn(() => taskState.dispose()), + } as unknown as Task + currentTask = task + + provider = { + log: vi.fn(), + getCurrentTask: () => currentTask, + getState: vi.fn(async () => ({ + experiments: { + filesChangedOverview: { enabled: true }, + }, + })), + postMessageToWebview: vi.fn((msg: any) => posts.push(msg)), + } + + handler = new FilesChangedMessageHandler(provider) + }) + + afterEach(() => { + vi.useRealTimers() + handler.dispose() + }) + + const getLatestFilesMessage = () => posts.filter((m) => m.type === "filesChanged").pop() + const advance = async () => { + await vi.advanceTimersByTimeAsync(1200) // Increased to handle new 500-1000ms debounce timing + await Promise.resolve() + } + const emitBaseline = async (service = checkpointService, fromHash = "commit-A", toHash = "commit-B") => { + service.emit("checkpoint", { fromHash, toHash }) + await Promise.resolve() + } + + it("requires a checkpoint baseline before processing edits", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + posts.length = 0 + + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + expect(getLatestFilesMessage()).toBeUndefined() + + await emitBaseline() + expect(taskState.isWaiting()).toBe(false) + // Baseline establishment keeps the UI empty until a file edit arrives + expect(getLatestFilesMessage()).toBeUndefined() + + posts.length = 0 + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + const message = getLatestFilesMessage() + expect(checkpointService.getDiff).toHaveBeenLastCalledWith({ from: "commit-A" }) + expect(message?.filesChanged?.files?.[0]?.uri).toBe("app/foo.ts") + }) + + it("keeps newly attached tasks idle until their first checkpoint", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + await emitBaseline() + + posts.length = 0 + + const secondCheckpointService = new MockCheckpointService() + secondCheckpointService.baseHash = "base-B" + secondCheckpointService.getDiff = vi.fn(async () => [ + { + paths: { relative: "app/child.ts", newFile: false, deletedFile: false }, + content: { before: "console.log('a')\n", after: "console.log('b')\n" }, + }, + ]) + secondCheckpointService.getDiffStats = vi.fn(async () => ({ + "app/child.ts": { insertions: 1, deletions: 0 }, + })) + + const secondTracker = new MockFileContextTracker() + const secondState = new TaskFilesChangedState() + const secondTask = { + taskId: "task-2", + checkpointService: secondCheckpointService, + fileContextTracker: secondTracker, + ensureFilesChangedState: vi.fn(() => secondState), + getFilesChangedState: vi.fn(() => secondState), + disposeFilesChangedState: vi.fn(() => secondState.dispose()), + } as unknown as Task + + vi.mocked(getCheckpointService).mockImplementation(async (requestedTask: Task) => + requestedTask === secondTask ? (secondCheckpointService as any) : (checkpointService as any), + ) + + currentTask = secondTask + await handler.applyExperimentsToTask(secondTask) + + expect(secondState.isWaiting()).toBe(true) + posts.length = 0 + + secondTracker.emit("roo_edited", "app/child.ts") + await advance() + expect(getLatestFilesMessage()).toBeUndefined() + + await emitBaseline(secondCheckpointService, "commit-C", "commit-D") + expect(secondState.isWaiting()).toBe(false) + + await advance() + const message = getLatestFilesMessage() + expect(secondCheckpointService.getDiff).toHaveBeenCalledWith({ from: "commit-C" }) + expect(message?.filesChanged?.files?.[0]?.uri).toBe("app/child.ts") + }) + + it("keeps existing entries when additional files change", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + await emitBaseline() + + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + + checkpointService.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "app/foo.ts", newFile: false, deletedFile: false }, + content: { before: "console.log(2)\n", after: "console.log(3)\n" }, + }, + { + paths: { relative: "app/bar.ts", newFile: false, deletedFile: false }, + content: { before: "let x = 1\n", after: "let x = 2\n" }, + }, + ]) + checkpointService.getDiffStats.mockResolvedValueOnce({ + "app/foo.ts": { insertions: 1, deletions: 1 }, + "app/bar.ts": { insertions: 1, deletions: 1 }, + }) + + fileContextTracker.emit("roo_edited", "app/bar.ts") + await advance() + const message = getLatestFilesMessage() + expect(message?.filesChanged?.files?.map((f: any) => f.uri).sort()).toEqual(["app/bar.ts", "app/foo.ts"]) + }) + + it("rejects only the selected files when uris are provided", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + await emitBaseline() + + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + + checkpointService.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "app/foo.ts", newFile: false, deletedFile: false }, + content: { before: "console.log(2)\n", after: "console.log(3)\n" }, + }, + { + paths: { relative: "app/bar.ts", newFile: false, deletedFile: false }, + content: { before: "let x = 1\n", after: "let x = 5\n" }, + }, + ]) + checkpointService.getDiffStats.mockResolvedValueOnce({ + "app/foo.ts": { insertions: 1, deletions: 1 }, + "app/bar.ts": { insertions: 1, deletions: 1 }, + }) + + fileContextTracker.emit("roo_edited", "app/bar.ts") + await advance() + + posts.length = 0 + checkpointService.restoreFileFromCheckpoint.mockClear() + const rejectAllSpy = vi.spyOn(manager, "rejectAll") + + await handler.handleMessage({ type: "rejectAllFileChanges", uris: ["app/foo.ts"] } as any) + + expect(checkpointService.restoreFileFromCheckpoint).toHaveBeenCalledWith("commit-A", "app/foo.ts") + expect(checkpointService.restoreFileFromCheckpoint).not.toHaveBeenCalledWith("commit-A", "app/bar.ts") + expect(rejectAllSpy).not.toHaveBeenCalled() + expect(taskState.isWaiting()).toBe(false) + + const latest = getLatestFilesMessage() + expect(latest?.filesChanged?.files?.map((f: any) => f.uri)).toEqual(["app/bar.ts"]) + rejectAllSpy.mockRestore() + }) + + it("waits for a new checkpoint after accepting all changes", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + await emitBaseline() + + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + + posts.length = 0 + await handler.handleMessage({ type: "acceptAllFileChanges" } as any) + expect(getLatestFilesMessage()).toEqual({ type: "filesChanged", filesChanged: null }) + expect(taskState.isWaiting()).toBe(true) + + checkpointService.getDiff.mockClear() + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + expect(getLatestFilesMessage()).toEqual({ type: "filesChanged", filesChanged: null }) + expect(checkpointService.getDiff).not.toHaveBeenCalled() + + await emitBaseline(checkpointService, "commit-C", "commit-D") + checkpointService.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "app/foo.ts", newFile: false, deletedFile: false }, + content: { before: "console.log(3)\n", after: "console.log(4)\n" }, + }, + ]) + + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + expect(checkpointService.getDiff).toHaveBeenCalledWith({ from: "commit-C" }) + expect(getLatestFilesMessage()?.filesChanged?.files?.[0]?.uri).toBe("app/foo.ts") + expect(taskState.isWaiting()).toBe(false) + }) + + it("applies completed child changes immediately when baseline is established", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + await emitBaseline() + + checkpointService.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "child.ts", newFile: true, deletedFile: false }, + content: { before: "", after: "console.log(5)\n" }, + }, + ]) + checkpointService.getDiffStats.mockResolvedValueOnce({ "child.ts": { insertions: 1, deletions: 0 } }) + + posts.length = 0 + handler.queueChildFiles(provider.getCurrentTask(), "task-2", ["child.ts"]) + await vi.waitUntil(() => Boolean(getLatestFilesMessage()?.filesChanged?.files?.length)) + + expect(taskState.isWaiting()).toBe(false) + const message = getLatestFilesMessage() + expect(message?.filesChanged?.files?.map((f: any) => f.uri)).toEqual(["child.ts"]) + }) + + it("defers child changes until parent baseline is ready", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + + checkpointService.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "child.ts", newFile: true, deletedFile: false }, + content: { before: "", after: "console.log(5)\n" }, + }, + ]) + checkpointService.getDiffStats.mockResolvedValueOnce({ "child.ts": { insertions: 1, deletions: 0 } }) + + posts.length = 0 + handler.queueChildFiles(provider.getCurrentTask(), "task-2", ["child.ts"]) + expect(getLatestFilesMessage()).toBeUndefined() + + await emitBaseline(checkpointService, "commit-C", "commit-D") + await vi.waitUntil(() => Boolean(getLatestFilesMessage()?.filesChanged?.files?.length)) + + const message = getLatestFilesMessage() + expect(message?.filesChanged?.files?.map((f: any) => f.uri)).toEqual(["child.ts"]) + expect(taskState.isWaiting()).toBe(false) + }) + + it("disposes Files Changed state when experiment is disabled", async () => { + const currentTask = provider.getCurrentTask() + await handler.handleExperimentToggle(true, currentTask) + await handler.handleExperimentToggle(false, currentTask) + expect(currentTask.disposeFilesChangedState).toHaveBeenCalled() + }) + + it("reverts a rejected file and removes it from the list", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + checkpointService.emit("checkpoint", { fromHash: "commit-A", toHash: "commit-B" }) + await Promise.resolve() + + manager.upsertFile({ + uri: "app/foo.ts", + type: "edit", + fromCheckpoint: "commit-A", + toCheckpoint: "HEAD_WORKING", + linesAdded: 1, + linesRemoved: 1, + }) + + posts.length = 0 + const emitSpy = vi.spyOn(fileContextTracker, "emit") + await handler.handleMessage({ type: "rejectFileChange", uri: "app/foo.ts" } as any) + expect(checkpointService.restoreFileFromCheckpoint).toHaveBeenCalledWith("commit-A", "app/foo.ts") + expect(emitSpy).toHaveBeenCalledWith("user_edited", "app/foo.ts") + expect(getLatestFilesMessage()).toEqual({ type: "filesChanged", filesChanged: null }) + }) + + it("recomputes all files when tracker emits wildcard", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + checkpointService.emit("checkpoint", { fromHash: "commit-A", toHash: "commit-B" }) + await Promise.resolve() + + checkpointService.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "app/foo.ts", newFile: false, deletedFile: false }, + content: { before: "console.log(1)\n", after: "console.log(2)\n" }, + }, + ]) + checkpointService.getDiffStats.mockResolvedValueOnce({ "app/foo.ts": { insertions: 1, deletions: 1 } }) + + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + + checkpointService.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "app/foo.ts", newFile: false, deletedFile: false }, + content: { before: "console.log(2)\n", after: "console.log(3)\n" }, + }, + { + paths: { relative: "app/bar.ts", newFile: false, deletedFile: false }, + content: { before: "let x = 1\n", after: "let x = 3\n" }, + }, + ]) + checkpointService.getDiffStats.mockResolvedValueOnce({ + "app/foo.ts": { insertions: 1, deletions: 1 }, + "app/bar.ts": { insertions: 2, deletions: 0 }, + }) + + posts.length = 0 + fileContextTracker.emit("roo_edited", "*") + await advance() + const message = getLatestFilesMessage() + expect(message?.filesChanged?.files?.map((f: any) => f.uri).sort()).toEqual(["app/bar.ts", "app/foo.ts"]) + }) + + it("does not clear queued diff updates when rehydrating task state", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + await emitBaseline() + + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + + posts.length = 0 + const freshState = new TaskFilesChangedState() + freshState.cloneFrom(taskState) + const freshTask = { + taskId: "task-1", + checkpointService, + fileContextTracker, + ensureFilesChangedState: vi.fn(() => freshState), + getFilesChangedState: vi.fn(() => freshState), + disposeFilesChangedState: vi.fn(() => freshState.dispose()), + } as unknown as Task + + const previousGetter = provider.getCurrentTask + provider.getCurrentTask = () => freshTask + await handler.applyExperimentsToTask(freshTask) + provider.getCurrentTask = previousGetter + + const latest = getLatestFilesMessage() + expect((latest?.filesChanged?.files ?? []).length).toBeGreaterThan(0) + }) + + it("clears pending tracker debounce when switching tasks", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + await emitBaseline() + + fileContextTracker.emit("roo_edited", "app/foo.ts") + expect(vi.getTimerCount()).toBe(1) + + const nextState = new TaskFilesChangedState() + const nextTask = { + taskId: "task-2", + checkpointService: new MockCheckpointService(), + fileContextTracker: new MockFileContextTracker(), + ensureFilesChangedState: vi.fn(() => nextState), + getFilesChangedState: vi.fn(() => nextState), + disposeFilesChangedState: vi.fn(() => nextState.dispose()), + } as unknown as Task + + await handler.applyExperimentsToTask(nextTask) + + expect(vi.getTimerCount()).toBe(0) + }) + + it("logs and aborts enable when checkpoint service initialization fails", async () => { + vi.mocked(getCheckpointService).mockRejectedValueOnce(new Error("nope")) + + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + + expect(provider.log).toHaveBeenCalledWith(expect.stringContaining("Failed to initialize checkpoint service")) + expect(fileContextTracker.listenerCount("roo_edited")).toBe(0) + }) + + it("maintains existing files when queued child URIs drain immediately", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + await emitBaseline() + + checkpointService.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "app/foo.ts", newFile: false, deletedFile: false }, + content: { before: "console.log(1)\n", after: "console.log(2)\n" }, + }, + ]) + checkpointService.getDiffStats.mockResolvedValueOnce({ "app/foo.ts": { insertions: 1, deletions: 0 } }) + + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + posts.length = 0 + + const combinedDiff = [ + { + paths: { relative: "app/foo.ts", newFile: false, deletedFile: false }, + content: { before: "console.log(2)\n", after: "console.log(3)\n" }, + }, + { + paths: { relative: "child.ts", newFile: true, deletedFile: false }, + content: { before: "", after: "export const child = 1\n" }, + }, + ] + checkpointService.getDiff.mockResolvedValue(combinedDiff) + checkpointService.getDiffStats.mockResolvedValue({ + "app/foo.ts": { insertions: 1, deletions: 0 }, + "child.ts": { insertions: 1, deletions: 0 }, + }) + + handler.queueChildFiles(provider.getCurrentTask(), "child-task", ["child.ts"]) + await vi.waitUntil(() => Boolean(getLatestFilesMessage()?.filesChanged)) + + const uris = (getLatestFilesMessage()?.filesChanged?.files ?? []).map((f: any) => f.uri) + expect(uris.sort()).toEqual(["app/foo.ts", "child.ts"].sort()) + }) + + it("reattaches listeners when switching to a subtask", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + checkpointService.emit("checkpoint", { fromHash: "commit-A", toHash: "commit-B" }) + await Promise.resolve() + + fileContextTracker.emit("roo_edited", "app/foo.ts") + await advance() + expect(manager.getChanges().baseCheckpoint).toBe("commit-A") + + const childTracker = new MockFileContextTracker() + const childCheckpoint = new MockCheckpointService() + childCheckpoint.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "child.ts", newFile: true, deletedFile: false }, + content: { before: "", after: "console.log(5)\n" }, + }, + ]) + childCheckpoint.getDiffStats.mockResolvedValueOnce({ + "child.ts": { insertions: 1, deletions: 0 }, + }) + + const childState = new TaskFilesChangedState() + const childTask = { + taskId: "task-2", + parentTask: { taskId: "task-1" }, + checkpointService: childCheckpoint, + fileContextTracker: childTracker, + ensureFilesChangedState: vi.fn(() => childState), + getFilesChangedState: vi.fn(() => childState), + disposeFilesChangedState: vi.fn(() => childState.dispose()), + } as unknown as Task + + provider.getCurrentTask = () => childTask + await handler.applyExperimentsToTask(childTask) + expect(childTracker.listenerCount("roo_edited")).toBeGreaterThan(0) + + childCheckpoint.emit("checkpoint", { fromHash: "commit-A", toHash: "commit-B" }) + await Promise.resolve() + childCheckpoint.getDiff.mockResolvedValueOnce([ + { + paths: { relative: "child.ts", newFile: true, deletedFile: false }, + content: { before: "", after: "console.log(5)\n" }, + }, + ]) + childCheckpoint.getDiffStats.mockResolvedValueOnce({ "child.ts": { insertions: 1, deletions: 0 } }) + + provider.getCurrentTask = () => childTask + expect(childState.isWaiting()).toBe(false) + await (handler as any).refreshEditedFile(childTask, "child.ts") + expect(childCheckpoint.getDiff).toHaveBeenCalledWith({ from: "commit-A" }) + const uris = + childState + .getManager() + ?.getChanges() + .files.map((f) => f.uri) ?? [] + expect(uris).toContain("child.ts") + }) + + it("disposes Files Changed state on handler dispose", () => { + handler.dispose() + expect(provider.getCurrentTask().disposeFilesChangedState).toHaveBeenCalled() + }) + + it("queues child URIs when child task completes while waiting for baseline", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + + const childState = new TaskFilesChangedState() + const childManager = childState.ensureManager() + childManager.upsertFile({ + uri: "child.ts", + type: "edit", + fromCheckpoint: "commit-A", + toCheckpoint: "HEAD_WORKING", + linesAdded: 1, + linesRemoved: 0, + }) + const childTask = { + taskId: "task-child", + checkpointService, + fileContextTracker, + ensureFilesChangedState: vi.fn(() => childState), + getFilesChangedState: vi.fn(() => childState), + disposeFilesChangedState: vi.fn(() => childState.dispose()), + } as unknown as Task + + handler.handleChildTaskCompletion(childTask, provider.getCurrentTask()) + + expect(taskState.hasQueuedChildUris()).toBe(true) + expect(taskState.isWaiting()).toBe(true) + expect(childTask.disposeFilesChangedState).toHaveBeenCalled() + }) + + it("deduplicates queued child URIs", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + + const firstChildState = new TaskFilesChangedState() + firstChildState.ensureManager().upsertFile({ + uri: "child.ts", + type: "edit", + fromCheckpoint: "commit-A", + toCheckpoint: "HEAD_WORKING", + linesAdded: 1, + linesRemoved: 0, + }) + const firstChildTask = { + taskId: "task-child-1", + checkpointService, + fileContextTracker, + ensureFilesChangedState: vi.fn(() => firstChildState), + getFilesChangedState: vi.fn(() => firstChildState), + disposeFilesChangedState: vi.fn(() => firstChildState.dispose()), + } as unknown as Task + + handler.handleChildTaskCompletion(firstChildTask, provider.getCurrentTask()) + expect(taskState.hasQueuedChildUris()).toBe(true) + + const secondChildState = new TaskFilesChangedState() + secondChildState.ensureManager().upsertFile({ + uri: "child.ts", + type: "edit", + fromCheckpoint: "commit-B", + toCheckpoint: "HEAD_WORKING", + linesAdded: 2, + linesRemoved: 0, + }) + const secondChildTask = { + taskId: "task-child-2", + checkpointService, + fileContextTracker, + ensureFilesChangedState: vi.fn(() => secondChildState), + getFilesChangedState: vi.fn(() => secondChildState), + disposeFilesChangedState: vi.fn(() => secondChildState.dispose()), + } as unknown as Task + + handler.handleChildTaskCompletion(secondChildTask, provider.getCurrentTask()) + expect(taskState.hasQueuedChildUris()).toBe(true) + await Promise.resolve() + const queued = taskState.takeQueuedChildUris() + expect(queued).toEqual(["child.ts"]) + }) + + it("transfers state between matching tasks", () => { + const sourceState = new TaskFilesChangedState() + sourceState.ensureManager().upsertFile({ + uri: "shared.ts", + type: "edit", + fromCheckpoint: "commit-root", + toCheckpoint: "HEAD_WORKING", + linesAdded: 3, + linesRemoved: 1, + }) + sourceState.queueChildUris(["child-a.ts"]) + sourceState.setWaiting(true) + + const sourceTask = { + taskId: "task-1", + getFilesChangedState: vi.fn(() => sourceState), + disposeFilesChangedState: vi.fn(() => sourceState.dispose()), + } as unknown as Task + + const targetState = new TaskFilesChangedState() + const targetTask = { + taskId: "task-1", + ensureFilesChangedState: vi.fn(() => targetState), + getFilesChangedState: vi.fn(() => targetState), + } as unknown as Task + + handler.transferStateBetweenTasks(sourceTask, targetTask) + + expect( + targetState + .getManager() + ?.getChanges() + .files.map((f) => f.uri), + ).toContain("shared.ts") + expect(targetState.hasQueuedChildUris()).toBe(true) + expect(sourceTask.disposeFilesChangedState).toHaveBeenCalled() + }) + + it("reject handler emits user_edited instead of roo_edited", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + const task = provider.getCurrentTask() + + // Set up file to be rejected + taskState.ensureManager().upsertFile({ + uri: "test-reject.ts", + type: "edit", + fromCheckpoint: "commit-A", + toCheckpoint: "HEAD_WORKING", + linesAdded: 1, + linesRemoved: 0, + }) + + // Mock fileContextTracker emit to capture the event + const emitSpy = vi.fn() + task.fileContextTracker.emit = emitSpy + + // Reject the file + await (handler as any).handleRejectFileChange({ + type: "rejectFileChange", + uri: "test-reject.ts", + }) + + // Verify user_edited event was emitted, not roo_edited + expect(emitSpy).toHaveBeenCalledWith("user_edited", "test-reject.ts") + expect(emitSpy).not.toHaveBeenCalledWith("roo_edited", "test-reject.ts") + + // Verify file was removed from FCO + const manager = taskState.getManager() + expect(manager?.getChanges().files.find((f) => f.uri === "test-reject.ts")).toBeUndefined() + }) + + it("cancel task preserves FCO state", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + + // Set up some files in FCO + taskState.ensureManager().upsertFile({ + uri: "file1.ts", + type: "edit", + fromCheckpoint: "commit-A", + toCheckpoint: "HEAD_WORKING", + linesAdded: 2, + linesRemoved: 1, + }) + + taskState.ensureManager().upsertFile({ + uri: "file2.ts", + type: "create", + fromCheckpoint: "commit-A", + toCheckpoint: "HEAD_WORKING", + linesAdded: 10, + linesRemoved: 0, + }) + + // Capture the original state + const originalFiles = taskState.getManager()?.getChanges().files || [] + expect(originalFiles).toHaveLength(2) + + // Simulate FCO state restoration after cancel (this is the fix we made) + const capturedState = new TaskFilesChangedState() + capturedState.cloneFrom(taskState) + + // Verify the captured state has the files + expect(capturedState.getManager()?.getChanges().files).toHaveLength(2) + + // Verify waiting state can be controlled + capturedState.setWaiting(false) + expect(capturedState.isWaiting()).toBe(false) + expect(capturedState.shouldWaitForNextCheckpoint()).toBe(false) + }) + + it("handles child task completion with fallback for missed roo_edited events", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + const task = provider.getCurrentTask() + + // Create a child task that has checkpoint service but no FCO files (simulating missed roo_edited events) + const childTaskState = new TaskFilesChangedState() + const childManager = childTaskState.ensureManager() + childManager.reset("commit-child-baseline") + const childCheckpointService = { + baseHash: undefined, + getCurrentCheckpoint: vi.fn(() => undefined), + getDiff: vi.fn().mockResolvedValue([ + { + paths: { relative: "missed-file.ts", absolute: "/abs/path/missed-file.ts" }, + content: { before: "old content", after: "new content" }, + }, + ]), + } + + const childTask = { + taskId: "child-task-123", + getFilesChangedState: () => childTaskState, + disposeFilesChangedState: vi.fn(), + checkpointService: childCheckpointService, + } as unknown as Task + + // Before completion, child should have no tracked files (simulating the bug) + expect(childTaskState.collectCurrentFileUris()).toEqual([]) + + // Handle child completion - should trigger fallback + await handler.handleChildTaskCompletion(childTask, task) + + // Verify fallback was triggered - checkpoint service should have been called + expect(childCheckpointService.getDiff).toHaveBeenCalledWith({ from: "commit-child-baseline" }) + + // Verify the files were queued despite not being tracked through roo_edited events + const parentState = task.getFilesChangedState() + expect(parentState?.hasQueuedChildUris()).toBe(true) + }) + + it("fallback gracefully handles missing HEAD~1 reference", async () => { + await handler.handleExperimentToggle(true, provider.getCurrentTask()) + const task = provider.getCurrentTask() + + const childTaskState = new TaskFilesChangedState() + const childCheckpointService = { + baseHash: undefined, + getCurrentCheckpoint: vi.fn(() => undefined), + getDiff: vi.fn(), + } + + const childTask = { + taskId: "child-missing-head", + getFilesChangedState: () => childTaskState, + disposeFilesChangedState: vi.fn(), + checkpointService: childCheckpointService, + } as unknown as Task + + expect(() => handler.handleChildTaskCompletion(childTask, task)).not.toThrow() + expect(childCheckpointService.getDiff).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 66f389f81c..bfca2e0bb6 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -124,10 +124,12 @@ export interface ExtensionMessage { | "showEditMessageDialog" | "commands" | "insertTextIntoTextarea" + | "filesChanged" | "dismissedUpsells" | "organizationSwitchResult" text?: string payload?: any // Add a generic payload for now, can refine later + filesChanged?: any // Files changed data action?: | "chatButtonClicked" | "mcpButtonClicked" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d43a2fce04..e43e3de5b6 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -50,6 +50,14 @@ export interface WebviewMessage { | "alwaysAllowUpdateTodoList" | "followupAutoApproveTimeoutMs" | "webviewDidLaunch" + | "webviewReady" + | "filesChangedRequest" + | "filesChangedBaselineUpdate" + | "viewDiff" + | "acceptFileChange" + | "rejectFileChange" + | "acceptAllFileChanges" + | "rejectAllFileChanges" | "newTask" | "askResponse" | "terminalOperation" @@ -235,6 +243,8 @@ export interface WebviewMessage { disabled?: boolean context?: string dataUri?: string + uri?: string + uris?: string[] askResponse?: ClineAskResponse apiConfiguration?: ProviderSettings images?: string[] @@ -272,9 +282,11 @@ export interface WebviewMessage { mpInstallOptions?: InstallMarketplaceItemOptions config?: Record // Add config to the payload visibility?: ShareVisibility // For share visibility + upsellId?: string // For dismissUpsell hasContent?: boolean // For checkRulesDirectoryResult checkOnly?: boolean // For deleteCustomMode check - upsellId?: string // For dismissUpsell + fileChanges?: any[] // For filesChanged message + baseline?: string // For filesChangedBaselineUpdate list?: string[] // For dismissedUpsells response organizationId?: string | null // For organization switching codeIndexSettings?: { diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 8a3c300441..a8844855f2 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -31,6 +31,7 @@ describe("experiments", () => { preventFocusDisruption: false, imageGeneration: false, runSlashCommand: false, + filesChangedOverview: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) @@ -42,6 +43,7 @@ describe("experiments", () => { preventFocusDisruption: false, imageGeneration: false, runSlashCommand: false, + filesChangedOverview: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true) }) @@ -53,6 +55,7 @@ describe("experiments", () => { preventFocusDisruption: false, imageGeneration: false, runSlashCommand: false, + filesChangedOverview: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 90495c56b7..36daec264b 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -6,6 +6,7 @@ export const EXPERIMENT_IDS = { PREVENT_FOCUS_DISRUPTION: "preventFocusDisruption", IMAGE_GENERATION: "imageGeneration", RUN_SLASH_COMMAND: "runSlashCommand", + FILES_CHANGED_OVERVIEW: "filesChangedOverview", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -22,6 +23,7 @@ export const experimentConfigsMap: Record = { PREVENT_FOCUS_DISRUPTION: { enabled: false }, IMAGE_GENERATION: { enabled: false }, RUN_SLASH_COMMAND: { enabled: false }, + FILES_CHANGED_OVERVIEW: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index aef0bc5eee..af6e55fdbe 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -23,6 +23,8 @@ import { TaskActions } from "./TaskActions" import { ContextWindowProgress } from "./ContextWindowProgress" import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" +import FilesChangedOverview from "../file-changes/FilesChangedOverview" +import ErrorBoundary from "../ErrorBoundary" export interface TaskHeaderProps { task: ClineMessage @@ -327,6 +329,9 @@ const TaskHeader = ({ )} + + + ) diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx index 3f54bec115..e11d4fbf7c 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx @@ -16,6 +16,13 @@ vi.mock("react-i18next", () => ({ return translations[key] || key }, }), + withTranslation: () => (Component: any) => { + Component.defaultProps = { + ...Component.defaultProps, + t: (key: string) => key, + } + return Component + }, Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { return <>{children || i18nKey} }, diff --git a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx index 96efb00673..4532321e0d 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx @@ -65,6 +65,13 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, }), + withTranslation: () => (Component: any) => { + Component.defaultProps = { + ...Component.defaultProps, + t: (key: string) => key, + } + return Component + }, initReactI18next: { type: "3rdParty", init: () => {}, diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index f7ba2732fd..83f22128ce 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -148,6 +148,13 @@ vi.mock("react-i18next", () => ({ return key }, }), + withTranslation: () => (Component: any) => { + Component.defaultProps = { + ...Component.defaultProps, + t: (key: string) => key, + } + return Component + }, initReactI18next: { type: "3rdParty", init: () => {}, diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.fco-boundary.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.fco-boundary.spec.tsx new file mode 100644 index 0000000000..d79416021d --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.fco-boundary.spec.tsx @@ -0,0 +1,115 @@ +import React from "react" +import { render } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import type { ProviderSettings } from "@roo-code/types" + +describe("TaskHeader FilesChangedOverview boundary", () => { + it("catches errors thrown by FilesChangedOverview", async () => { + vi.resetModules() + + vi.doMock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + withTranslation: () => (Component: any) => { + Component.defaultProps = { + ...Component.defaultProps, + t: (key: string) => key, + } + return Component + }, + initReactI18next: { + type: "3rdParty", + init: vi.fn(), + }, + })) + + const postMessageMock = vi.fn() + vi.doMock("@/utils/vscode", () => ({ + vscode: { + postMessage: postMessageMock, + }, + })) + + const mockExtensionState: { + apiConfiguration: ProviderSettings + currentTaskItem: { id: string } | null + clineMessages: any[] + } = { + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-api-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings, + currentTaskItem: { id: "test-task-id" }, + clineMessages: [], + } + + vi.doMock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => mockExtensionState, + })) + + vi.doMock("@src/hooks/useCloudUpsell", () => ({ + useCloudUpsell: () => ({ + isOpen: false, + openUpsell: vi.fn(), + closeUpsell: vi.fn(), + handleConnect: vi.fn(), + }), + })) + + vi.doMock("@src/components/common/DismissibleUpsell", () => ({ + __esModule: true, + default: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + })) + + vi.doMock("@src/components/cloud/CloudUpsellDialog", () => ({ + CloudUpsellDialog: () => null, + })) + + vi.doMock("@roo/array", () => ({ + findLastIndex: (array: any[], predicate: (item: any) => boolean) => { + for (let i = array.length - 1; i >= 0; i--) { + if (predicate(array[i])) { + return i + } + } + return -1 + }, + })) + + vi.doMock("../../file-changes/FilesChangedOverview", () => ({ + __esModule: true, + default: () => { + throw new Error("FilesChangedOverview exploded") + }, + })) + + const { default: TaskHeader } = await import("../TaskHeader") + + const defaultProps = { + task: { type: "say" as const, ts: Date.now(), text: "Test task", images: [] as string[] }, + tokensIn: 100, + tokensOut: 50, + totalCost: 0.05, + contextTokens: 200, + buttonsDisabled: false, + handleCondenseContext: vi.fn(), + } + + const queryClient = new QueryClient() + + expect(() => + render( + + + , + ), + ).not.toThrow() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 6cdbeaf0c6..4949497a85 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -13,6 +13,13 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, // Simple mock that returns the key }), + withTranslation: () => (Component: any) => { + Component.defaultProps = { + ...Component.defaultProps, + t: (key: string) => key, + } + return Component + }, // Mock initReactI18next to prevent initialization errors in tests initReactI18next: { type: "3rdParty", diff --git a/webview-ui/src/components/file-changes/FilesChangedOverview.module.css b/webview-ui/src/components/file-changes/FilesChangedOverview.module.css new file mode 100644 index 0000000000..26f2387669 --- /dev/null +++ b/webview-ui/src/components/file-changes/FilesChangedOverview.module.css @@ -0,0 +1,27 @@ +/* Files Changed Overview CSS Module */ + +.header { + /* Dynamic border bottom controlled via CSS custom property */ + border-bottom: var(--header-border-bottom, none); +} + +.content { + /* Dynamic opacity controlled via CSS custom property */ + opacity: var(--content-opacity, 1); + max-height: 300px; + overflow-y: auto; + transition: opacity 200ms ease-in-out; + position: relative; + padding-top: 0.5rem; +} + +.virtualizationContainer { + /* Dynamic height controlled via CSS custom property */ + height: var(--virtualization-height, auto); + position: relative; +} + +.virtualizationOffset { + /* Dynamic transform controlled via CSS custom property */ + transform: var(--virtualization-transform, translateY(0)); +} diff --git a/webview-ui/src/components/file-changes/FilesChangedOverview.tsx b/webview-ui/src/components/file-changes/FilesChangedOverview.tsx new file mode 100644 index 0000000000..0c4cc582b9 --- /dev/null +++ b/webview-ui/src/components/file-changes/FilesChangedOverview.tsx @@ -0,0 +1,484 @@ +import React from "react" +import { FileChangeset, FileChange } from "@roo-code/types" +import { useTranslation } from "react-i18next" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { vscode } from "@/utils/vscode" +import { useDebouncedAction } from "@/hooks/useDebouncedAction" +import { EXPERIMENT_IDS } from "../../../../src/shared/experiments" +import styles from "./FilesChangedOverview.module.css" + +// Helper functions for file path display +const getFileName = (uri: string): string => { + return uri.split(/[/\\]/).pop() || uri +} + +const getFilePath = (uri: string): string => { + const parts = uri.split(/[/\\]/) + parts.pop() // Remove filename + return parts.length > 0 ? parts.join("/") : "/" +} + +const VIRTUALIZATION_THRESHOLD = 20 +const VIRTUALIZATION_ITEM_HEIGHT = 60 // Approximate height of each file item +const VIRTUALIZATION_MAX_VISIBLE_ITEMS = 10 + +/** + * FilesChangedOverview is a self-managing component that listens for checkpoint events + * and displays file changes. It manages its own state and communicates with the backend + * through VS Code message passing. + */ +const FilesChangedOverview: React.FC = () => { + const { t } = useTranslation() + const { experiments } = useExtensionState() + const filesChangedEnabled = !!experiments?.[EXPERIMENT_IDS.FILES_CHANGED_OVERVIEW] + + // Self-managed state + const [changeset, setChangeset] = React.useState(null) + const [isInitialized, setIsInitialized] = React.useState(false) + + // Refs for dynamic CSS custom properties + const headerRef = React.useRef(null) + const contentRef = React.useRef(null) + const virtualizationContainerRef = React.useRef(null) + const virtualizationOffsetRef = React.useRef(null) + + const files = React.useMemo(() => changeset?.files ?? [], [changeset?.files]) + const [isCollapsed, setIsCollapsed] = React.useState(true) + + // Performance optimization: Use virtualization for large file lists + const [scrollTop, setScrollTop] = React.useState(0) + + const shouldVirtualize = files.length > VIRTUALIZATION_THRESHOLD + + const virtualizationState = React.useMemo(() => { + if (!shouldVirtualize) { + return { + items: files, + totalHeight: "auto" as const, + offsetY: 0, + } + } + + const startIndex = Math.floor(scrollTop / VIRTUALIZATION_ITEM_HEIGHT) + const endIndex = Math.min(startIndex + VIRTUALIZATION_MAX_VISIBLE_ITEMS, files.length) + return { + items: files.slice(startIndex, endIndex), + totalHeight: files.length * VIRTUALIZATION_ITEM_HEIGHT, + offsetY: startIndex * VIRTUALIZATION_ITEM_HEIGHT, + } + }, [files, scrollTop, shouldVirtualize]) + + const { items: visibleItems, totalHeight, offsetY } = virtualizationState + + // Update CSS custom properties for dynamic styling + React.useEffect(() => { + if (headerRef.current) { + const borderValue = isCollapsed ? "none" : "1px solid var(--vscode-panel-border)" + headerRef.current.style.setProperty("--header-border-bottom", borderValue) + } + }, [isCollapsed]) + + React.useEffect(() => { + if (contentRef.current) { + contentRef.current.style.setProperty("--content-opacity", isCollapsed ? "0" : "1") + } + }, [isCollapsed]) + + React.useEffect(() => { + if (virtualizationContainerRef.current && shouldVirtualize) { + virtualizationContainerRef.current.style.setProperty("--virtualization-height", `${totalHeight}px`) + } + }, [totalHeight, shouldVirtualize]) + + React.useEffect(() => { + if (virtualizationOffsetRef.current && shouldVirtualize) { + virtualizationOffsetRef.current.style.setProperty("--virtualization-transform", `translateY(${offsetY}px)`) + } + }, [offsetY, shouldVirtualize]) + + // Debounced click handling for double-click prevention + const { isProcessing, handleWithDebounce } = useDebouncedAction(300) + + // FilesChanged initialization logic + const checkInit = React.useCallback( + (_baseCheckpoint: string) => { + if (!isInitialized) { + setIsInitialized(true) + } + }, + [isInitialized], + ) + + // Update changeset - backend handles filtering, no local filtering needed + const updateChangeset = React.useCallback((newChangeset: FileChangeset) => { + setChangeset(newChangeset) + }, []) + + // Handle checkpoint creation + const handleCheckpointCreated = React.useCallback( + (checkpoint: string, previousCheckpoint?: string) => { + if (!isInitialized) { + checkInit(previousCheckpoint || checkpoint) + } + // Note: Backend automatically sends file changes during checkpoint creation + // No need to request them here - just wait for the filesChanged message + }, + [isInitialized, checkInit], + ) + + // Handle checkpoint restoration (backend will push updated filesChanged state) + const handleCheckpointRestored = React.useCallback((_restoredCheckpoint: string) => { + // No-op: rely on backend to post updated filesChanged after restore + }, []) + + // Action handlers + const handleViewDiff = React.useCallback((uri: string) => { + try { + vscode.postMessage({ type: "viewDiff", uri }) + } catch (error) { + console.error("Failed to view diff for file:", uri, error) + } + }, []) + + const handleAcceptFile = React.useCallback((uri: string) => { + try { + vscode.postMessage({ type: "acceptFileChange", uri }) + // Backend will send updated filesChanged message with filtered results + } catch (error) { + console.error("Failed to accept file change:", uri, error) + } + }, []) + + const handleRejectFile = React.useCallback((uri: string) => { + try { + vscode.postMessage({ type: "rejectFileChange", uri }) + // Backend will send updated filesChanged message with filtered results + } catch (error) { + console.error("Failed to reject file change:", uri, error) + } + }, []) + + const handleAcceptAll = React.useCallback(() => { + try { + vscode.postMessage({ type: "acceptAllFileChanges" }) + // Backend will send updated filesChanged message with filtered results + } catch (error) { + console.error("Failed to accept all file changes:", error) + } + }, []) + + const handleRejectAll = React.useCallback(() => { + try { + const visibleUris = files.map((file) => file.uri) + vscode.postMessage({ type: "rejectAllFileChanges", uris: visibleUris }) + // Backend will send updated filesChanged message with filtered results + } catch (error) { + console.error("Failed to reject all file changes:", error) + } + }, [files]) + + /** + * Handles scroll events for virtualization + * Updates scrollTop state to calculate visible items + */ + const handleScroll = React.useCallback( + (e: React.UIEvent) => { + if (shouldVirtualize) { + setScrollTop(e.currentTarget.scrollTop) + } + }, + [shouldVirtualize], + ) + + // Listen for filesChanged messages from the backend + React.useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + + // Guard against null/undefined/malformed messages + if (!message || typeof message !== "object" || !message.type) { + return + } + + // Only process known message types to avoid noisy updates + switch (message.type) { + case "filesChanged": + // Additional validation for filesChanged message shape + if ("filesChanged" in message) { + if ( + message.filesChanged && + typeof message.filesChanged === "object" && + typeof message.filesChanged.baseCheckpoint === "string" && + Array.isArray(message.filesChanged.files) + ) { + checkInit(message.filesChanged.baseCheckpoint) + updateChangeset(message.filesChanged) + } else if (message.filesChanged === null || message.filesChanged === undefined) { + // Clear the changeset + setChangeset(null) + } + } + break + case "checkpoint": + // Additional validation for checkpoint message shape + if ( + typeof message.checkpoint === "string" && + (message.previousCheckpoint === undefined || typeof message.previousCheckpoint === "string") + ) { + handleCheckpointCreated(message.checkpoint, message.previousCheckpoint) + } + break + case "checkpointRestored": + // Additional validation for checkpointRestored message shape + if (typeof message.checkpoint === "string") { + handleCheckpointRestored(message.checkpoint) + } + break + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, [checkInit, updateChangeset, handleCheckpointCreated, handleCheckpointRestored]) + + // Enable/disable handled by backend; avoid duplicate filesChanged requests + + /** + * Formats line change counts for display - shows only plus/minus numbers + * @param file - The file change to format + * @returns Formatted string with just the line change counts + */ + const formatLineChanges = (file: FileChange): string => { + const added = file.linesAdded || 0 + const removed = file.linesRemoved || 0 + + const parts = [] + if (added > 0) parts.push(`+${added}`) + if (removed > 0) parts.push(`-${removed}`) + + return parts.length > 0 ? parts.join(", ") : "" + } + + // Memoize expensive total calculations + const totalChanges = React.useMemo(() => { + const totalAdded = files.reduce((sum, file) => sum + (file.linesAdded || 0), 0) + const totalRemoved = files.reduce((sum, file) => sum + (file.linesRemoved || 0), 0) + + const parts = [] + if (totalAdded > 0) parts.push(`+${totalAdded}`) + if (totalRemoved > 0) parts.push(`-${totalRemoved}`) + return parts.length > 0 ? ` (${parts.join(", ")})` : "" + }, [files]) + + // Don't render if the feature is disabled or no changes to show + if (!filesChangedEnabled || !changeset || files.length === 0) { + return null + } + + return ( +
+ {/* Collapsible header */} +
setIsCollapsed(!isCollapsed)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setIsCollapsed(!isCollapsed) + } + }} + tabIndex={0} + role="button" + aria-expanded={!isCollapsed} + aria-label={t("file-changes:accessibility.files_list", { + count: files.length, + state: isCollapsed + ? t("file-changes:accessibility.collapsed") + : t("file-changes:accessibility.expanded"), + })} + title={isCollapsed ? t("file-changes:header.expand") : t("file-changes:header.collapse")}> +
+ +

+ + {t("file-changes:summary.count_with_changes", { + count: files.length, + changes: totalChanges, + })} + +

+
+ + {/* Action buttons always visible for quick access */} +
e.stopPropagation()} // Prevent collapse toggle when clicking buttons + > + + +
+
+ + {/* Collapsible content area */} + {!isCollapsed && ( +
+ {shouldVirtualize && ( +
+
+ {visibleItems.map((file) => ( + + ))} +
+
+ )} + {!shouldVirtualize && + files.map((file) => ( + + ))} +
+ )} +
+ ) +} + +/** + * Props for the FileItem component + */ +interface FileItemProps { + /** File change data */ + file: FileChange + /** Function to format line change counts for display */ + formatLineChanges: (file: FileChange) => string + /** Callback to view diff for the file */ + onViewDiff: (uri: string) => void + /** Callback to accept changes for the file */ + onAcceptFile: (uri: string) => void + /** Callback to reject changes for the file */ + onRejectFile: (uri: string) => void + /** Debounced handler to prevent double-clicks */ + handleWithDebounce: (operation: () => void) => void + /** Whether operations are currently being processed */ + isProcessing: boolean + /** Translation function */ + t: (key: string, options?: Record) => string +} + +/** + * FileItem renders a single file change with action buttons. + * Used for both virtualized and non-virtualized rendering. + * Memoized for performance optimization. + */ +const FileItem: React.FC = React.memo( + ({ file, formatLineChanges, onViewDiff, onAcceptFile, onRejectFile, handleWithDebounce, isProcessing, t }) => ( +
+
+
+ {getFileName(file.uri)} + + {t(`file-changes:file_types.${file.type}`)} +
+
+ {getFilePath(file.uri)} +
+
+ +
+
+ {formatLineChanges(file)} +
+
+ + + +
+
+
+ ), +) + +FileItem.displayName = "FileItem" + +export default FilesChangedOverview diff --git a/webview-ui/src/components/file-changes/__tests__/FilesChangedOverview.spec.tsx b/webview-ui/src/components/file-changes/__tests__/FilesChangedOverview.spec.tsx new file mode 100644 index 0000000000..5fe48eb7b8 --- /dev/null +++ b/webview-ui/src/components/file-changes/__tests__/FilesChangedOverview.spec.tsx @@ -0,0 +1,75 @@ +import React from "react" +import { act, fireEvent, render, screen } from "@/utils/test-utils" + +import { EXPERIMENT_IDS } from "../../../../../src/shared/experiments" + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const postMessageMock = vi.fn() + +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: postMessageMock, + }, +})) + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + experiments: { + [EXPERIMENT_IDS.FILES_CHANGED_OVERVIEW]: true, + }, + }), +})) + +vi.mock("@/hooks/useDebouncedAction", () => ({ + useDebouncedAction: () => ({ + isProcessing: false, + handleWithDebounce: (fn: () => void) => fn(), + }), +})) + +describe("FilesChangedOverview", () => { + beforeEach(() => { + postMessageMock.mockClear() + }) + + it("virtualizes large file lists", async () => { + const { default: FilesChangedOverview } = await import("../FilesChangedOverview") + + render() + + const files = Array.from({ length: 40 }, (_, index) => ({ + uri: `path/file-${index}.ts`, + type: "edit" as const, + fromCheckpoint: "base", + toCheckpoint: "HEAD_WORKING", + linesAdded: 1, + linesRemoved: 0, + })) + + await act(async () => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "filesChanged", + filesChanged: { + baseCheckpoint: "base", + files, + }, + }, + }), + ) + }) + + const toggle = await screen.findByRole("button", { name: "file-changes:accessibility.files_list" }) + fireEvent.click(toggle) + + const renderedItems = screen.getAllByTestId(/file-item-/) + + expect(renderedItems.length).toBeLessThanOrEqual(10) + }) +}) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 33d7dc0ec7..a6b8edc8c6 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -235,6 +235,7 @@ describe("mergeExtensionState", () => { newTaskRequireTodos: false, imageGeneration: false, runSlashCommand: false, + filesChangedOverview: false, } as Record, } @@ -255,6 +256,7 @@ describe("mergeExtensionState", () => { newTaskRequireTodos: false, imageGeneration: false, runSlashCommand: false, + filesChangedOverview: false, }) }) }) diff --git a/webview-ui/src/hooks/useDebouncedAction.ts b/webview-ui/src/hooks/useDebouncedAction.ts new file mode 100644 index 0000000000..639a399ddf --- /dev/null +++ b/webview-ui/src/hooks/useDebouncedAction.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef, useState } from "react" + +export function useDebouncedAction(delay = 300) { + const [isProcessing, setIsProcessing] = useState(false) + const timeoutRef = useRef | null>(null) + + const handleWithDebounce = useCallback( + (operation: () => void) => { + if (isProcessing) return + setIsProcessing(true) + try { + operation() + } catch { + // no-op: swallow errors from caller operations + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + timeoutRef.current = setTimeout( + () => { + setIsProcessing(false) + }, + Math.max(0, delay), + ) + }, + [isProcessing, delay], + ) + + // Cleanup effect to prevent memory leaks + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, []) + + return { isProcessing, handleWithDebounce } +} + +export default useDebouncedAction diff --git a/webview-ui/src/i18n/locales/ca/file-changes.json b/webview-ui/src/i18n/locales/ca/file-changes.json new file mode 100644 index 0000000000..5b772b5df5 --- /dev/null +++ b/webview-ui/src/i18n/locales/ca/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Fitxers Modificats", + "expand": "Expandir llista de fitxers", + "collapse": "Contraure llista de fitxers" + }, + "actions": { + "accept_all": "Acceptar Tot", + "reject_all": "Rebutjar Tot", + "accept_file": "Acceptar canvis per aquest fitxer", + "reject_file": "Rebutjar canvis per aquest fitxer", + "view_diff": "Veure Diferències" + }, + "file_types": { + "edit": "editar", + "create": "crear", + "delete": "eliminar" + }, + "line_changes": { + "added": "+{{count}} línies", + "removed": "-{{count}} línies", + "added_removed": "+{{added}}, -{{removed}} línies", + "deleted": "eliminat", + "modified": "modificat" + }, + "summary": { + "count_with_changes": "({{count}}) Fitxers Modificats{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Llista de fitxers modificats. {{count}} fitxers. {{state}}", + "expanded": "Expandit", + "collapsed": "Contret" + } +} diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 2aa6b7ad72..e8032bcd9e 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -754,6 +754,10 @@ "RUN_SLASH_COMMAND": { "name": "Habilitar comandes de barra diagonal iniciades pel model", "description": "Quan està habilitat, Roo pot executar les vostres comandes de barra diagonal per executar fluxos de treball." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Activa la visió general dels fitxers canviats", + "description": "Quan està activat, mostra un panell amb els fitxers que s'han modificat entre punts de control. Això us permet veure les diferències i acceptar/rebutjar canvis individuals." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/file-changes.json b/webview-ui/src/i18n/locales/de/file-changes.json new file mode 100644 index 0000000000..ade80da593 --- /dev/null +++ b/webview-ui/src/i18n/locales/de/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Geänderte Dateien", + "expand": "Dateiliste erweitern", + "collapse": "Dateiliste reduzieren" + }, + "actions": { + "accept_all": "Alle Akzeptieren", + "reject_all": "Alle Ablehnen", + "accept_file": "Änderungen für diese Datei akzeptieren", + "reject_file": "Änderungen für diese Datei ablehnen", + "view_diff": "Unterschiede Anzeigen" + }, + "file_types": { + "edit": "bearbeiten", + "create": "erstellen", + "delete": "löschen" + }, + "line_changes": { + "added": "+{{count}} Zeilen", + "removed": "-{{count}} Zeilen", + "added_removed": "+{{added}}, -{{removed}} Zeilen", + "deleted": "gelöscht", + "modified": "geändert" + }, + "summary": { + "count_with_changes": "({{count}}) Geänderte Dateien{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Liste geänderter Dateien. {{count}} Dateien. {{state}}", + "expanded": "Erweitert", + "collapsed": "Reduziert" + } +} diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index a96e215185..792f2f1440 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -754,6 +754,10 @@ "RUN_SLASH_COMMAND": { "name": "Modellinitierte Slash-Befehle aktivieren", "description": "Wenn aktiviert, kann Roo deine Slash-Befehle ausführen, um Workflows zu starten." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Übersicht über geänderte Dateien aktivieren", + "description": "Wenn aktiviert, wird ein Panel angezeigt, das die zwischen den Prüfpunkten geänderten Dateien anzeigt. Dies ermöglicht es dir, Diffs anzuzeigen und einzelne Änderungen zu akzeptieren/abzulehnen." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/file-changes.json b/webview-ui/src/i18n/locales/en/file-changes.json new file mode 100644 index 0000000000..d8ce319366 --- /dev/null +++ b/webview-ui/src/i18n/locales/en/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Files Changed", + "expand": "Expand files list", + "collapse": "Collapse files list" + }, + "actions": { + "accept_all": "Accept All", + "reject_all": "Reject All", + "accept_file": "Accept changes for this file", + "reject_file": "Reject changes for this file", + "view_diff": "View Diff" + }, + "file_types": { + "edit": "edit", + "create": "create", + "delete": "delete" + }, + "line_changes": { + "added": "+{{count}} lines", + "removed": "-{{count}} lines", + "added_removed": "+{{added}}, -{{removed}} lines", + "deleted": "deleted", + "modified": "modified" + }, + "summary": { + "count_with_changes": "({{count}}) Files Changed{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Files changed list. {{count}} files. {{state}}", + "expanded": "Expanded", + "collapsed": "Collapsed" + } +} diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index aa3199e8e8..b4c302bf9b 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -759,6 +759,10 @@ "RUN_SLASH_COMMAND": { "name": "Enable model-initiated slash commands", "description": "When enabled, Roo can run your slash commands to execute workflows." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Enable Files Changed Overview", + "description": "When enabled, displays a panel showing files that have been modified between checkpoints. This allows you to view diffs and accept/reject individual changes." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/file-changes.json b/webview-ui/src/i18n/locales/es/file-changes.json new file mode 100644 index 0000000000..92fe661210 --- /dev/null +++ b/webview-ui/src/i18n/locales/es/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Archivos Modificados", + "expand": "Expandir lista de archivos", + "collapse": "Contraer lista de archivos" + }, + "actions": { + "accept_all": "Aceptar Todo", + "reject_all": "Rechazar Todo", + "accept_file": "Aceptar cambios para este archivo", + "reject_file": "Rechazar cambios para este archivo", + "view_diff": "Ver Diferencias" + }, + "file_types": { + "edit": "editar", + "create": "crear", + "delete": "eliminar" + }, + "line_changes": { + "added": "+{{count}} líneas", + "removed": "-{{count}} líneas", + "added_removed": "+{{added}}, -{{removed}} líneas", + "deleted": "eliminado", + "modified": "modificado" + }, + "summary": { + "count_with_changes": "({{count}}) Archivos Modificados{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Lista de archivos modificados. {{count}} archivos. {{state}}", + "expanded": "Expandido", + "collapsed": "Contraído" + } +} diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 44c1b9496d..cee201be0b 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -754,6 +754,10 @@ "RUN_SLASH_COMMAND": { "name": "Habilitar comandos slash iniciados por el modelo", "description": "Cuando está habilitado, Roo puede ejecutar tus comandos slash para ejecutar flujos de trabajo." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Habilitar la vista general de archivos modificados", + "description": "Cuando está habilitado, muestra un panel que indica los archivos que se han modificado entre los puntos de control.\nEsto le permite ver las diferencias y aceptar/rechazar cambios individuales." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/file-changes.json b/webview-ui/src/i18n/locales/fr/file-changes.json new file mode 100644 index 0000000000..7ee9471922 --- /dev/null +++ b/webview-ui/src/i18n/locales/fr/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Fichiers Modifiés", + "expand": "Développer la liste des fichiers", + "collapse": "Réduire la liste des fichiers" + }, + "actions": { + "accept_all": "Tout Accepter", + "reject_all": "Tout Rejeter", + "accept_file": "Accepter les modifications pour ce fichier", + "reject_file": "Rejeter les modifications pour ce fichier", + "view_diff": "Voir les Différences" + }, + "file_types": { + "edit": "modifier", + "create": "créer", + "delete": "supprimer" + }, + "line_changes": { + "added": "+{{count}} lignes", + "removed": "-{{count}} lignes", + "added_removed": "+{{added}}, -{{removed}} lignes", + "deleted": "supprimé", + "modified": "modifié" + }, + "summary": { + "count_with_changes": "({{count}}) Fichiers Modifiés{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Liste des fichiers modifiés. {{count}} fichiers. {{state}}", + "expanded": "Développé", + "collapsed": "Réduit" + } +} diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index cd2b3bef87..868efacbcd 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -754,6 +754,10 @@ "RUN_SLASH_COMMAND": { "name": "Activer les commandes slash initiées par le modèle", "description": "Lorsque activé, Roo peut exécuter tes commandes slash pour lancer des workflows." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Activer l'aperçu des fichiers modifiés", + "description": "Lorsqu'il est activé, affiche un panneau montrant les fichiers qui ont été modifiés entre les points de contrôle.\nCela vous permet de visualiser les différences et d'accepter/rejeter les modifications individuelles." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/file-changes.json b/webview-ui/src/i18n/locales/hi/file-changes.json new file mode 100644 index 0000000000..76b46508ef --- /dev/null +++ b/webview-ui/src/i18n/locales/hi/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "परिवर्तित फ़ाइलें", + "expand": "फ़ाइल सूची विस्तृत करें", + "collapse": "फ़ाइल सूची संक्षिप्त करें" + }, + "actions": { + "accept_all": "सभी स्वीकार करें", + "reject_all": "सभी अस्वीकार करें", + "accept_file": "इस फ़ाइल के लिए परिवर्तन स्वीकार करें", + "reject_file": "इस फ़ाइल के लिए परिवर्तन अस्वीकार करें", + "view_diff": "अंतर देखें" + }, + "file_types": { + "edit": "संपादित करें", + "create": "बनाएं", + "delete": "हटाएं" + }, + "line_changes": { + "added": "+{{count}} लाइनें", + "removed": "-{{count}} लाइनें", + "added_removed": "+{{added}}, -{{removed}} लाइनें", + "deleted": "हटाया गया", + "modified": "संशोधित" + }, + "summary": { + "count_with_changes": "({{count}}) परिवर्तित फ़ाइलें{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "परिवर्तित फ़ाइलों की सूची। {{count}} फ़ाइलें। {{state}}", + "expanded": "विस्तृत", + "collapsed": "संक्षिप्त" + } +} diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index d9d8184fbb..741b3dc589 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "मॉडल द्वारा शुरू किए गए स्लैश कमांड सक्षम करें", "description": "जब सक्षम होता है, Roo वर्कफ़्लो चलाने के लिए आपके स्लैश कमांड चला सकता है।" + }, + "FILES_CHANGED_OVERVIEW": { + "name": "फ़ाइलें बदली गईं अवलोकन सक्षम करें", + "description": "सक्षम होने पर, एक पैनल प्रदर्शित करता है जो चौकियों के बीच संशोधित की गई फ़ाइलों को दिखाता है।\nयह आपको अंतर देखने और व्यक्तिगत परिवर्तनों को स्वीकार/अस्वीकार करने की अनुमति देता है।" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/file-changes.json b/webview-ui/src/i18n/locales/id/file-changes.json new file mode 100644 index 0000000000..64ad754aaa --- /dev/null +++ b/webview-ui/src/i18n/locales/id/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "File yang Diubah", + "expand": "Perluas daftar file", + "collapse": "Diciutkan daftar file" + }, + "actions": { + "accept_all": "Terima Semua", + "reject_all": "Tolak Semua", + "accept_file": "Terima perubahan untuk file ini", + "reject_file": "Tolak perubahan untuk file ini", + "view_diff": "Lihat Perbedaan" + }, + "file_types": { + "edit": "edit", + "create": "buat", + "delete": "hapus" + }, + "line_changes": { + "added": "+{{count}} baris", + "removed": "-{{count}} baris", + "added_removed": "+{{added}}, -{{removed}} baris", + "deleted": "dihapus", + "modified": "dimodifikasi" + }, + "summary": { + "count_with_changes": "({{count}}) File yang Diubah{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Daftar file yang diubah. {{count}} file. {{state}}", + "expanded": "Diperluas", + "collapsed": "Diciutkan" + } +} diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 187f42958b..ba991c968a 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -784,6 +784,10 @@ "RUN_SLASH_COMMAND": { "name": "Aktifkan perintah slash yang dimulai model", "description": "Ketika diaktifkan, Roo dapat menjalankan perintah slash Anda untuk mengeksekusi alur kerja." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Aktifkan Ikhtisar File yang Diubah", + "description": "Saat diaktifkan, menampilkan panel yang menunjukkan file yang telah dimodifikasi di antara pos-pos pemeriksaan.\nIni memungkinkan Anda untuk melihat perbedaan dan menerima/menolak perubahan individual." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/file-changes.json b/webview-ui/src/i18n/locales/it/file-changes.json new file mode 100644 index 0000000000..1fc58d1eb2 --- /dev/null +++ b/webview-ui/src/i18n/locales/it/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "File Modificati", + "expand": "Espandi elenco file", + "collapse": "Comprimi elenco file" + }, + "actions": { + "accept_all": "Accetta Tutto", + "reject_all": "Rifiuta Tutto", + "accept_file": "Accetta modifiche per questo file", + "reject_file": "Rifiuta modifiche per questo file", + "view_diff": "Visualizza Differenze" + }, + "file_types": { + "edit": "modifica", + "create": "crea", + "delete": "elimina" + }, + "line_changes": { + "added": "+{{count}} righe", + "removed": "-{{count}} righe", + "added_removed": "+{{added}}, -{{removed}} righe", + "deleted": "eliminato", + "modified": "modificato" + }, + "summary": { + "count_with_changes": "({{count}}) File Modificati{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Elenco file modificati. {{count}} file. {{state}}", + "expanded": "Espanso", + "collapsed": "Compresso" + } +} diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 335877b0a8..8b91093c55 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "Abilita comandi slash avviati dal modello", "description": "Quando abilitato, Roo può eseguire i tuoi comandi slash per eseguire flussi di lavoro." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Abilita la panoramica dei file modificati", + "description": "Se abilitato, visualizza un pannello che mostra i file modificati tra i checkpoint.\nCiò consente di visualizzare le differenze e accettare/rifiutare le singole modifiche." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/file-changes.json b/webview-ui/src/i18n/locales/ja/file-changes.json new file mode 100644 index 0000000000..2e9292e8d4 --- /dev/null +++ b/webview-ui/src/i18n/locales/ja/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "変更されたファイル", + "expand": "ファイルリストを展開", + "collapse": "ファイルリストを折りたたみ" + }, + "actions": { + "accept_all": "すべて承認", + "reject_all": "すべて拒否", + "accept_file": "このファイルの変更を承認", + "reject_file": "このファイルの変更を拒否", + "view_diff": "差分を表示" + }, + "file_types": { + "edit": "編集", + "create": "作成", + "delete": "削除" + }, + "line_changes": { + "added": "+{{count}}行", + "removed": "-{{count}}行", + "added_removed": "+{{added}}, -{{removed}}行", + "deleted": "削除済み", + "modified": "変更済み" + }, + "summary": { + "count_with_changes": "({{count}}) 変更されたファイル{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "変更されたファイルリスト。{{count}}ファイル。{{state}}", + "expanded": "展開済み", + "collapsed": "折りたたみ済み" + } +} diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index bce95eeab2..c31837a386 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "モデル開始スラッシュコマンドを有効にする", "description": "有効にすると、Rooがワークフローを実行するためにあなたのスラッシュコマンドを実行できます。" + }, + "FILES_CHANGED_OVERVIEW": { + "name": "変更されたファイルの概要を有効にする", + "description": "有効にすると、チェックポイント間で変更されたファイルを示すパネルが表示されます。\nこれにより、差分を表示して個々の変更を承認/拒否できます。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/file-changes.json b/webview-ui/src/i18n/locales/ko/file-changes.json new file mode 100644 index 0000000000..d8a3c1bdd1 --- /dev/null +++ b/webview-ui/src/i18n/locales/ko/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "변경된 파일", + "expand": "파일 목록 펼치기", + "collapse": "파일 목록 접기" + }, + "actions": { + "accept_all": "모두 승인", + "reject_all": "모두 거부", + "accept_file": "이 파일의 변경사항 승인", + "reject_file": "이 파일의 변경사항 거부", + "view_diff": "차이점 보기" + }, + "file_types": { + "edit": "편집", + "create": "생성", + "delete": "삭제" + }, + "line_changes": { + "added": "+{{count}}줄", + "removed": "-{{count}}줄", + "added_removed": "+{{added}}, -{{removed}}줄", + "deleted": "삭제됨", + "modified": "수정됨" + }, + "summary": { + "count_with_changes": "({{count}}) 변경된 파일{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "변경된 파일 목록. {{count}}개 파일. {{state}}", + "expanded": "펼쳐짐", + "collapsed": "접혀짐" + } +} diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index f7aec2f4ce..dbd17cab52 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "모델 시작 슬래시 명령 활성화", "description": "활성화되면 Roo가 워크플로를 실행하기 위해 슬래시 명령을 실행할 수 있습니다." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "변경된 파일 개요 활성화", + "description": "활성화하면 체크포인트 간에 수정된 파일을 보여주는 패널이 표시됩니다.\n이를 통해 차이점을 보고 개별 변경 사항을 수락/거부할 수 있습니다." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/file-changes.json b/webview-ui/src/i18n/locales/nl/file-changes.json new file mode 100644 index 0000000000..229966e33f --- /dev/null +++ b/webview-ui/src/i18n/locales/nl/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Gewijzigde Bestanden", + "expand": "Bestandslijst uitklappen", + "collapse": "Bestandslijst inklappen" + }, + "actions": { + "accept_all": "Alles Accepteren", + "reject_all": "Alles Afwijzen", + "accept_file": "Wijzigingen voor dit bestand accepteren", + "reject_file": "Wijzigingen voor dit bestand afwijzen", + "view_diff": "Verschillen Bekijken" + }, + "file_types": { + "edit": "bewerken", + "create": "aanmaken", + "delete": "verwijderen" + }, + "line_changes": { + "added": "+{{count}} regels", + "removed": "-{{count}} regels", + "added_removed": "+{{added}}, -{{removed}} regels", + "deleted": "verwijderd", + "modified": "gewijzigd" + }, + "summary": { + "count_with_changes": "({{count}}) Gewijzigde Bestanden{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Lijst van gewijzigde bestanden. {{count}} bestanden. {{state}}", + "expanded": "Uitgeklapt", + "collapsed": "Ingeklapt" + } +} diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index d5b246e22a..5780cf69b1 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "Model-geïnitieerde slash-commando's inschakelen", "description": "Wanneer ingeschakeld, kan Roo je slash-commando's uitvoeren om workflows uit te voeren." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Overzicht van gewijzigde bestanden inschakelen", + "description": "Indien ingeschakeld, wordt een paneel weergegeven met bestanden die zijn gewijzigd tussen controlepunten.\nHiermee kunt u verschillen bekijken en afzonderlijke wijzigingen accepteren/weigeren." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/file-changes.json b/webview-ui/src/i18n/locales/pl/file-changes.json new file mode 100644 index 0000000000..2f01bdc54c --- /dev/null +++ b/webview-ui/src/i18n/locales/pl/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Zmienione Pliki", + "expand": "Rozwiń listę plików", + "collapse": "Zwiń listę plików" + }, + "actions": { + "accept_all": "Zaakceptuj Wszystkie", + "reject_all": "Odrzuć Wszystkie", + "accept_file": "Zaakceptuj zmiany dla tego pliku", + "reject_file": "Odrzuć zmiany dla tego pliku", + "view_diff": "Zobacz Różnice" + }, + "file_types": { + "edit": "edytuj", + "create": "utwórz", + "delete": "usuń" + }, + "line_changes": { + "added": "+{{count}} linii", + "removed": "-{{count}} linii", + "added_removed": "+{{added}}, -{{removed}} linii", + "deleted": "usunięty", + "modified": "zmodyfikowany" + }, + "summary": { + "count_with_changes": "({{count}}) Zmienione Pliki{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Lista zmienionych plików. {{count}} plików. {{state}}", + "expanded": "Rozwinięte", + "collapsed": "Zwinięte" + } +} diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 385a38fe2c..22babe4eef 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "Włącz polecenia slash inicjowane przez model", "description": "Gdy włączone, Roo może uruchamiać twoje polecenia slash w celu wykonywania przepływów pracy." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Włącz przegląd zmienionych plików", + "description": "Po włączeniu wyświetla panel pokazujący pliki, które zostały zmodyfikowane między punktami kontrolnymi.\nUmożliwia to przeglądanie różnic i akceptowanie/odrzucanie poszczególnych zmian." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/file-changes.json b/webview-ui/src/i18n/locales/pt-BR/file-changes.json new file mode 100644 index 0000000000..8e021ed048 --- /dev/null +++ b/webview-ui/src/i18n/locales/pt-BR/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Arquivos Modificados", + "expand": "Expandir lista de arquivos", + "collapse": "Recolher lista de arquivos" + }, + "actions": { + "accept_all": "Aceitar Todos", + "reject_all": "Rejeitar Todos", + "accept_file": "Aceitar mudanças para este arquivo", + "reject_file": "Rejeitar mudanças para este arquivo", + "view_diff": "Ver Diferenças" + }, + "file_types": { + "edit": "editar", + "create": "criar", + "delete": "excluir" + }, + "line_changes": { + "added": "+{{count}} linhas", + "removed": "-{{count}} linhas", + "added_removed": "+{{added}}, -{{removed}} linhas", + "deleted": "excluído", + "modified": "modificado" + }, + "summary": { + "count_with_changes": "({{count}}) Arquivos Modificados{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Lista de arquivos modificados. {{count}} arquivos. {{state}}", + "expanded": "Expandido", + "collapsed": "Recolhido" + } +} diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index be2ff89ff7..bd54b65346 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "Ativar comandos slash iniciados pelo modelo", "description": "Quando ativado, Roo pode executar seus comandos slash para executar fluxos de trabalho." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Ativar Visão Geral de Arquivos Alterados", + "description": "Quando ativado, exibe um painel mostrando os arquivos que foram modificados entre os pontos de verificação.\nIsso permite que você visualize as diferenças e aceite/rejeite alterações individuais." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/file-changes.json b/webview-ui/src/i18n/locales/ru/file-changes.json new file mode 100644 index 0000000000..a5e2121c4b --- /dev/null +++ b/webview-ui/src/i18n/locales/ru/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Изменённые файлы", + "expand": "Развернуть список файлов", + "collapse": "Свернуть список файлов" + }, + "actions": { + "accept_all": "Принять все", + "reject_all": "Отклонить все", + "accept_file": "Принять изменения для этого файла", + "reject_file": "Отклонить изменения для этого файла", + "view_diff": "Посмотреть различия" + }, + "file_types": { + "edit": "редактировать", + "create": "создать", + "delete": "удалить" + }, + "line_changes": { + "added": "+{{count}} строк", + "removed": "-{{count}} строк", + "added_removed": "+{{added}}, -{{removed}} строк", + "deleted": "удалён", + "modified": "изменён" + }, + "summary": { + "count_with_changes": "({{count}}) Изменённые файлы{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Список изменённых файлов. {{count}} файлов. {{state}}", + "expanded": "Развёрнут", + "collapsed": "Свёрнут" + } +} diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index b429f01f4e..631ca78855 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "Включить слэш-команды, инициированные моделью", "description": "Когда включено, Roo может выполнять ваши слэш-команды для выполнения рабочих процессов." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Включить обзор измененных файлов", + "description": "Если включено, отображается панель с файлами, которые были изменены между контрольными точками.\nЭто позволяет просматривать различия и принимать/отклонять отдельные изменения." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/file-changes.json b/webview-ui/src/i18n/locales/tr/file-changes.json new file mode 100644 index 0000000000..974e273726 --- /dev/null +++ b/webview-ui/src/i18n/locales/tr/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Değiştirilen Dosyalar", + "expand": "Dosya listesini genişlet", + "collapse": "Dosya listesini daralt" + }, + "actions": { + "accept_all": "Hepsini Kabul Et", + "reject_all": "Hepsini Reddet", + "accept_file": "Bu dosya için değişiklikleri kabul et", + "reject_file": "Bu dosya için değişiklikleri reddet", + "view_diff": "Farkları Görüntüle" + }, + "file_types": { + "edit": "düzenle", + "create": "oluştur", + "delete": "sil" + }, + "line_changes": { + "added": "+{{count}} satır", + "removed": "-{{count}} satır", + "added_removed": "+{{added}}, -{{removed}} satır", + "deleted": "silindi", + "modified": "değiştirildi" + }, + "summary": { + "count_with_changes": "({{count}}) Değiştirilen Dosyalar{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Değiştirilen dosyalar listesi. {{count}} dosya. {{state}}", + "expanded": "Genişletildi", + "collapsed": "Daraltıldı" + } +} diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 429599d7ea..ba217a120b 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "Model tarafından başlatılan slash komutlarını etkinleştir", "description": "Etkinleştirildiğinde, Roo iş akışlarını yürütmek için slash komutlarınızı çalıştırabilir." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Değiştirilen Dosyalara Genel Bakışı Etkinleştir", + "description": "Etkinleştirildiğinde, kontrol noktaları arasında değiştirilmiş dosyaları gösteren bir panel görüntüler.\nBu, farklılıkları görüntülemenizi ve bireysel değişiklikleri kabul etmenizi/reddetmenizi sağlar." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/file-changes.json b/webview-ui/src/i18n/locales/vi/file-changes.json new file mode 100644 index 0000000000..6e231cef81 --- /dev/null +++ b/webview-ui/src/i18n/locales/vi/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "Tệp Đã Thay Đổi", + "expand": "Mở rộng danh sách tệp", + "collapse": "Thu gọn danh sách tệp" + }, + "actions": { + "accept_all": "Chấp Nhận Tất Cả", + "reject_all": "Từ Chối Tất Cả", + "accept_file": "Chấp nhận thay đổi cho tệp này", + "reject_file": "Từ chối thay đổi cho tệp này", + "view_diff": "Xem Sự Khác Biệt" + }, + "file_types": { + "edit": "chỉnh sửa", + "create": "tạo", + "delete": "xóa" + }, + "line_changes": { + "added": "+{{count}} dòng", + "removed": "-{{count}} dòng", + "added_removed": "+{{added}}, -{{removed}} dòng", + "deleted": "đã xóa", + "modified": "đã sửa đổi" + }, + "summary": { + "count_with_changes": "({{count}}) Tệp Đã Thay Đổi{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "Danh sách tệp đã thay đổi. {{count}} tệp. {{state}}", + "expanded": "Đã mở rộng", + "collapsed": "Đã thu gọn" + } +} diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 35fd639ba6..714a53903e 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "Bật lệnh slash do mô hình khởi tạo", "description": "Khi được bật, Roo có thể chạy các lệnh slash của bạn để thực hiện các quy trình làm việc." + }, + "FILES_CHANGED_OVERVIEW": { + "name": "Bật Tổng quan về Tệp đã Thay đổi", + "description": "Khi được bật, hiển thị một bảng điều khiển hiển thị các tệp đã được sửa đổi giữa các điểm kiểm tra.\nĐiều này cho phép bạn xem các khác biệt và chấp nhận/từ chối các thay đổi riêng lẻ." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/file-changes.json b/webview-ui/src/i18n/locales/zh-CN/file-changes.json new file mode 100644 index 0000000000..4ebf5a10cc --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-CN/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "已更改文件", + "expand": "展开文件列表", + "collapse": "折叠文件列表" + }, + "actions": { + "accept_all": "全部接受", + "reject_all": "全部拒绝", + "accept_file": "接受此文件的更改", + "reject_file": "拒绝此文件的更改", + "view_diff": "查看差异" + }, + "file_types": { + "edit": "编辑", + "create": "创建", + "delete": "删除" + }, + "line_changes": { + "added": "+{{count}}行", + "removed": "-{{count}}行", + "added_removed": "+{{added}}, -{{removed}}行", + "deleted": "已删除", + "modified": "已修改" + }, + "summary": { + "count_with_changes": "({{count}}) 已更改文件{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "已更改文件列表。{{count}}个文件。{{state}}", + "expanded": "已展开", + "collapsed": "已折叠" + } +} diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index abb3e44637..ea2c35b202 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "启用模型发起的斜杠命令", "description": "启用后 Roo 可运行斜杠命令执行工作流程。" + }, + "FILES_CHANGED_OVERVIEW": { + "name": "启用文件更改概览", + "description": "启用后,显示一个面板,显示检查点之间已修改的文件。\n这使您可以查看差异并接受/拒绝单个更改。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/file-changes.json b/webview-ui/src/i18n/locales/zh-TW/file-changes.json new file mode 100644 index 0000000000..3d612137df --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-TW/file-changes.json @@ -0,0 +1,35 @@ +{ + "header": { + "files_changed": "已變更檔案", + "expand": "展開檔案清單", + "collapse": "摺疊檔案清單" + }, + "actions": { + "accept_all": "全部接受", + "reject_all": "全部拒絕", + "accept_file": "接受此檔案的變更", + "reject_file": "拒絕此檔案的變更", + "view_diff": "檢視差異" + }, + "file_types": { + "edit": "編輯", + "create": "建立", + "delete": "刪除" + }, + "line_changes": { + "added": "+{{count}}行", + "removed": "-{{count}}行", + "added_removed": "+{{added}}, -{{removed}}行", + "deleted": "已刪除", + "modified": "已修改" + }, + "summary": { + "count_with_changes": "({{count}}) 已變更檔案{{changes}}", + "changes_format": " ({{changes}})" + }, + "accessibility": { + "files_list": "已變更檔案清單。{{count}}個檔案。{{state}}", + "expanded": "已展開", + "collapsed": "已摺疊" + } +} diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 91f7c5677a..55020ecdd8 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -755,6 +755,10 @@ "RUN_SLASH_COMMAND": { "name": "啟用模型啟動的斜線命令", "description": "啟用時,Roo 可以執行您的斜線命令來執行工作流程。" + }, + "FILES_CHANGED_OVERVIEW": { + "name": "啟用已變更檔案總覽", + "description": "啟用後,會顯示一個面板,其中顯示檢查點之間已修改的檔案。\n這可讓您檢視差異並接受/拒絕個別變更。" } }, "promptCaching": {