FCO: resolve conflicts, integrate types + UI, and fix provider.getCurrentTask usage

This commit is contained in:
Hannes Rudolph 2025-08-27 13:59:11 -06:00
parent 0ce4e891fd
commit 8c2d125f94
42 changed files with 3897 additions and 88 deletions

View file

@ -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[]
}

View file

@ -152,6 +152,7 @@ export const globalSettingsSchema = z.object({
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),
lastModeImportPath: z.string().optional(),
filesChangedEnabled: z.boolean().optional(),
})
export type GlobalSettings = z.infer<typeof globalSettingsSchema>

View file

@ -21,5 +21,5 @@ export * from "./terminal.js"
export * from "./tool.js"
export * from "./type-fu.js"
export * from "./vscode.js"
export * from "./providers/index.js"
export * from "./file-changes.js"

View file

@ -2,6 +2,7 @@ import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { TelemetryService } from "@roo-code/telemetry"
import { FileChangeType } from "@roo-code/types"
import { Task } from "../task/Task"
@ -15,6 +16,8 @@ import { getApiMetrics } from "../../shared/getApiMetrics"
import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider"
import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../../services/checkpoints"
import { FileChangeManager } from "../../services/file-changes/FileChangeManager"
import { CheckpointResult } from "../../services/checkpoints/types"
export async function getCheckpointService(
task: Task,
@ -126,36 +129,167 @@ async function checkGitInstallation(
}
// Git is installed, proceed with initialization
service.on("initialize", () => {
service.on("initialize", async () => {
log("[Task#getCheckpointService] service initialized")
task.checkpointServiceInitializing = false
try {
// Debug logging to understand checkpoint detection
console.log("[DEBUG] Checkpoint detection - total messages:", task.clineMessages.length)
console.log(
"[DEBUG] Checkpoint detection - message types:",
task.clineMessages.map((m) => ({ ts: m.ts, type: m.type, say: m.say, ask: m.ask })),
)
const checkpointMessages = task.clineMessages.filter(({ say }) => say === "checkpoint_saved")
console.log(
"[DEBUG] Found checkpoint messages:",
checkpointMessages.length,
checkpointMessages.map((m) => ({ ts: m.ts, text: m.text })),
)
const isCheckpointNeeded = checkpointMessages.length === 0
console.log("[DEBUG] isCheckpointNeeded result:", isCheckpointNeeded)
task.checkpointService = service
task.checkpointServiceInitializing = false
// Update FileChangeManager baseline to match checkpoint service
try {
const fileChangeManager = provider?.getFileChangeManager()
if (fileChangeManager) {
const currentBaseline = fileChangeManager.getChanges().baseCheckpoint
if (currentBaseline === "HEAD") {
if (isCheckpointNeeded) {
// New task: set baseline to initial checkpoint
if (service.baseHash && service.baseHash !== "HEAD") {
await fileChangeManager.updateBaseline(service.baseHash)
log(
`[Task#getCheckpointService] New task: Updated FileChangeManager baseline from HEAD to ${service.baseHash}`,
)
}
} else {
// Existing task: set baseline to current checkpoint (HEAD of checkpoint history)
const currentCheckpoint = service.baseHash
if (currentCheckpoint && currentCheckpoint !== "HEAD") {
await fileChangeManager.updateBaseline(currentCheckpoint)
log(
`[Task#getCheckpointService] Existing task: Updated FileChangeManager baseline from HEAD to current checkpoint ${currentCheckpoint}`,
)
}
}
}
}
} catch (error) {
log(`[Task#getCheckpointService] Failed to update FileChangeManager baseline: ${error}`)
// Don't throw - allow checkpoint service to continue initializing
}
if (isCheckpointNeeded) {
log("[Task#getCheckpointService] no checkpoints found, saving initial checkpoint")
checkpointSave(task, true)
} else {
log("[Task#getCheckpointService] existing checkpoints found, skipping initial checkpoint")
}
} catch (err) {
log("[Task#getCheckpointService] caught error in on('initialize'), disabling checkpoints")
task.enableCheckpoints = false
}
})
service.on("checkpoint", ({ fromHash: from, toHash: to, suppressMessage }) => {
service.on("checkpoint", async ({ fromHash: fromHash, toHash: toHash, suppressMessage }) => {
try {
// Always update the current checkpoint hash in the webview, including the suppress flag
provider?.postMessageToWebview({
type: "currentCheckpointUpdated",
text: to,
text: toHash,
suppressMessage: !!suppressMessage,
})
// Always create the chat message but include the suppress flag in the payload
// so the chatview can choose not to render it while keeping it in history.
task.say(
await task.say(
"checkpoint_saved",
to,
toHash,
undefined,
undefined,
{ from, to, suppressMessage: !!suppressMessage },
{ from: fromHash, to: toHash, suppressMessage: !!suppressMessage },
undefined,
{ isNonInteractive: true },
).catch((err) => {
log("[Task#getCheckpointService] caught unexpected error in say('checkpoint_saved')")
console.error(err)
})
)
// Calculate changes using checkpoint service directly
try {
const checkpointFileChangeManager = provider?.getFileChangeManager()
if (checkpointFileChangeManager) {
// Get the initial baseline (preserve for cumulative diff tracking)
const initialBaseline = checkpointFileChangeManager.getChanges().baseCheckpoint
log(
`[Task#checkpointCreated] Calculating cumulative changes from initial baseline ${initialBaseline} to ${toHash}`,
)
// Calculate cumulative diff from initial baseline to new checkpoint using checkpoint service
const changes = await service.getDiff({ from: initialBaseline, to: toHash })
if (changes && changes.length > 0) {
// Convert to FileChange format with correct checkpoint references
const fileChanges = changes.map((change: any) => ({
uri: change.paths.relative,
type: (change.paths.newFile
? "create"
: change.paths.deletedFile
? "delete"
: "edit") as FileChangeType,
fromCheckpoint: initialBaseline, // Always reference initial baseline for cumulative view
toCheckpoint: toHash, // Current checkpoint for comparison
linesAdded: change.content.after ? change.content.after.split("\n").length : 0,
linesRemoved: change.content.before ? change.content.before.split("\n").length : 0,
}))
log(`[Task#checkpointCreated] Found ${fileChanges.length} cumulative file changes`)
// Update FileChangeManager with the new files so view diff can find them
checkpointFileChangeManager.setFiles(fileChanges)
// DON'T clear accepted/rejected state here - preserve user's accept/reject decisions
// The state should only be cleared on baseline changes (checkpoint restore) or task restart
// Get filtered changeset that excludes already accepted/rejected files and only shows LLM-modified files
const filteredChangeset = await checkpointFileChangeManager.getLLMOnlyChanges(
task.taskId,
task.fileContextTracker,
)
// Create changeset and send to webview (only LLM-modified, unaccepted files)
const serializableChangeset = {
baseCheckpoint: filteredChangeset.baseCheckpoint,
files: filteredChangeset.files,
}
log(
`[Task#checkpointCreated] Sending ${filteredChangeset.files.length} LLM-only file changes to webview`,
)
provider?.postMessageToWebview({
type: "filesChanged",
filesChanged: serializableChangeset,
})
} else {
log(`[Task#checkpointCreated] No changes found between ${initialBaseline} and ${toHash}`)
}
// DON'T update the baseline - keep it at initial baseline for cumulative tracking
// The baseline should only change when explicitly requested (e.g., checkpoint restore)
log(
`[Task#checkpointCreated] Keeping FileChangeManager baseline at ${initialBaseline} for cumulative tracking`,
)
}
} catch (error) {
log(`[Task#checkpointCreated] Error calculating/sending file changes: ${error}`)
}
} catch (err) {
log("[Task#getCheckpointService] caught unexpected error in on('checkpoint'), disabling checkpoints")
log(
"[Task#getCheckpointService] caught unexpected error in on('checkpointCreated'), disabling checkpoints",
)
console.error(err)
task.enableCheckpoints = false
}
@ -177,8 +311,51 @@ async function checkGitInstallation(
}
}
export async function checkpointSave(task: Task, force = false, suppressMessage = false) {
const service = await getCheckpointService(task)
export async function getInitializedCheckpointService(
task: Task,
{ interval = 250, timeout = 15_000 }: { interval?: number; timeout?: number } = {},
) {
const service = await getCheckpointService(task, { interval, timeout })
if (!service || service.isInitialized) {
return service
}
try {
await pWaitFor(
() => {
console.log("[Task#getCheckpointService] waiting for service to initialize")
return service.isInitialized
},
{ interval, timeout },
)
return service
} catch (err) {
return undefined
}
}
// Track ongoing checkpoint saves per task to prevent duplicates
const ongoingCheckpointSaves = new Map<string, Promise<void | CheckpointResult | undefined>>()
export async function checkpointSave(task: Task, force = false, files?: vscode.Uri[], suppressMessage = false) {
// Create a unique key for this checkpoint save operation
const filesKey = files
? files
.map((f) => f.fsPath)
.sort()
.join("|")
: "all"
const saveKey = `${task.taskId}-${force}-${filesKey}`
// If there's already an ongoing checkpoint save for this exact operation, return the existing promise
if (ongoingCheckpointSaves.has(saveKey)) {
const provider = task.providerRef.deref()
provider?.log(`[checkpointSave] duplicate checkpoint save detected for ${saveKey}, using existing operation`)
return ongoingCheckpointSaves.get(saveKey)
}
const service = await getInitializedCheckpointService(task)
if (!service) {
return
@ -186,13 +363,52 @@ export async function checkpointSave(task: Task, force = false, suppressMessage
TelemetryService.instance.captureCheckpointCreated(task.taskId)
// Start the checkpoint process in the background.
return service
.saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, suppressMessage })
.catch((err) => {
// Get provider for messaging
const provider = task.providerRef.deref()
// Capture the previous checkpoint BEFORE saving the new one
const previousCheckpoint = service.baseHash
console.log(`[checkpointSave] Previous checkpoint: ${previousCheckpoint}`)
// Start the checkpoint process in the background and track it
const savePromise = service
.saveCheckpoint(`Task: ${task.taskId}, Time: ${Date.now()}`, { allowEmpty: force, files, suppressMessage })
.then(async (result: any) => {
console.log(`[checkpointSave] New checkpoint created: ${result?.commit}`)
// Notify FCO that checkpoint was created
if (provider && result) {
try {
provider.postMessageToWebview({
type: "checkpoint_created",
checkpoint: result.commit,
previousCheckpoint: previousCheckpoint,
} as any)
// NOTE: Don't send filesChanged here - it's handled by the checkpoint event
// to avoid duplicate/conflicting messages that override cumulative tracking.
// The checkpoint event handler calculates cumulative changes from the baseline
// and sends the complete filesChanged message with all accumulated changes.
console.log(
`[checkpointSave] FCO update delegated to checkpoint event for cumulative tracking`,
)
} catch (error) {
console.error("[Task#checkpointSave] Failed to notify FCO of checkpoint creation:", error)
}
}
return result
})
.catch((err: any) => {
console.error("[Task#checkpointSave] caught unexpected error, disabling checkpoints", err)
task.enableCheckpoints = false
})
.finally(() => {
// Clean up the tracking once completed
ongoingCheckpointSaves.delete(saveKey)
})
ongoingCheckpointSaves.set(saveKey, savePromise)
return savePromise
}
export type CheckpointRestoreOptions = {
@ -225,6 +441,44 @@ export async function checkpointRestore(
TelemetryService.instance.captureCheckpointRestored(task.taskId)
await provider?.postMessageToWebview({ type: "currentCheckpointUpdated", text: commitHash })
// Update FileChangeManager baseline to restored checkpoint and clear accept/reject state
try {
const fileChangeManager = provider?.getFileChangeManager()
if (fileChangeManager) {
// Reset baseline to restored checkpoint (fresh start from this point)
await fileChangeManager.updateBaseline(commitHash)
provider?.log(
`[checkpointRestore] Reset FileChangeManager baseline to restored checkpoint ${commitHash}`,
)
// Clear accept/reject state - checkpoint restore is time travel, start with clean slate
if (typeof fileChangeManager.clearAcceptedRejectedState === "function") {
fileChangeManager.clearAcceptedRejectedState()
provider?.log(`[checkpointRestore] Cleared accept/reject state for fresh start`)
}
// Calculate and send current changes (should be empty immediately after restore)
const changes = fileChangeManager.getChanges()
provider?.postMessageToWebview({
type: "filesChanged",
filesChanged: changes.files.length > 0 ? changes : undefined,
})
}
} catch (error) {
provider?.log(`[checkpointRestore] Failed to update FileChangeManager baseline: ${error}`)
// Don't throw - allow restore to continue even if FCO sync fails
}
// Notify FCO that checkpoint was restored
try {
await provider?.postMessageToWebview({
type: "checkpoint_restored",
checkpoint: commitHash,
} as any)
} catch (error) {
console.error("[checkpointRestore] Failed to notify FCO of checkpoint restore:", error)
}
if (mode === "restore") {
await task.overwriteApiConversationHistory(task.apiConversationHistory.filter((m) => !m.ts || m.ts < ts))
@ -309,7 +563,7 @@ export async function checkpointDiff(task: Task, { ts, previousCommitHash, commi
await vscode.commands.executeCommand(
"vscode.changes",
mode === "full" ? "Changes since task started" : "Changes compare with next checkpoint",
changes.map((change) => [
changes.map((change: any) => [
vscode.Uri.file(change.paths.absolute),
vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${change.paths.relative}`).with({
query: Buffer.from(change.content.before ?? "").toString("base64"),

View file

@ -91,6 +91,7 @@ import { getSystemPromptFilePath } from "../prompts/sections/custom-system-promp
import { webviewMessageHandler } from "./webviewMessageHandler"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { FCOMessageHandler } from "../../services/file-changes/FCOMessageHandler"
/**
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@ -138,6 +139,7 @@ export class ClineProvider
private recentTasksCache?: string[]
private pendingOperations: Map<string, PendingEditOperation> = new Map()
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
private globalFileChangeManager?: import("../../services/file-changes/FileChangeManager").FileChangeManager
public isViewLaunched = false
public settingsImportedAt?: number
@ -578,6 +580,8 @@ export class ClineProvider
this.mcpHub = undefined
this.marketplaceManager?.cleanup()
this.customModesManager?.dispose()
this.globalFileChangeManager?.dispose()
this.globalFileChangeManager = undefined
this.log("Disposed all disposables")
ClineProvider.activeInstances.delete(this)
@ -1119,8 +1123,17 @@ 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) => {
// Handle FCO messages first
const fcoMessageHandler = new FCOMessageHandler(this)
if (fcoMessageHandler.shouldHandleMessage(message)) {
await fcoMessageHandler.handleMessage(message)
return
}
// Delegate to main message handler
await webviewMessageHandler(this, message, this.marketplaceManager)
}
const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage)
this.webviewDisposables.push(messageDisposable)
@ -1912,7 +1925,8 @@ export class ClineProvider
includeDiagnosticMessages: includeDiagnosticMessages ?? true,
maxDiagnosticMessages: maxDiagnosticMessages ?? 50,
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
remoteControlEnabled,
remoteControlEnabled: remoteControlEnabled ?? false,
filesChangedEnabled: this.getGlobalState("filesChangedEnabled") ?? true,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
@ -2705,4 +2719,20 @@ export class ClineProvider
return vscode.Uri.file(filePath).toString()
}
}
public getFileChangeManager():
| import("../../services/file-changes/FileChangeManager").FileChangeManager
| undefined {
return this.globalFileChangeManager
}
public async ensureFileChangeManager(): Promise<
import("../../services/file-changes/FileChangeManager").FileChangeManager
> {
if (!this.globalFileChangeManager) {
const { FileChangeManager } = await import("../../services/file-changes/FileChangeManager")
this.globalFileChangeManager = new FileChangeManager("HEAD")
}
return this.globalFileChangeManager
}
}

View file

@ -20,6 +20,22 @@ import { ClineProvider } from "../ClineProvider"
// Mock setup must come before imports.
vi.mock("../../prompts/sections/custom-instructions")
vi.mock("vscode")
vi.mock("../../../integrations/editor/DecorationController", () => ({
DecorationController: vi.fn().mockImplementation(() => ({
addLines: vi.fn(),
clear: vi.fn(),
updateOverlayAfterLine: vi.fn(),
setActiveLine: vi.fn(),
})),
}))
vi.mock("../../../integrations/editor/DiffViewProvider", () => ({
DiffViewProvider: vi.fn().mockImplementation(() => ({
// Add mock methods if needed
})),
}))
vi.mock("p-wait-for", () => ({
__esModule: true,
default: vi.fn().mockResolvedValue(undefined),
@ -148,6 +164,9 @@ vi.mock("vscode", () => ({
executeCommand: vi.fn().mockResolvedValue(undefined),
},
window: {
createTextEditorDecorationType: vi.fn().mockReturnValue({
dispose: vi.fn(),
}),
showInformationMessage: vi.fn(),
showWarningMessage: vi.fn(),
showErrorMessage: vi.fn(),
@ -176,6 +195,16 @@ vi.mock("vscode", () => ({
Development: 2,
Test: 3,
},
Range: vi.fn().mockImplementation((start, startChar, end, endChar) => ({
start: { line: start, character: startChar },
end: { line: end, character: endChar },
with: vi.fn().mockReturnThis(),
})),
Position: vi.fn().mockImplementation((line, character) => ({
line,
character,
translate: vi.fn().mockReturnThis(),
})),
version: "1.85.0",
}))
@ -554,6 +583,7 @@ describe("ClineProvider", () => {
diagnosticsEnabled: true,
openRouterImageApiKey: undefined,
openRouterImageGenerationSelectedModel: undefined,
filesChangedEnabled: true,
}
const message: ExtensionMessage = {

View file

@ -1431,6 +1431,7 @@ export const webviewMessageHandler = async (
...currentState,
customModePrompts: updatedPrompts,
hasOpenedModeSelector: currentState.hasOpenedModeSelector ?? false,
filesChangedEnabled: currentState.filesChangedEnabled ?? true,
}
provider.postMessageToWebview({ type: "state", state: stateWithPrompts })
@ -1515,6 +1516,11 @@ export const webviewMessageHandler = async (
await updateGlobalState("showRooIgnoredFiles", message.bool ?? false)
await provider.postStateToWebview()
break
case "filesChangedEnabled":
const filesChangedEnabled = message.bool ?? true
await updateGlobalState("filesChangedEnabled", filesChangedEnabled)
await provider.postStateToWebview()
break
case "hasOpenedModeSelector":
await updateGlobalState("hasOpenedModeSelector", message.bool ?? true)
await provider.postStateToWebview()
@ -1748,7 +1754,12 @@ export const webviewMessageHandler = async (
break
case "upsertApiConfiguration":
if (message.text && message.apiConfiguration) {
await provider.upsertProviderProfile(message.text, message.apiConfiguration)
try {
await provider.upsertProviderProfile(message.text, message.apiConfiguration)
} catch (error) {
// Error is already logged in upsertProviderProfile, just show user message
vscode.window.showErrorMessage(t("errors.create_api_config"))
}
}
break
case "renameApiConfiguration":

View file

@ -8,7 +8,7 @@ import simpleGit, { SimpleGit } from "simple-git"
import pWaitFor from "p-wait-for"
import { fileExistsAtPath } from "../../utils/fs"
import { executeRipgrep } from "../../services/search/file-search"
import vscode from "vscode"
import { CheckpointDiff, CheckpointResult, CheckpointEventMap } from "./types"
import { getExcludePatterns } from "./excludes"
@ -24,7 +24,7 @@ export abstract class ShadowCheckpointService extends EventEmitter {
protected readonly dotGitDir: string
protected git?: SimpleGit
protected readonly log: (message: string) => void
protected shadowGitConfigWorktree?: string
private shadowGitConfigWorktree?: string
public get baseHash() {
return this._baseHash
@ -34,6 +34,14 @@ export abstract class ShadowCheckpointService extends EventEmitter {
this._baseHash = value
}
public get checkpoints() {
return [...this._checkpoints] // Return a copy to prevent external modification
}
public getCurrentCheckpoint(): string | undefined {
return this._checkpoints.length > 0 ? this._checkpoints[this._checkpoints.length - 1] : this.baseHash
}
public get isInitialized() {
return !!this.git
}
@ -68,17 +76,8 @@ export abstract class ShadowCheckpointService extends EventEmitter {
throw new Error("Shadow git repo already initialized")
}
const hasNestedGitRepos = await this.hasNestedGitRepositories()
if (hasNestedGitRepos) {
throw new Error(
"Checkpoints are disabled because nested git repositories were detected in the workspace. " +
"Please remove or relocate nested git repositories to use the checkpoints feature.",
)
}
await fs.mkdir(this.checkpointsDir, { recursive: true })
const git = simpleGit(this.checkpointsDir)
const git = simpleGit(this.workspaceDir, { binary: "git" }).env("GIT_DIR", this.dotGitDir)
const gitVersion = await git.version()
this.log(`[${this.constructor.name}#create] git = ${gitVersion}`)
@ -96,7 +95,31 @@ export abstract class ShadowCheckpointService extends EventEmitter {
}
await this.writeExcludeFile()
this.baseHash = await git.revparse(["HEAD"])
// Restore checkpoint history from git log
try {
// Get the initial commit (first commit in the repo)
const initialCommit = await git
.raw(["rev-list", "--max-parents=0", "HEAD"])
.then((result) => result.trim())
this.baseHash = initialCommit
// Get all commits from initial commit to HEAD to restore checkpoint history
const logResult = await git.log({ from: initialCommit, to: "HEAD" })
if (logResult.all.length > 1) {
// Skip the first commit (baseHash) and get the rest as checkpoints
this._checkpoints = logResult.all
.slice(0, -1)
.map((commit) => commit.hash)
.reverse()
this.log(`restored ${this._checkpoints.length} checkpoints from git history`)
} else {
this.baseHash = await git.revparse(["HEAD"])
}
} catch (error) {
this.log(`failed to restore checkpoint history: ${error}`)
// Fallback to simple HEAD approach
this.baseHash = await git.revparse(["HEAD"])
}
} else {
this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`)
await git.init()
@ -147,40 +170,22 @@ export abstract class ShadowCheckpointService extends EventEmitter {
try {
await git.add(".")
} catch (error) {
this.log(
`[${this.constructor.name}#stageAll] failed to add files to git: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
const errorMessage = error instanceof Error ? error.message : String(error)
private async hasNestedGitRepositories(): Promise<boolean> {
try {
// Find all .git directories that are not at the root level.
const args = ["--files", "--hidden", "--follow", "-g", "**/.git/HEAD", this.workspaceDir]
// Handle git lock errors by waiting and retrying once
if (errorMessage.includes("index.lock")) {
this.log(`git lock detected, waiting and retrying...`)
await new Promise((resolve) => setTimeout(resolve, 1000))
const gitPaths = await executeRipgrep({ args, workspacePath: this.workspaceDir })
// Filter to only include nested git directories (not the root .git).
const nestedGitPaths = gitPaths.filter(
({ type, path }) =>
type === "folder" && path.includes(".git") && !path.startsWith(".git") && path !== ".git",
)
if (nestedGitPaths.length > 0) {
this.log(
`[${this.constructor.name}#hasNestedGitRepositories] found ${nestedGitPaths.length} nested git repositories: ${nestedGitPaths.map((p) => p.path).join(", ")}`,
)
return true
try {
await git.add(".")
this.log(`retry successful after git lock`)
} catch (retryError) {
this.log(`retry failed: ${retryError}`)
}
} else {
this.log(`failed to add files to git: ${errorMessage}`)
}
return false
} catch (error) {
this.log(
`[${this.constructor.name}#hasNestedGitRepositories] failed to check for nested git repos: ${error instanceof Error ? error.message : String(error)}`,
)
// If we can't check, assume there are no nested repos to avoid blocking the feature.
return false
}
}
@ -200,7 +205,7 @@ export abstract class ShadowCheckpointService extends EventEmitter {
public async saveCheckpoint(
message: string,
options?: { allowEmpty?: boolean; suppressMessage?: boolean },
options?: { allowEmpty?: boolean; suppressMessage?: boolean; files?: vscode.Uri[] },
): Promise<CheckpointResult | undefined> {
try {
this.log(
@ -221,12 +226,16 @@ export abstract class ShadowCheckpointService extends EventEmitter {
const duration = Date.now() - startTime
if (result.commit) {
const isFirst = fromHash === this.baseHash
this.emit("checkpoint", {
type: "checkpoint",
message,
isFirst,
fromHash,
toHash,
duration,
suppressMessage: options?.suppressMessage ?? false,
files: options?.files,
})
}
@ -256,8 +265,11 @@ export abstract class ShadowCheckpointService extends EventEmitter {
}
const start = Date.now()
await this.git.clean("f", ["-d", "-f"])
// Restore shadow
await this.git.reset(["--hard", commitHash])
await this.git.clean("f", ["-d", "-f"])
// With worktree, the workspace is already updated by the reset.
// Remove all checkpoints after the specified commitHash.
const checkpointIndex = this._checkpoints.indexOf(commitHash)
@ -301,16 +313,31 @@ export abstract class ShadowCheckpointService extends EventEmitter {
const absPath = path.join(cwdPath, relPath)
const before = await this.git.show([`${from}:${relPath}`]).catch(() => "")
const after = to
? await this.git.show([`${to}:${relPath}`]).catch(() => "")
: await fs.readFile(absPath, "utf8").catch(() => "")
const after = await this.git.show([`${to ?? "HEAD"}:${relPath}`]).catch(() => "")
result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after } })
let type: "create" | "delete" | "edit"
if (!before) {
type = "create"
} else if (!after) {
type = "delete"
} else {
type = "edit"
}
result.push({ paths: { relative: relPath, absolute: absPath }, content: { before, after }, type })
}
return result
}
public async getContent(commitHash: string, filePath: string): Promise<string> {
if (!this.git) {
throw new Error("Shadow git repo not initialized")
}
const relativePath = path.relative(this.workspaceDir, filePath)
return this.git.show([`${commitHash}:${relativePath}`])
}
/**
* EventEmitter
*/

View file

@ -379,6 +379,10 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
})
describe(`${klass.name}#hasNestedGitRepositories`, () => {
// NOTE: This test is commented out because ShadowCheckpointService no longer checks for nested git repositories.
// The FCO integration changed the shadow git implementation to use .roo directory approach,
// eliminating the need for nested git repository detection.
/*
it("throws error when nested git repositories are detected during initialization", async () => {
// Create a new temporary workspace and service for this test.
const shadowDir = path.join(tmpDir, `${prefix}-nested-git-${Date.now()}`)
@ -445,6 +449,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
await fs.rm(shadowDir, { recursive: true, force: true })
await fs.rm(workspaceDir, { recursive: true, force: true })
})
*/
it("succeeds when no nested git repositories are detected", async () => {
// Create a new temporary workspace and service for this test.
@ -534,9 +539,9 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
await fs.rm(workspaceDir, { recursive: true, force: true })
})
it("emits checkpoint event when saving checkpoint", async () => {
it("emits checkpointCreated event when saving checkpoint", async () => {
const checkpointHandler = vitest.fn()
service.on("checkpoint", checkpointHandler)
service.on("checkpointCreated", checkpointHandler)
await fs.writeFile(testFile, "Changed content for checkpoint event test")
const result = await service.saveCheckpoint("Test checkpoint event")
@ -544,7 +549,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
expect(checkpointHandler).toHaveBeenCalledTimes(1)
const eventData = checkpointHandler.mock.calls[0][0]
expect(eventData.type).toBe("checkpoint")
expect(eventData.type).toBe("checkpointCreated")
expect(eventData.toHash).toBeDefined()
expect(eventData.toHash).toBe(result!.commit)
expect(typeof eventData.duration).toBe("number")
@ -602,8 +607,8 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
const checkpointHandler1 = vitest.fn()
const checkpointHandler2 = vitest.fn()
service.on("checkpoint", checkpointHandler1)
service.on("checkpoint", checkpointHandler2)
service.on("checkpointCreated", checkpointHandler1)
service.on("checkpointCreated", checkpointHandler2)
await fs.writeFile(testFile, "Content for multiple listeners test")
const result = await service.saveCheckpoint("Testing multiple listeners")
@ -616,7 +621,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
const eventData2 = checkpointHandler2.mock.calls[0][0]
expect(eventData1).toEqual(eventData2)
expect(eventData1.type).toBe("checkpoint")
expect(eventData1.type).toBe("checkpointCreated")
expect(eventData1.toHash).toBe(result?.commit)
})
@ -624,7 +629,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
const checkpointHandler = vitest.fn()
// Add the listener.
service.on("checkpoint", checkpointHandler)
service.on("checkpointCreated", checkpointHandler)
// Make a change and save a checkpoint.
await fs.writeFile(testFile, "Content for remove listener test - part 1")
@ -635,7 +640,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
checkpointHandler.mockClear()
// Remove the listener.
service.off("checkpoint", checkpointHandler)
service.off("checkpointCreated", checkpointHandler)
// Make another change and save a checkpoint.
await fs.writeFile(testFile, "Content for remove listener test - part 2")
@ -684,13 +689,13 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
it("emits checkpoint event for empty commits when allowEmpty=true", async () => {
const checkpointHandler = vitest.fn()
service.on("checkpoint", checkpointHandler)
service.on("checkpointCreated", checkpointHandler)
const result = await service.saveCheckpoint("Empty checkpoint event test", { allowEmpty: true })
expect(checkpointHandler).toHaveBeenCalledTimes(1)
const eventData = checkpointHandler.mock.calls[0][0]
expect(eventData.type).toBe("checkpoint")
expect(eventData.type).toBe("checkpointCreated")
expect(eventData.toHash).toBe(result?.commit)
expect(typeof eventData.duration).toBe("number")
})
@ -706,7 +711,7 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])(
// Now test with no changes and allowEmpty=false
const checkpointHandler = vitest.fn()
service.on("checkpoint", checkpointHandler)
service.on("checkpointCreated", checkpointHandler)
const result = await service.saveCheckpoint("No changes, no event", { allowEmpty: false })

View file

@ -11,6 +11,7 @@ export type CheckpointDiff = {
before: string
after: string
}
type: "create" | "delete" | "edit"
}
export interface CheckpointServiceOptions {
@ -23,8 +24,10 @@ export interface CheckpointServiceOptions {
export interface CheckpointEventMap {
initialize: { type: "initialize"; workspaceDir: string; baseHash: string; created: boolean; duration: number }
checkpoint: {
type: "checkpoint"
checkpointCreated: {
type: "checkpointCreated"
message: string
isFirst: boolean
fromHash: string
toHash: string
duration: number

View file

@ -0,0 +1,458 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import { WebviewMessage } from "../../shared/WebviewMessage"
import type { FileChangeType } from "@roo-code/types"
import { FileChangeManager } from "./FileChangeManager"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { getCheckpointService } from "../../core/checkpoints"
/**
* Handles FCO-specific webview messages that were previously scattered throughout ClineProvider
*/
export class FCOMessageHandler {
constructor(private provider: ClineProvider) {}
/**
* Check if a message should be handled by FCO
*/
public shouldHandleMessage(message: WebviewMessage): boolean {
const fcoMessageTypes = [
"webviewReady",
"viewDiff",
"acceptFileChange",
"rejectFileChange",
"acceptAllFileChanges",
"rejectAllFileChanges",
"filesChangedRequest",
"filesChangedBaselineUpdate",
]
return fcoMessageTypes.includes(message.type)
}
/**
* Handle FCO-specific messages
*/
public async handleMessage(message: WebviewMessage): Promise<void> {
const task = this.provider.getCurrentCline()
switch (message.type) {
case "webviewReady": {
// Ensure FileChangeManager is initialized when webview is ready
let fileChangeManager = this.provider.getFileChangeManager()
if (!fileChangeManager) {
fileChangeManager = await this.provider.ensureFileChangeManager()
}
if (fileChangeManager) {
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: fileChangeManager.getChanges(),
})
}
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: any): Promise<void> {
const diffFileChangeManager = this.provider.getFileChangeManager()
if (message.uri && diffFileChangeManager && task?.checkpointService) {
// Get the file change information
const changeset = diffFileChangeManager.getChanges()
const fileChange = changeset.files.find((f) => f.uri === message.uri)
if (fileChange) {
try {
// Get the specific file content from both checkpoints
const changes = await task.checkpointService.getDiff({
from: fileChange.fromCheckpoint,
to: fileChange.toCheckpoint,
})
// 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 {
console.warn(`FCOMessageHandler: No file change data found for URI: ${message.uri}`)
vscode.window.showInformationMessage(`No changes found for ${message.uri}`)
}
} catch (error) {
console.error(`FCOMessageHandler: Failed to open diff for ${message.uri}:`, error)
vscode.window.showErrorMessage(`Failed to open diff for ${message.uri}: ${error.message}`)
}
} else {
console.warn(`FCOMessageHandler: File change not found in changeset for URI: ${message.uri}`)
vscode.window.showInformationMessage(`File change not found for ${message.uri}`)
}
} else {
console.warn(`FCOMessageHandler: Missing dependencies for viewDiff. URI: ${message.uri}`)
vscode.window.showErrorMessage("Unable to view diff - missing required dependencies")
}
}
private async showFileDiff(uri: string, fileChangeData: any): Promise<void> {
const beforeContent = fileChangeData.content.before || ""
const afterContent = fileChangeData.content.after || ""
// Create temporary files for the diff view
const tempDir = require("os").tmpdir()
const path = require("path")
const fs = require("fs/promises")
const fileName = path.basename(uri)
const beforeTempPath = path.join(tempDir, `${fileName}.before.tmp`)
const afterTempPath = path.join(tempDir, `${fileName}.after.tmp`)
try {
// Write temporary files
await fs.writeFile(beforeTempPath, beforeContent, "utf8")
await fs.writeFile(afterTempPath, afterContent, "utf8")
// Create URIs for the temporary files
const beforeUri = vscode.Uri.file(beforeTempPath)
const afterUri = vscode.Uri.file(afterTempPath)
// Open the diff view for this specific file
await vscode.commands.executeCommand("vscode.diff", beforeUri, afterUri, `${uri}: Before ↔ After`, {
preview: false,
})
// Clean up temporary files after a delay
setTimeout(async () => {
try {
await fs.unlink(beforeTempPath)
await fs.unlink(afterTempPath)
} catch (cleanupError) {
console.warn(`Failed to clean up temp files: ${cleanupError.message}`)
}
}, 30000) // Clean up after 30 seconds
} catch (fileError) {
console.error(`Failed to create temporary files: ${fileError.message}`)
vscode.window.showErrorMessage(`Failed to create diff view: ${fileError.message}`)
}
}
private async handleAcceptFileChange(message: WebviewMessage): Promise<void> {
let acceptFileChangeManager = this.provider.getFileChangeManager()
if (!acceptFileChangeManager) {
acceptFileChangeManager = await this.provider.ensureFileChangeManager()
}
if (message.uri && acceptFileChangeManager) {
await acceptFileChangeManager.acceptChange(message.uri)
// Send updated state
const updatedChangeset = acceptFileChangeManager.getChanges()
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: updatedChangeset.files.length > 0 ? updatedChangeset : undefined,
})
}
}
private async handleRejectFileChange(message: WebviewMessage): Promise<void> {
console.log(`[FCO] handleRejectFileChange called for URI: ${message.uri}`)
let rejectFileChangeManager = this.provider.getFileChangeManager()
if (!rejectFileChangeManager) {
rejectFileChangeManager = await this.provider.ensureFileChangeManager()
}
if (!message.uri || !rejectFileChangeManager) {
return
}
try {
// Get the file change details to know which checkpoint to restore from
const fileChange = rejectFileChangeManager.getFileChange(message.uri)
if (!fileChange) {
console.error(`[FCO] File change not found for URI: ${message.uri}`)
return
}
// Get the current task and checkpoint service
const currentTask = this.provider.getCurrentCline()
if (!currentTask) {
console.error(`[FCO] No current task found for file reversion`)
return
}
const checkpointService = getCheckpointService(currentTask)
if (!checkpointService) {
console.error(`[FCO] No checkpoint service available for file reversion`)
return
}
// Revert the file to its previous state
await this.revertFileToCheckpoint(message.uri, fileChange.fromCheckpoint, checkpointService)
console.log(`[FCO] File ${message.uri} successfully reverted`)
// Remove from tracking since the file has been reverted
await rejectFileChangeManager.rejectChange(message.uri)
// Send updated state
const updatedChangeset = rejectFileChangeManager.getChanges()
console.log(`[FCO] After rejection, sending ${updatedChangeset.files.length} files to webview`)
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: updatedChangeset.files.length > 0 ? updatedChangeset : undefined,
})
} catch (error) {
console.error(`[FCO] Error reverting file ${message.uri}:`, error)
// Fall back to old behavior (just remove from display) if reversion fails
await rejectFileChangeManager.rejectChange(message.uri)
const updatedChangeset = rejectFileChangeManager.getChanges()
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: updatedChangeset.files.length > 0 ? updatedChangeset : undefined,
})
}
}
private async handleAcceptAllFileChanges(): Promise<void> {
let acceptAllFileChangeManager = this.provider.getFileChangeManager()
if (!acceptAllFileChangeManager) {
acceptAllFileChangeManager = await this.provider.ensureFileChangeManager()
}
await acceptAllFileChangeManager?.acceptAll()
// Clear state
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: undefined,
})
}
private async handleRejectAllFileChanges(message: WebviewMessage): Promise<void> {
let rejectAllFileChangeManager = this.provider.getFileChangeManager()
if (!rejectAllFileChangeManager) {
rejectAllFileChangeManager = await this.provider.ensureFileChangeManager()
}
if (!rejectAllFileChangeManager) {
return
}
try {
// Get all current file changes
const changeset = rejectAllFileChangeManager.getChanges()
// Filter files if specific URIs provided, otherwise use all files
const filesToReject = message.uris
? changeset.files.filter((file) => message.uris!.includes(file.uri))
: changeset.files
// Get the current task and checkpoint service
const currentTask = this.provider.getCurrentCline()
if (!currentTask) {
console.error(`[FCO] No current task found for file reversion`)
return
}
const checkpointService = getCheckpointService(currentTask)
if (!checkpointService) {
console.error(`[FCO] No checkpoint service available for file reversion`)
return
}
// Revert filtered files to their previous states
for (const fileChange of filesToReject) {
try {
await this.revertFileToCheckpoint(fileChange.uri, fileChange.fromCheckpoint, checkpointService)
} catch (error) {
console.error(`[FCO] Failed to revert file ${fileChange.uri}:`, error)
// Continue with other files even if one fails
}
}
// Clear all tracking after reverting files
await rejectAllFileChangeManager.rejectAll()
// Clear state
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: undefined,
})
} catch (error) {
console.error(`[FCO] Error reverting all files:`, error)
// Fall back to old behavior if reversion fails
await rejectAllFileChangeManager.rejectAll()
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: undefined,
})
}
}
private async handleFilesChangedRequest(message: WebviewMessage, task: any): Promise<void> {
try {
let fileChangeManager = this.provider.getFileChangeManager()
if (!fileChangeManager) {
fileChangeManager = await this.provider.ensureFileChangeManager()
}
if (fileChangeManager && task?.checkpointService) {
const changeset = fileChangeManager.getChanges()
// Handle message file changes if provided
if (message.fileChanges) {
const fileChanges = message.fileChanges.map((fc: any) => ({
uri: fc.uri,
type: fc.type,
fromCheckpoint: task.checkpointService?.baseHash || "base",
toCheckpoint: "current",
}))
fileChangeManager.setFiles(fileChanges)
}
// Get filtered changeset and send to webview
const filteredChangeset = fileChangeManager.getChanges()
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: filteredChangeset.files.length > 0 ? filteredChangeset : undefined,
})
} else {
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: undefined,
})
}
} catch (error) {
console.error("FCOMessageHandler: Error handling filesChangedRequest:", error)
// Send empty response to prevent FCO from hanging
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: undefined,
})
}
}
private async handleFilesChangedBaselineUpdate(message: WebviewMessage, task: any): Promise<void> {
try {
let fileChangeManager = this.provider.getFileChangeManager()
if (!fileChangeManager) {
fileChangeManager = await this.provider.ensureFileChangeManager()
}
if (fileChangeManager && task && message.baseline) {
// Update baseline to the specified checkpoint
await fileChangeManager.updateBaseline(message.baseline)
// Send updated state
const updatedChangeset = fileChangeManager.getChanges()
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: updatedChangeset.files.length > 0 ? updatedChangeset : undefined,
})
} else {
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: undefined,
})
}
} catch (error) {
console.error("FCOMessageHandler: Failed to update baseline:", error)
this.provider.postMessageToWebview({
type: "filesChanged",
filesChanged: undefined,
})
}
}
/**
* Revert a specific file to its content at a specific checkpoint
*/
private async revertFileToCheckpoint(
relativeFilePath: string,
fromCheckpoint: string,
checkpointService: any,
): Promise<void> {
try {
// Get the workspace path
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
if (!workspaceFolder) {
throw new Error("No workspace folder found")
}
const absoluteFilePath = path.join(workspaceFolder.uri.fsPath, relativeFilePath)
// Get the file content from the checkpoint
if (!checkpointService.getContent) {
throw new Error("Checkpoint service does not support getContent method")
}
let previousContent: string | null = null
try {
previousContent = await checkpointService.getContent(fromCheckpoint, absoluteFilePath)
} catch (error) {
// If file doesn't exist in checkpoint, it's a newly created file
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage.includes("exists on disk, but not in") || errorMessage.includes("does not exist")) {
console.log(
`[FCO] File ${relativeFilePath} didn't exist in checkpoint ${fromCheckpoint}, treating as new file`,
)
previousContent = null
} else {
throw error
}
}
// Check if the file was newly created (didn't exist in the fromCheckpoint)
if (!previousContent) {
// File was newly created, so delete it
console.log(`[FCO] Deleting newly created file: ${relativeFilePath}`)
try {
await fs.unlink(absoluteFilePath)
} catch (error) {
if ((error as any).code !== "ENOENT") {
throw error
}
// File already doesn't exist, that's fine
}
} else {
// File existed before, restore its previous content
console.log(`[FCO] Restoring file content: ${relativeFilePath}`)
await fs.writeFile(absoluteFilePath, previousContent, "utf8")
}
} catch (error) {
console.error(`[FCO] Failed to revert file ${relativeFilePath}:`, error)
throw error
}
}
}

View file

@ -0,0 +1,192 @@
import { FileChange, FileChangeset } from "@roo-code/types"
import type { FileContextTracker } from "../../core/context-tracking/FileContextTracker"
/**
* Simplified FileChangeManager - Pure diff calculation service
* No complex persistence, events, or tool integration
*/
export class FileChangeManager {
private changeset: FileChangeset
private acceptedFiles: Set<string>
private rejectedFiles: Set<string>
constructor(baseCheckpoint: string) {
this.changeset = {
baseCheckpoint,
files: [],
}
this.acceptedFiles = new Set()
this.rejectedFiles = new Set()
}
/**
* Get current changeset with accepted/rejected files filtered out
*/
public getChanges(): FileChangeset {
const filteredFiles = this.changeset.files.filter(
(file) => !this.acceptedFiles.has(file.uri) && !this.rejectedFiles.has(file.uri),
)
return {
...this.changeset,
files: filteredFiles,
}
}
/**
* Get changeset filtered to only show LLM-modified files
*/
public async getLLMOnlyChanges(taskId: string, fileContextTracker: FileContextTracker): Promise<FileChangeset> {
// Get task metadata to determine which files were modified by LLM
const taskMetadata = await fileContextTracker.getTaskMetadata(taskId)
// Get files that were modified by LLM (record_source: "roo_edited")
const llmModifiedFiles = new Set(
taskMetadata.files_in_context
.filter((entry) => entry.record_source === "roo_edited")
.map((entry) => entry.path),
)
// Filter changeset to only include LLM-modified files
const filteredFiles = this.changeset.files.filter(
(file) =>
llmModifiedFiles.has(file.uri) &&
!this.acceptedFiles.has(file.uri) &&
!this.rejectedFiles.has(file.uri),
)
return {
...this.changeset,
files: filteredFiles,
}
}
/**
* Get a specific file change
*/
public getFileChange(uri: string): FileChange | undefined {
return this.changeset.files.find((file) => file.uri === uri)
}
/**
* Accept a specific file change
*/
public async acceptChange(uri: string): Promise<void> {
this.acceptedFiles.add(uri)
this.rejectedFiles.delete(uri)
}
/**
* Reject a specific file change
*/
public async rejectChange(uri: string): Promise<void> {
this.rejectedFiles.add(uri)
this.acceptedFiles.delete(uri)
}
/**
* Accept all file changes
*/
public async acceptAll(): Promise<void> {
this.changeset.files.forEach((file) => {
this.acceptedFiles.add(file.uri)
})
this.rejectedFiles.clear()
}
/**
* Reject all file changes
*/
public async rejectAll(): Promise<void> {
this.changeset.files.forEach((file) => {
this.rejectedFiles.add(file.uri)
})
this.acceptedFiles.clear()
}
/**
* Update the baseline checkpoint and recalculate changes
*/
public async updateBaseline(
newBaselineCheckpoint: string,
_getDiff?: (from: string, to: string) => Promise<{ filePath: string; content: string }[]>,
_checkpointService?: {
checkpoints: string[]
baseHash?: string
},
): Promise<void> {
this.changeset.baseCheckpoint = newBaselineCheckpoint
// Simple approach: request fresh calculation from backend
// The actual diff calculation should be handled by the checkpoint service
this.changeset.files = []
// Clear accepted/rejected state - baseline change means we're starting fresh
// This happens during checkpoint restore (time travel) where we want a clean slate
this.acceptedFiles.clear()
this.rejectedFiles.clear()
}
/**
* Set the files for the changeset (called by backend when files change)
* Preserves existing accept/reject state for files with the same URI
*/
public setFiles(files: FileChange[]): void {
this.changeset.files = files
}
/**
* Clear accepted/rejected state (called when new checkpoint created)
*/
public clearAcceptedRejectedState(): void {
this.acceptedFiles.clear()
this.rejectedFiles.clear()
}
/**
* Calculate line differences between two file contents
*/
public static calculateLineDifferences(
originalContent: string,
newContent: string,
): { linesAdded: number; linesRemoved: number } {
const originalLines = originalContent.split("\n")
const newLines = newContent.split("\n")
// Simple diff calculation
const linesAdded = Math.max(0, newLines.length - originalLines.length)
const linesRemoved = Math.max(0, originalLines.length - newLines.length)
return { linesAdded, linesRemoved }
}
/**
* Dispose of the manager (for compatibility)
*/
public dispose(): void {
this.changeset.files = []
this.acceptedFiles.clear()
this.rejectedFiles.clear()
}
}
// 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 || originalError?.message || "File change operation failed")
this.name = "FileChangeError"
}
}

View file

@ -0,0 +1,463 @@
// Tests for simplified FileChangeManager - Pure diff calculation service
// npx vitest run src/services/file-changes/__tests__/FileChangeManager.simplified.test.ts
import { describe, beforeEach, afterEach, it, expect, vi } from "vitest"
import { FileChangeManager } from "../FileChangeManager"
import { FileChange } from "@roo-code/types"
import type { FileContextTracker } from "../../../core/context-tracking/FileContextTracker"
import type { TaskMetadata } from "../../../core/context-tracking/FileContextTrackerTypes"
describe("FileChangeManager (Simplified)", () => {
let fileChangeManager: FileChangeManager
beforeEach(() => {
fileChangeManager = new FileChangeManager("initial-checkpoint")
})
afterEach(() => {
fileChangeManager.dispose()
})
describe("Constructor", () => {
it("should create manager with baseline checkpoint", () => {
const manager = new FileChangeManager("test-checkpoint")
const changes = manager.getChanges()
expect(changes.baseCheckpoint).toBe("test-checkpoint")
expect(changes.files).toEqual([])
})
})
describe("getChanges", () => {
it("should return empty changeset initially", () => {
const changes = fileChangeManager.getChanges()
expect(changes.baseCheckpoint).toBe("initial-checkpoint")
expect(changes.files).toEqual([])
})
it("should filter out accepted files", () => {
// Setup some files
const testFiles: FileChange[] = [
{
uri: "file1.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 5,
linesRemoved: 2,
},
{
uri: "file2.txt",
type: "create",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 10,
linesRemoved: 0,
},
]
fileChangeManager.setFiles(testFiles)
// Accept one file
fileChangeManager.acceptChange("file1.txt")
const changes = fileChangeManager.getChanges()
expect(changes.files).toHaveLength(1)
expect(changes.files[0].uri).toBe("file2.txt")
})
it("should filter out rejected files", () => {
const testFiles: FileChange[] = [
{
uri: "file1.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 5,
linesRemoved: 2,
},
{
uri: "file2.txt",
type: "create",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 10,
linesRemoved: 0,
},
]
fileChangeManager.setFiles(testFiles)
// Reject one file
fileChangeManager.rejectChange("file1.txt")
const changes = fileChangeManager.getChanges()
expect(changes.files).toHaveLength(1)
expect(changes.files[0].uri).toBe("file2.txt")
})
})
describe("getFileChange", () => {
it("should return specific file change", () => {
const testFile: FileChange = {
uri: "test.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 3,
linesRemoved: 1,
}
fileChangeManager.setFiles([testFile])
const result = fileChangeManager.getFileChange("test.txt")
expect(result).toEqual(testFile)
})
it("should return undefined for non-existent file", () => {
const result = fileChangeManager.getFileChange("non-existent.txt")
expect(result).toBeUndefined()
})
})
describe("acceptChange", () => {
it("should mark file as accepted", async () => {
const testFile: FileChange = {
uri: "test.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 3,
linesRemoved: 1,
}
fileChangeManager.setFiles([testFile])
await fileChangeManager.acceptChange("test.txt")
const changes = fileChangeManager.getChanges()
expect(changes.files).toHaveLength(0) // File filtered out
})
it("should remove from rejected if previously rejected", async () => {
const testFile: FileChange = {
uri: "test.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 3,
linesRemoved: 1,
}
fileChangeManager.setFiles([testFile])
// First reject, then accept
await fileChangeManager.rejectChange("test.txt")
await fileChangeManager.acceptChange("test.txt")
const changes = fileChangeManager.getChanges()
expect(changes.files).toHaveLength(0) // File filtered out as accepted
})
})
describe("rejectChange", () => {
it("should mark file as rejected", async () => {
const testFile: FileChange = {
uri: "test.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 3,
linesRemoved: 1,
}
fileChangeManager.setFiles([testFile])
await fileChangeManager.rejectChange("test.txt")
const changes = fileChangeManager.getChanges()
expect(changes.files).toHaveLength(0) // File filtered out
})
})
describe("acceptAll", () => {
it("should accept all files", async () => {
const testFiles: FileChange[] = [
{
uri: "file1.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 5,
linesRemoved: 2,
},
{
uri: "file2.txt",
type: "create",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 10,
linesRemoved: 0,
},
]
fileChangeManager.setFiles(testFiles)
await fileChangeManager.acceptAll()
const changes = fileChangeManager.getChanges()
expect(changes.files).toHaveLength(0) // All files filtered out
})
})
describe("rejectAll", () => {
it("should reject all files", async () => {
const testFiles: FileChange[] = [
{
uri: "file1.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 5,
linesRemoved: 2,
},
{
uri: "file2.txt",
type: "create",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 10,
linesRemoved: 0,
},
]
fileChangeManager.setFiles(testFiles)
await fileChangeManager.rejectAll()
const changes = fileChangeManager.getChanges()
expect(changes.files).toHaveLength(0) // All files filtered out
})
})
describe("updateBaseline", () => {
it("should update baseline checkpoint", async () => {
await fileChangeManager.updateBaseline("new-baseline")
const changes = fileChangeManager.getChanges()
expect(changes.baseCheckpoint).toBe("new-baseline")
})
it("should clear files and reset state on baseline update", async () => {
const testFile: FileChange = {
uri: "test.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 3,
linesRemoved: 1,
}
fileChangeManager.setFiles([testFile])
await fileChangeManager.acceptChange("test.txt")
// Update baseline should clear everything
await fileChangeManager.updateBaseline("new-baseline")
// Add the same file again
fileChangeManager.setFiles([testFile])
// File should appear again (accepted state cleared)
const changes = fileChangeManager.getChanges()
expect(changes.files).toHaveLength(1)
})
})
describe("setFiles", () => {
it("should set the files in changeset", () => {
const testFiles: FileChange[] = [
{
uri: "file1.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 5,
linesRemoved: 2,
},
]
fileChangeManager.setFiles(testFiles)
const changes = fileChangeManager.getChanges()
expect(changes.files).toEqual(testFiles)
})
})
describe("calculateLineDifferences", () => {
it("should calculate lines added", () => {
const original = "line1\nline2"
const modified = "line1\nline2\nline3\nline4"
const result = FileChangeManager.calculateLineDifferences(original, modified)
expect(result.linesAdded).toBe(2)
expect(result.linesRemoved).toBe(0)
})
it("should calculate lines removed", () => {
const original = "line1\nline2\nline3\nline4"
const modified = "line1\nline2"
const result = FileChangeManager.calculateLineDifferences(original, modified)
expect(result.linesAdded).toBe(0)
expect(result.linesRemoved).toBe(2)
})
it("should handle equal length changes", () => {
const original = "line1\nline2"
const modified = "line1\nline2"
const result = FileChangeManager.calculateLineDifferences(original, modified)
expect(result.linesAdded).toBe(0)
expect(result.linesRemoved).toBe(0)
})
})
describe("getLLMOnlyChanges", () => {
it("should filter files to only show LLM-modified files", async () => {
// Mock FileContextTracker
const mockFileContextTracker = {
getTaskMetadata: vi.fn().mockResolvedValue({
files_in_context: [
{ path: "file1.txt", record_source: "roo_edited" },
{ path: "file2.txt", record_source: "user_edited" },
{ path: "file3.txt", record_source: "roo_edited" },
],
} as TaskMetadata),
} as unknown as FileContextTracker
const testFiles: FileChange[] = [
{
uri: "file1.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 5,
linesRemoved: 2,
},
{
uri: "file2.txt", // This should be filtered out (user_edited)
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 3,
linesRemoved: 1,
},
{
uri: "file3.txt",
type: "create",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 10,
linesRemoved: 0,
},
]
fileChangeManager.setFiles(testFiles)
const llmOnlyChanges = await fileChangeManager.getLLMOnlyChanges("test-task-id", mockFileContextTracker)
expect(llmOnlyChanges.files).toHaveLength(2)
expect(llmOnlyChanges.files.map((f) => f.uri)).toEqual(["file1.txt", "file3.txt"])
})
it("should filter out accepted and rejected files from LLM-only changes", async () => {
const mockFileContextTracker = {
getTaskMetadata: vi.fn().mockResolvedValue({
files_in_context: [
{ path: "file1.txt", record_source: "roo_edited" },
{ path: "file2.txt", record_source: "roo_edited" },
{ path: "file3.txt", record_source: "roo_edited" },
],
} as TaskMetadata),
} as unknown as FileContextTracker
const testFiles: FileChange[] = [
{
uri: "file1.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 5,
linesRemoved: 2,
},
{
uri: "file2.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 3,
linesRemoved: 1,
},
{
uri: "file3.txt",
type: "create",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 10,
linesRemoved: 0,
},
]
fileChangeManager.setFiles(testFiles)
// Accept one file, reject another
await fileChangeManager.acceptChange("file1.txt")
await fileChangeManager.rejectChange("file2.txt")
const llmOnlyChanges = await fileChangeManager.getLLMOnlyChanges("test-task-id", mockFileContextTracker)
expect(llmOnlyChanges.files).toHaveLength(1)
expect(llmOnlyChanges.files[0].uri).toBe("file3.txt")
})
it("should return empty changeset when no LLM-modified files exist", async () => {
const mockFileContextTracker = {
getTaskMetadata: vi.fn().mockResolvedValue({
files_in_context: [
{ path: "file1.txt", record_source: "user_edited" },
{ path: "file2.txt", record_source: "read_tool" },
],
} as TaskMetadata),
} as unknown as FileContextTracker
const testFiles: FileChange[] = [
{
uri: "file1.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 5,
linesRemoved: 2,
},
{
uri: "file2.txt",
type: "edit",
fromCheckpoint: "initial-checkpoint",
toCheckpoint: "current",
linesAdded: 3,
linesRemoved: 1,
},
]
fileChangeManager.setFiles(testFiles)
const llmOnlyChanges = await fileChangeManager.getLLMOnlyChanges("test-task-id", mockFileContextTracker)
expect(llmOnlyChanges.files).toHaveLength(0)
})
})
})

View file

@ -13,6 +13,8 @@ import type {
OrganizationAllowList,
ShareVisibility,
QueuedMessage,
ClineSay,
FileChangeset,
} from "@roo-code/types"
import { GitCommit } from "../utils/git"
@ -123,6 +125,10 @@ export interface ExtensionMessage {
| "showEditMessageDialog"
| "commands"
| "insertTextIntoTextarea"
| "filesChanged"
| "checkpoint_created"
| "checkpoint_restored"
| "say"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
@ -199,6 +205,10 @@ export interface ExtensionMessage {
context?: string
commands?: Command[]
queuedMessages?: QueuedMessage[]
filesChanged?: FileChangeset // Added filesChanged property
checkpoint?: string // For checkpoint_created and checkpoint_restored messages
previousCheckpoint?: string // For checkpoint_created message
say?: ClineSay // Added say property
}
export type ExtensionState = Pick<
@ -342,6 +352,7 @@ export type ExtensionState = Pick<
mcpServers?: McpServer[]
hasSystemPromptOverride?: boolean
mdmCompliant?: boolean
filesChangedEnabled: boolean
}
export interface ClineSayTool {

View file

@ -50,6 +50,7 @@ export interface WebviewMessage {
| "alwaysAllowUpdateTodoList"
| "followupAutoApproveTimeoutMs"
| "webviewDidLaunch"
| "webviewReady"
| "newTask"
| "askResponse"
| "terminalOperation"
@ -221,6 +222,14 @@ export interface WebviewMessage {
| "queueMessage"
| "removeQueuedMessage"
| "editQueuedMessage"
| "viewDiff"
| "acceptFileChange"
| "rejectFileChange"
| "acceptAllFileChanges"
| "rejectAllFileChanges"
| "filesChangedEnabled"
| "filesChangedRequest"
| "filesChangedBaselineUpdate"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@ -292,6 +301,17 @@ export interface WebviewMessage {
codebaseIndexMistralApiKey?: string
codebaseIndexVercelAiGatewayApiKey?: string
}
command?: string // Added for new message types sent from webview
uri?: string // Added for file URIs in new message types
uris?: string[] // For rejectAllFileChanges to specify which files to reject
baseline?: string // For filesChangedBaselineUpdate message
fileChanges?: Array<{ uri: string; type: string }> // For filesChangedRequest message
}
export interface Terminal {
pid: number
name: string
cwd: string
}
export const checkoutDiffPayloadSchema = z.object({

View file

@ -56,6 +56,7 @@ import SystemPromptWarning from "./SystemPromptWarning"
import ProfileViolationWarning from "./ProfileViolationWarning"
import { CheckpointWarning } from "./CheckpointWarning"
import { QueuedMessages } from "./QueuedMessages"
import FilesChangedOverview from "../file-changes/FilesChangedOverview"
export interface ChatViewProps {
isHidden: boolean
@ -840,7 +841,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
useEvent("message", handleMessage)
// NOTE: the VSCode window needs to be focused for this to work.
useMount(() => textAreaRef.current?.focus())
useMount(() => {
vscode.postMessage({ type: "webviewReady" })
textAreaRef.current?.focus()
})
const visibleMessages = useMemo(() => {
// Pre-compute checkpoint hashes that have associated user messages for O(1) lookup
@ -1803,6 +1807,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
<CheckpointWarning />
</div>
)}
<div className="px-3">
<FilesChangedOverview />
</div>
</>
) : (
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 relative">

View file

@ -0,0 +1,518 @@
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"
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface FilesChangedOverviewProps {}
interface _CheckpointEventData {
type: "checkpoint_created" | "checkpoint_restored"
checkpoint: string
previousCheckpoint?: string
}
/**
* 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<FilesChangedOverviewProps> = () => {
const { t } = useTranslation()
const { filesChangedEnabled } = useExtensionState()
// Self-managed state
const [changeset, setChangeset] = React.useState<FileChangeset | null>(null)
const [isInitialized, setIsInitialized] = React.useState(false)
const files = React.useMemo(() => changeset?.files || [], [changeset?.files])
const [isCollapsed, setIsCollapsed] = React.useState(true)
// Performance optimization: Use virtualization for large file lists
const VIRTUALIZATION_THRESHOLD = 50
const ITEM_HEIGHT = 60 // Approximate height of each file item
const MAX_VISIBLE_ITEMS = 10
const [scrollTop, setScrollTop] = React.useState(0)
const shouldVirtualize = files.length > VIRTUALIZATION_THRESHOLD
// Calculate visible items for virtualization
const visibleItems = React.useMemo(() => {
if (!shouldVirtualize) return files
const startIndex = Math.floor(scrollTop / ITEM_HEIGHT)
const endIndex = Math.min(startIndex + MAX_VISIBLE_ITEMS, files.length)
return files.slice(startIndex, endIndex).map((file, index) => ({
...file,
virtualIndex: startIndex + index,
}))
}, [files, scrollTop, shouldVirtualize])
const totalHeight = shouldVirtualize ? files.length * ITEM_HEIGHT : "auto"
const offsetY = shouldVirtualize ? Math.floor(scrollTop / ITEM_HEIGHT) * ITEM_HEIGHT : 0
// Simple double-click prevention
const [isProcessing, setIsProcessing] = React.useState(false)
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null)
// Cleanup timeout on unmount
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
// FCO initialization logic
const checkInit = React.useCallback(
(baseCheckpoint: string) => {
if (!isInitialized) {
console.log("[FCO] Initializing with base checkpoint:", baseCheckpoint)
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 with the 4 examples logic
const handleCheckpointRestored = React.useCallback((restoredCheckpoint: string) => {
console.log("[FCO] Handling checkpoint restore to:", restoredCheckpoint)
// Request file changes after checkpoint restore
// Backend should calculate changes from initial baseline to restored checkpoint
vscode.postMessage({ type: "filesChangedRequest" })
}, [])
// Action handlers
const handleViewDiff = React.useCallback((uri: string) => {
vscode.postMessage({ type: "viewDiff", uri })
}, [])
const handleAcceptFile = React.useCallback((uri: string) => {
vscode.postMessage({ type: "acceptFileChange", uri })
// Backend will send updated filesChanged message with filtered results
}, [])
const handleRejectFile = React.useCallback((uri: string) => {
vscode.postMessage({ type: "rejectFileChange", uri })
// Backend will send updated filesChanged message with filtered results
}, [])
const handleAcceptAll = React.useCallback(() => {
vscode.postMessage({ type: "acceptAllFileChanges" })
// Backend will send updated filesChanged message with filtered results
}, [])
const handleRejectAll = React.useCallback(() => {
const visibleUris = files.map((file) => file.uri)
vscode.postMessage({ type: "rejectAllFileChanges", uris: visibleUris })
// Backend will send updated filesChanged message with filtered results
}, [files])
const handleWithDebounce = React.useCallback(
async (operation: () => void) => {
if (isProcessing) return
setIsProcessing(true)
try {
operation()
} catch (_error) {
// Silently handle any errors to prevent crashing
// Debug logging removed for production
}
// Brief delay to prevent double-clicks
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => setIsProcessing(false), 300)
},
[isProcessing],
)
/**
* Handles scroll events for virtualization
* Updates scrollTop state to calculate visible items
*/
const handleScroll = React.useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
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) {
console.debug("[FCO] Ignoring malformed message:", message)
return
}
switch (message.type) {
case "filesChanged":
if (message.filesChanged) {
console.log("[FCO] Received filesChanged message:", message.filesChanged)
checkInit(message.filesChanged.baseCheckpoint)
updateChangeset(message.filesChanged)
} else {
// Clear the changeset
setChangeset(null)
}
break
case "checkpoint_created":
console.log("[FCO] Checkpoint created:", message.checkpoint)
handleCheckpointCreated(message.checkpoint, message.previousCheckpoint)
break
case "checkpoint_restored":
console.log("[FCO] Checkpoint restored:", message.checkpoint)
handleCheckpointRestored(message.checkpoint)
break
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [checkInit, updateChangeset, handleCheckpointCreated, handleCheckpointRestored])
/**
* Formats line change counts for display based on file type
* @param file - The file change to format
* @returns Formatted string describing the changes
*/
const formatLineChanges = (file: FileChange): string => {
const added = file.linesAdded || 0
const removed = file.linesRemoved || 0
if (file.type === "create") {
return t("file-changes:line_changes.added", { count: added })
} else if (file.type === "delete") {
return t("file-changes:line_changes.deleted")
} else {
if (added > 0 && removed > 0) {
return t("file-changes:line_changes.added_removed", { added, removed })
} else if (added > 0) {
return t("file-changes:line_changes.added", { count: added })
} else if (removed > 0) {
return t("file-changes:line_changes.removed", { count: removed })
} else {
return t("file-changes:line_changes.modified")
}
}
}
// 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 (
<div
className="files-changed-overview"
data-testid="files-changed-overview"
style={{
border: "1px solid var(--vscode-panel-border)",
borderRadius: "4px",
padding: "12px",
margin: "8px 0",
backgroundColor: "var(--vscode-editor-background)",
}}>
{/* Collapsible header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: isCollapsed ? "0" : "12px",
borderBottom: isCollapsed ? "none" : "1px solid var(--vscode-panel-border)",
paddingBottom: "8px",
cursor: "pointer",
userSelect: "none",
}}
onClick={() => 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")}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span
className={`codicon ${isCollapsed ? "codicon-chevron-right" : "codicon-chevron-down"}`}
style={{
fontSize: "12px",
transition: "transform 0.2s ease",
}}
/>
<h3 style={{ margin: 0, fontSize: "14px", fontWeight: "bold" }} data-testid="files-changed-header">
{t("file-changes:summary.count_with_changes", {
count: files.length,
changes: totalChanges,
})}
</h3>
</div>
{/* Action buttons always visible for quick access */}
<div
style={{ display: "flex", gap: "8px" }}
onClick={(e) => e.stopPropagation()} // Prevent collapse toggle when clicking buttons
>
<button
onClick={() => handleWithDebounce(handleRejectAll)}
disabled={isProcessing}
tabIndex={0}
data-testid="reject-all-button"
style={{
backgroundColor: "var(--vscode-button-secondaryBackground)",
color: "var(--vscode-button-secondaryForeground)",
border: "none",
borderRadius: "3px",
padding: "4px 8px",
fontSize: "12px",
cursor: isProcessing ? "not-allowed" : "pointer",
opacity: isProcessing ? 0.6 : 1,
}}
title={t("file-changes:actions.reject_all")}>
{t("file-changes:actions.reject_all")}
</button>
<button
onClick={() => handleWithDebounce(handleAcceptAll)}
disabled={isProcessing}
tabIndex={0}
data-testid="accept-all-button"
style={{
backgroundColor: "var(--vscode-button-background)",
color: "var(--vscode-button-foreground)",
border: "none",
borderRadius: "3px",
padding: "4px 8px",
fontSize: "12px",
cursor: isProcessing ? "not-allowed" : "pointer",
opacity: isProcessing ? 0.6 : 1,
}}
title={t("file-changes:actions.accept_all")}>
{t("file-changes:actions.accept_all")}
</button>
</div>
</div>
{/* Collapsible content area */}
{!isCollapsed && (
<div
style={{
maxHeight: "300px",
overflowY: "auto",
transition: "opacity 0.2s ease-in-out",
opacity: isCollapsed ? 0 : 1,
position: "relative",
}}
onScroll={handleScroll}>
{shouldVirtualize && (
<div style={{ height: totalHeight, position: "relative" }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((file: any) => (
<FileItem
key={file.uri}
file={file}
formatLineChanges={formatLineChanges}
onViewDiff={handleViewDiff}
onAcceptFile={handleAcceptFile}
onRejectFile={handleRejectFile}
handleWithDebounce={handleWithDebounce}
isProcessing={isProcessing}
t={t}
/>
))}
</div>
</div>
)}
{!shouldVirtualize &&
files.map((file: FileChange) => (
<FileItem
key={file.uri}
file={file}
formatLineChanges={formatLineChanges}
onViewDiff={handleViewDiff}
onAcceptFile={handleAcceptFile}
onRejectFile={handleRejectFile}
handleWithDebounce={handleWithDebounce}
isProcessing={isProcessing}
t={t}
/>
))}
</div>
)}
</div>
)
}
/**
* 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, any>) => 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<FileItemProps> = React.memo(
({ file, formatLineChanges, onViewDiff, onAcceptFile, onRejectFile, handleWithDebounce, isProcessing, t }) => (
<div
data-testid={`file-item-${file.uri}`}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "6px 8px",
marginBottom: "4px",
backgroundColor: "var(--vscode-list-hoverBackground)",
borderRadius: "3px",
fontSize: "13px",
minHeight: "60px", // Consistent height for virtualization
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontFamily: "var(--vscode-editor-font-family)",
fontSize: "12px",
color: "var(--vscode-editor-foreground)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{file.uri}
</div>
<div
style={{
fontSize: "11px",
color: "var(--vscode-descriptionForeground)",
marginTop: "2px",
}}>
{t(`file-changes:file_types.${file.type}`)} {formatLineChanges(file)}
</div>
</div>
<div style={{ display: "flex", gap: "4px", marginLeft: "8px" }}>
<button
onClick={() => handleWithDebounce(() => onViewDiff(file.uri))}
disabled={isProcessing}
title={t("file-changes:actions.view_diff")}
data-testid={`diff-${file.uri}`}
style={{
backgroundColor: "transparent",
color: "var(--vscode-button-foreground)",
border: "1px solid var(--vscode-button-border)",
borderRadius: "3px",
padding: "2px 6px",
fontSize: "11px",
cursor: isProcessing ? "not-allowed" : "pointer",
minWidth: "50px",
opacity: isProcessing ? 0.6 : 1,
}}>
{t("file-changes:actions.view_diff")}
</button>
<button
onClick={() => handleWithDebounce(() => onRejectFile(file.uri))}
disabled={isProcessing}
title={t("file-changes:actions.reject_file")}
data-testid={`reject-${file.uri}`}
style={{
backgroundColor: "var(--vscode-button-secondaryBackground)",
color: "var(--vscode-button-secondaryForeground)",
border: "1px solid var(--vscode-button-border)",
borderRadius: "3px",
padding: "2px 6px",
fontSize: "11px",
cursor: isProcessing ? "not-allowed" : "pointer",
minWidth: "20px",
opacity: isProcessing ? 0.6 : 1,
}}>
</button>
<button
onClick={() => handleWithDebounce(() => onAcceptFile(file.uri))}
disabled={isProcessing}
title={t("file-changes:actions.accept_file")}
data-testid={`accept-${file.uri}`}
style={{
backgroundColor: "var(--vscode-button-background)",
color: "var(--vscode-button-foreground)",
border: "1px solid var(--vscode-button-border)",
borderRadius: "3px",
padding: "2px 6px",
fontSize: "11px",
cursor: isProcessing ? "not-allowed" : "pointer",
minWidth: "20px",
opacity: isProcessing ? 0.6 : 1,
}}>
</button>
</div>
</div>
),
)
FileItem.displayName = "FileItem"
export default FilesChangedOverview

View file

@ -0,0 +1,845 @@
// Tests for self-managing FilesChangedOverview component
// npx vitest run src/components/file-changes/__tests__/FilesChangedOverview.updated.spec.tsx
import React from "react"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { vi } from "vitest"
import { ExtensionStateContext } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
import { FileChangeType } from "@roo-code/types"
import FilesChangedOverview from "../FilesChangedOverview"
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: any) => {
// Simple key mapping for tests
const translations: Record<string, string> = {
"file-changes:summary.count_with_changes": `${options?.count || 0} files changed${options?.changes || ""}`,
"file-changes:actions.accept_all": "Accept All",
"file-changes:actions.reject_all": "Reject All",
"file-changes:actions.view_diff": "View Diff",
"file-changes:actions.accept_file": "Accept",
"file-changes:actions.reject_file": "Reject",
"file-changes:file_types.edit": "Modified",
"file-changes:file_types.create": "Created",
"file-changes:file_types.delete": "Deleted",
"file-changes:line_changes.added": `+${options?.count || 0}`,
"file-changes:line_changes.removed": `-${options?.count || 0}`,
"file-changes:line_changes.added_removed": `+${options?.added || 0}, -${options?.removed || 0}`,
"file-changes:line_changes.deleted": "deleted",
"file-changes:line_changes.modified": "modified",
"file-changes:accessibility.files_list": `${options?.count || 0} files ${options?.state || ""}`,
"file-changes:accessibility.expanded": "expanded",
"file-changes:accessibility.collapsed": "collapsed",
"file-changes:header.expand": "Expand",
"file-changes:header.collapse": "Collapse",
}
return translations[key] || key
},
}),
}))
describe("FilesChangedOverview (Self-Managing)", () => {
const mockExtensionState = {
filesChangedEnabled: true,
// Other required state properties
}
const mockFilesChanged = [
{
uri: "src/components/test1.ts",
type: "edit" as FileChangeType,
fromCheckpoint: "hash1",
toCheckpoint: "hash2",
linesAdded: 10,
linesRemoved: 5,
},
{
uri: "src/components/test2.ts",
type: "create" as FileChangeType,
fromCheckpoint: "hash1",
toCheckpoint: "hash2",
linesAdded: 25,
linesRemoved: 0,
},
]
const mockChangeset = {
baseCheckpoint: "hash1",
files: mockFilesChanged,
}
beforeEach(() => {
vi.clearAllMocks()
// Mock window.addEventListener for message handling
vi.spyOn(window, "addEventListener")
vi.spyOn(window, "removeEventListener")
})
afterEach(() => {
vi.restoreAllMocks()
})
const renderComponent = () => {
return render(
<ExtensionStateContext.Provider value={mockExtensionState as any}>
<FilesChangedOverview />
</ExtensionStateContext.Provider>,
)
}
// Helper to simulate messages from backend
const simulateMessage = (message: any) => {
const messageEvent = new MessageEvent("message", {
data: message,
})
window.dispatchEvent(messageEvent)
}
// Helper to setup component with files for integration tests
const setupComponentWithFiles = async () => {
renderComponent()
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
}
it("should render without errors when no files changed", () => {
renderComponent()
// Component should not render anything when no files
expect(screen.queryByTestId("files-changed-overview")).not.toBeInTheDocument()
})
it("should listen for window messages on mount", () => {
renderComponent()
expect(window.addEventListener).toHaveBeenCalledWith("message", expect.any(Function))
})
it("should remove event listener on unmount", () => {
const { unmount } = renderComponent()
unmount()
expect(window.removeEventListener).toHaveBeenCalledWith("message", expect.any(Function))
})
it("should display files when receiving filesChanged message", async () => {
renderComponent()
// Simulate receiving filesChanged message
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Check header shows file count
expect(screen.getByTestId("files-changed-header")).toHaveTextContent("2 files changed")
})
it("should handle checkpoint_created message", async () => {
renderComponent()
// Simulate checkpoint created event
simulateMessage({
type: "checkpoint_created",
checkpoint: "new-checkpoint-hash",
previousCheckpoint: "previous-hash",
})
// Backend automatically sends filesChanged message after checkpoint creation
// So we simulate that behavior
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
})
it("should handle checkpoint_restored message", async () => {
renderComponent()
// First set up some files
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Simulate checkpoint restore
simulateMessage({
type: "checkpoint_restored",
checkpoint: "restored-checkpoint-hash",
})
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "filesChangedRequest",
})
})
})
it("should expand/collapse when header is clicked", async () => {
renderComponent()
// Add some files first
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Component should start collapsed
expect(screen.queryByTestId("file-item-src/components/test1.ts")).not.toBeInTheDocument()
// Click to expand
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
fireEvent.click(header!)
await waitFor(() => {
expect(screen.getByTestId("file-item-src/components/test1.ts")).toBeInTheDocument()
})
})
it("should send accept file message when accept button clicked", async () => {
renderComponent()
// Add files and expand
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Expand to show files
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
fireEvent.click(header!)
await waitFor(() => {
expect(screen.getByTestId("file-item-src/components/test1.ts")).toBeInTheDocument()
})
// Click accept button
const acceptButton = screen.getByTestId("accept-src/components/test1.ts")
fireEvent.click(acceptButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "acceptFileChange",
uri: "src/components/test1.ts",
})
})
it("should send reject file message when reject button clicked", async () => {
renderComponent()
// Add files and expand
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Expand to show files
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
fireEvent.click(header!)
await waitFor(() => {
expect(screen.getByTestId("file-item-src/components/test1.ts")).toBeInTheDocument()
})
// Click reject button
const rejectButton = screen.getByTestId("reject-src/components/test1.ts")
fireEvent.click(rejectButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "rejectFileChange",
uri: "src/components/test1.ts",
})
})
it("should send accept all message when accept all button clicked", async () => {
renderComponent()
// Add files
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Click accept all button
const acceptAllButton = screen.getByTestId("accept-all-button")
fireEvent.click(acceptAllButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "acceptAllFileChanges",
})
})
it("should send reject all message when reject all button clicked", async () => {
renderComponent()
// Add files
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Click reject all button
const rejectAllButton = screen.getByTestId("reject-all-button")
fireEvent.click(rejectAllButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "rejectAllFileChanges",
uris: ["src/components/test1.ts", "src/components/test2.ts"],
})
})
it("should send accept message and update display when backend sends filtered results", async () => {
renderComponent()
// Add files
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Expand to show files
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
fireEvent.click(header!)
await waitFor(() => {
expect(screen.getByTestId("file-item-src/components/test1.ts")).toBeInTheDocument()
expect(screen.getByTestId("file-item-src/components/test2.ts")).toBeInTheDocument()
})
// Accept one file
const acceptButton = screen.getByTestId("accept-src/components/test1.ts")
fireEvent.click(acceptButton)
// Should send message to backend
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "acceptFileChange",
uri: "src/components/test1.ts",
})
// Backend responds with filtered results (only unaccepted files)
const filteredChangeset = {
baseCheckpoint: "hash1",
files: [mockFilesChanged[1]], // Only the second file
}
simulateMessage({
type: "filesChanged",
filesChanged: filteredChangeset,
})
// File should be filtered out from display
await waitFor(() => {
expect(screen.queryByTestId("file-item-src/components/test1.ts")).not.toBeInTheDocument()
expect(screen.getByTestId("file-item-src/components/test2.ts")).toBeInTheDocument()
})
})
it("should not render when filesChangedEnabled is false", () => {
const disabledState = { ...mockExtensionState, filesChangedEnabled: false }
render(
<ExtensionStateContext.Provider value={disabledState as any}>
<FilesChangedOverview />
</ExtensionStateContext.Provider>,
)
// Add files
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
// Component should not render when disabled
expect(screen.queryByTestId("files-changed-overview")).not.toBeInTheDocument()
})
it("should clear files when receiving empty filesChanged message", async () => {
renderComponent()
// First add files
simulateMessage({
type: "filesChanged",
filesChanged: mockChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Clear files with empty message
simulateMessage({
type: "filesChanged",
filesChanged: undefined,
})
await waitFor(() => {
expect(screen.queryByTestId("files-changed-overview")).not.toBeInTheDocument()
})
})
// ===== INTEGRATION TESTS =====
describe("Message Type Validation", () => {
it("should send viewDiff message for individual file action", async () => {
vi.clearAllMocks()
await setupComponentWithFiles()
// Expand to show individual files
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
fireEvent.click(header!)
await waitFor(() => {
expect(screen.getByTestId("file-item-src/components/test1.ts")).toBeInTheDocument()
})
// Test diff button
const diffButton = screen.getByTestId("diff-src/components/test1.ts")
fireEvent.click(diffButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "viewDiff",
uri: "src/components/test1.ts",
})
})
it("should send acceptAllFileChanges message correctly", async () => {
vi.clearAllMocks()
await setupComponentWithFiles()
const acceptAllButton = screen.getByTestId("accept-all-button")
fireEvent.click(acceptAllButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "acceptAllFileChanges",
})
})
it("should send rejectAllFileChanges message correctly", async () => {
vi.clearAllMocks()
await setupComponentWithFiles()
const rejectAllButton = screen.getByTestId("reject-all-button")
fireEvent.click(rejectAllButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "rejectAllFileChanges",
uris: ["src/components/test1.ts", "src/components/test2.ts"],
})
})
it("should only send URIs of visible files in reject all, not all changed files", async () => {
vi.clearAllMocks()
// Create a larger changeset with more files than what's visible
const allChangedFiles = [
{
uri: "src/components/visible1.ts",
type: "edit" as FileChangeType,
fromCheckpoint: "hash1",
toCheckpoint: "hash2",
linesAdded: 10,
linesRemoved: 5,
},
{
uri: "src/components/visible2.ts",
type: "create" as FileChangeType,
fromCheckpoint: "hash1",
toCheckpoint: "hash2",
linesAdded: 25,
linesRemoved: 0,
},
{
uri: "src/utils/hidden1.ts",
type: "edit" as FileChangeType,
fromCheckpoint: "hash1",
toCheckpoint: "hash2",
linesAdded: 15,
linesRemoved: 3,
},
{
uri: "src/utils/hidden2.ts",
type: "delete" as FileChangeType,
fromCheckpoint: "hash1",
toCheckpoint: "hash2",
linesAdded: 0,
linesRemoved: 20,
},
]
const largeChangeset = {
baseCheckpoint: "hash1",
files: allChangedFiles,
}
renderComponent()
// Simulate receiving a large changeset
simulateMessage({
type: "filesChanged",
filesChanged: largeChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
// Now simulate backend filtering to show only some files (e.g., after accepting some)
const filteredChangeset = {
baseCheckpoint: "hash1",
files: [allChangedFiles[0], allChangedFiles[1]], // Only first 2 files visible
}
simulateMessage({
type: "filesChanged",
filesChanged: filteredChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-header")).toHaveTextContent("2 files changed")
})
// Click reject all button
const rejectAllButton = screen.getByTestId("reject-all-button")
fireEvent.click(rejectAllButton)
// Should only send URIs of the 2 visible files, not all 4 changed files
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "rejectAllFileChanges",
uris: ["src/components/visible1.ts", "src/components/visible2.ts"],
})
// Verify it doesn't include the hidden files
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "rejectAllFileChanges",
uris: expect.arrayContaining(["src/utils/hidden1.ts", "src/utils/hidden2.ts"]),
})
})
})
// ===== ACCESSIBILITY COMPLIANCE =====
describe("Accessibility Compliance", () => {
it("should have proper ARIA attributes for main interactive elements", async () => {
await setupComponentWithFiles()
// Header should have proper ARIA attributes
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
expect(header).toHaveAttribute("role", "button")
expect(header).toHaveAttribute("aria-expanded", "false")
expect(header).toHaveAttribute("aria-label")
// ARIA label should be translated (shows actual file count in tests)
const ariaLabel = header!.getAttribute("aria-label")
expect(ariaLabel).toBe("2 files collapsed")
// Action buttons should have proper attributes
const acceptAllButton = screen.getByTestId("accept-all-button")
const rejectAllButton = screen.getByTestId("reject-all-button")
expect(acceptAllButton).toHaveAttribute("title", "Accept All")
expect(rejectAllButton).toHaveAttribute("title", "Reject All")
expect(acceptAllButton).toHaveAttribute("tabIndex", "0")
expect(rejectAllButton).toHaveAttribute("tabIndex", "0")
})
it("should update ARIA attributes when state changes", async () => {
await setupComponentWithFiles()
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
expect(header).toHaveAttribute("aria-expanded", "false")
// Expand
fireEvent.click(header!)
await waitFor(() => {
expect(header).toHaveAttribute("aria-expanded", "true")
})
// ARIA label should be translated (shows actual file count in tests)
const expandedAriaLabel = header!.getAttribute("aria-label")
expect(expandedAriaLabel).toBe("2 files expanded")
})
it("should provide meaningful tooltips for file actions", async () => {
await setupComponentWithFiles()
// Expand to show individual file actions
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
fireEvent.click(header!)
await waitFor(() => {
expect(screen.getByTestId("file-item-src/components/test1.ts")).toBeInTheDocument()
})
// File action buttons should have descriptive tooltips
const viewDiffButton = screen.getByTestId("diff-src/components/test1.ts")
const acceptButton = screen.getByTestId("accept-src/components/test1.ts")
expect(viewDiffButton).toHaveAttribute("title", "View Diff")
expect(acceptButton).toHaveAttribute("title", "Accept")
})
})
// ===== ERROR HANDLING =====
describe("Error Handling", () => {
it("should handle malformed filesChanged messages gracefully", () => {
renderComponent()
// Send malformed message
simulateMessage({
type: "filesChanged",
// Missing filesChanged property
})
// Should not crash or render component
expect(screen.queryByTestId("files-changed-overview")).not.toBeInTheDocument()
})
it("should handle malformed checkpoint messages gracefully", () => {
renderComponent()
// Send checkpoint message without required fields
simulateMessage({
type: "checkpoint_created",
// Missing checkpoint property
})
// Should not crash - component is resilient
expect(screen.queryByTestId("files-changed-overview")).not.toBeInTheDocument()
})
it("should handle undefined/null message data gracefully", () => {
renderComponent()
// Send message with null data (simulates real-world edge case)
const nullEvent = new MessageEvent("message", {
data: null,
})
// Should handle null data gracefully without throwing
expect(() => window.dispatchEvent(nullEvent)).not.toThrow()
// Should not render component with null data
expect(screen.queryByTestId("files-changed-overview")).not.toBeInTheDocument()
// Test other malformed message types
const undefinedEvent = new MessageEvent("message", {
data: undefined,
})
const stringEvent = new MessageEvent("message", {
data: "invalid",
})
const objectWithoutTypeEvent = new MessageEvent("message", {
data: { someField: "value" },
})
// All should be handled gracefully
expect(() => {
window.dispatchEvent(undefinedEvent)
window.dispatchEvent(stringEvent)
window.dispatchEvent(objectWithoutTypeEvent)
}).not.toThrow()
// Still should not render component
expect(screen.queryByTestId("files-changed-overview")).not.toBeInTheDocument()
})
it("should handle vscode API errors gracefully", async () => {
// Mock postMessage to throw error
vi.mocked(vscode.postMessage).mockImplementation(() => {
throw new Error("VSCode API error")
})
await setupComponentWithFiles()
// Expand to show individual files
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
fireEvent.click(header!)
await waitFor(() => {
expect(screen.getByTestId("file-item-src/components/test1.ts")).toBeInTheDocument()
})
// Clicking buttons should not crash the component
const acceptButton = screen.getByTestId("accept-src/components/test1.ts")
expect(() => fireEvent.click(acceptButton)).not.toThrow()
// Restore mock
vi.mocked(vscode.postMessage).mockRestore()
})
})
// ===== PERFORMANCE & EDGE CASES =====
describe("Performance and Edge Cases", () => {
it("should handle large file sets efficiently", async () => {
// Create large changeset (50 files)
const largeFiles = Array.from({ length: 50 }, (_, i) => ({
uri: `src/file${i}.ts`,
type: "edit" as FileChangeType,
fromCheckpoint: "hash1",
toCheckpoint: "hash2",
linesAdded: 10,
linesRemoved: 5,
}))
const largeChangeset = {
baseCheckpoint: "hash1",
files: largeFiles,
}
renderComponent()
// Should render efficiently with large dataset
const startTime = performance.now()
simulateMessage({
type: "filesChanged",
filesChanged: largeChangeset,
})
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
})
const renderTime = performance.now() - startTime
// Rendering should be fast (under 500ms for 50 files)
expect(renderTime).toBeLessThan(500)
// Header should show correct count
expect(screen.getByTestId("files-changed-header")).toHaveTextContent("50 files changed")
})
it("should handle rapid message updates", async () => {
renderComponent()
// Send multiple rapid updates
for (let i = 0; i < 5; i++) {
simulateMessage({
type: "filesChanged",
filesChanged: {
baseCheckpoint: `hash${i}`,
files: [
{
uri: `src/rapid${i}.ts`,
type: "edit" as FileChangeType,
fromCheckpoint: `hash${i}`,
toCheckpoint: `hash${i + 1}`,
linesAdded: i + 1,
linesRemoved: 0,
},
],
},
})
}
// Should show latest update (1 file from last message)
await waitFor(() => {
expect(screen.getByTestId("files-changed-overview")).toBeInTheDocument()
expect(screen.getByTestId("files-changed-header")).toHaveTextContent("1 files changed")
})
})
it("should handle empty file changesets", async () => {
renderComponent()
// Send empty changeset
simulateMessage({
type: "filesChanged",
filesChanged: {
baseCheckpoint: "hash1",
files: [],
},
})
// Should not render component with empty files
expect(screen.queryByTestId("files-changed-overview")).not.toBeInTheDocument()
})
})
// ===== INTERNATIONALIZATION =====
describe("Internationalization", () => {
it("should use proper translation keys for all UI elements", async () => {
await setupComponentWithFiles()
// Header should use translated text with file count and line changes
expect(screen.getByTestId("files-changed-header")).toHaveTextContent("2 files changed")
expect(screen.getByTestId("files-changed-header")).toHaveTextContent("(+35, -5)")
// Action buttons should use translations
expect(screen.getByTestId("accept-all-button")).toHaveAttribute("title", "Accept All")
expect(screen.getByTestId("reject-all-button")).toHaveAttribute("title", "Reject All")
})
it("should format file type labels correctly", async () => {
await setupComponentWithFiles()
// Expand to show individual files
const header = screen.getByTestId("files-changed-header").closest('[role="button"]')
fireEvent.click(header!)
await waitFor(() => {
expect(screen.getByTestId("file-item-src/components/test1.ts")).toBeInTheDocument()
})
// File type labels should be translated
// Check for file type labels within the file items (main test data has different files)
const editedFile = screen.getByTestId("file-item-src/components/test1.ts")
const createdFile = screen.getByTestId("file-item-src/components/test2.ts")
expect(editedFile).toHaveTextContent("Modified")
expect(createdFile).toHaveTextContent("Created")
})
it("should handle line count formatting for different locales", async () => {
await setupComponentWithFiles()
// Header should format line changes correctly
const header = screen.getByTestId("files-changed-header")
expect(header).toHaveTextContent("+35, -5") // Standard format
})
})
})

View file

@ -16,6 +16,7 @@ import {
GitBranch,
Bell,
Database,
Monitor,
SquareTerminal,
FlaskConical,
AlertTriangle,
@ -58,6 +59,7 @@ import { BrowserSettings } from "./BrowserSettings"
import { CheckpointSettings } from "./CheckpointSettings"
import { NotificationSettings } from "./NotificationSettings"
import { ContextManagementSettings } from "./ContextManagementSettings"
import { UISettings } from "./UISettings"
import { TerminalSettings } from "./TerminalSettings"
import { ExperimentalSettings } from "./ExperimentalSettings"
import { LanguageSettings } from "./LanguageSettings"
@ -83,6 +85,7 @@ const sectionNames = [
"checkpoints",
"notifications",
"contextManagement",
"ui",
"terminal",
"prompts",
"experimental",
@ -188,6 +191,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
includeTaskHistoryInEnhance,
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
filesChangedEnabled,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@ -350,6 +354,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 })
vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages })
vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 })
vscode.postMessage({ type: "filesChangedEnabled", bool: filesChangedEnabled })
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
vscode.postMessage({ type: "updateExperimental", values: experiments })
vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
@ -452,6 +457,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{ id: "checkpoints", icon: GitBranch },
{ id: "notifications", icon: Bell },
{ id: "contextManagement", icon: Database },
{ id: "ui", icon: Monitor },
{ id: "terminal", icon: SquareTerminal },
{ id: "prompts", icon: MessageSquare },
{ id: "experimental", icon: FlaskConical },
@ -720,6 +726,14 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
/>
)}
{/* UI Section */}
{activeTab === "ui" && (
<UISettings
filesChangedEnabled={filesChangedEnabled}
setCachedStateField={setCachedStateField}
/>
)}
{/* Terminal Section */}
{activeTab === "terminal" && (
<TerminalSettings

View file

@ -0,0 +1,45 @@
import { HTMLAttributes } from "react"
import React from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { Monitor } from "lucide-react"
import { cn } from "@/lib/utils"
import { SetCachedStateField } from "./types"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
type UISettingsProps = HTMLAttributes<HTMLDivElement> & {
filesChangedEnabled?: boolean
setCachedStateField: SetCachedStateField<"filesChangedEnabled">
}
export const UISettings = ({ filesChangedEnabled, setCachedStateField, className, ...props }: UISettingsProps) => {
const { t } = useAppTranslation()
return (
<div className={cn("flex flex-col gap-2", className)} {...props}>
<SectionHeader description={t("settings:ui.description")}>
<div className="flex items-center gap-2">
<Monitor className="w-4" />
<div>{t("settings:sections.ui")}</div>
</div>
</SectionHeader>
<Section>
<div>
<VSCodeCheckbox
checked={filesChangedEnabled}
onChange={(e: any) => setCachedStateField("filesChangedEnabled", e.target.checked)}
data-testid="files-changed-enabled-checkbox">
<label className="block font-medium mb-1">{t("settings:ui.filesChanged.label")}</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:ui.filesChanged.description")}
</div>
</div>
</Section>
</div>
)
}

View file

@ -0,0 +1,192 @@
import { render, screen, fireEvent } from "@/utils/test-utils"
import { UISettings } from "@src/components/settings/UISettings"
// Mock translation hook to return the key as the translation
vitest.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock VSCode components to behave like standard HTML elements
vitest.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeCheckbox: ({ checked, onChange, children, "data-testid": dataTestId, ...props }: any) => (
<div>
<input
type="checkbox"
checked={checked}
onChange={onChange}
data-testid={dataTestId}
aria-label={children?.props?.children || children}
role="checkbox"
aria-checked={checked}
{...props}
/>
{children}
</div>
),
}))
describe("UISettings", () => {
const defaultProps = {
filesChangedEnabled: false,
setCachedStateField: vitest.fn(),
}
beforeEach(() => {
vitest.clearAllMocks()
})
it("renders the UI settings section", () => {
render(<UISettings {...defaultProps} />)
// Check that the section header is rendered
expect(screen.getByText("settings:sections.ui")).toBeInTheDocument()
expect(screen.getByText("settings:ui.description")).toBeInTheDocument()
})
it("renders the files changed overview checkbox", () => {
render(<UISettings {...defaultProps} />)
// Files changed overview checkbox
const filesChangedCheckbox = screen.getByTestId("files-changed-enabled-checkbox")
expect(filesChangedCheckbox).toBeInTheDocument()
expect(filesChangedCheckbox).not.toBeChecked()
// Check label and description are present
expect(screen.getByText("settings:ui.filesChanged.label")).toBeInTheDocument()
expect(screen.getByText("settings:ui.filesChanged.description")).toBeInTheDocument()
})
it("displays correct state when filesChangedEnabled is true", () => {
const propsWithEnabled = {
...defaultProps,
filesChangedEnabled: true,
}
render(<UISettings {...propsWithEnabled} />)
const checkbox = screen.getByTestId("files-changed-enabled-checkbox")
expect(checkbox).toBeChecked()
})
it("displays correct state when filesChangedEnabled is false", () => {
const propsWithDisabled = {
...defaultProps,
filesChangedEnabled: false,
}
render(<UISettings {...propsWithDisabled} />)
const checkbox = screen.getByTestId("files-changed-enabled-checkbox")
expect(checkbox).not.toBeChecked()
})
it("calls setCachedStateField when files changed checkbox is toggled", () => {
const mockSetCachedStateField = vitest.fn()
const props = {
...defaultProps,
filesChangedEnabled: false,
setCachedStateField: mockSetCachedStateField,
}
render(<UISettings {...props} />)
const checkbox = screen.getByTestId("files-changed-enabled-checkbox")
fireEvent.click(checkbox)
expect(mockSetCachedStateField).toHaveBeenCalledWith("filesChangedEnabled", true)
})
it("calls setCachedStateField with false when enabled checkbox is clicked", () => {
const mockSetCachedStateField = vitest.fn()
const props = {
...defaultProps,
filesChangedEnabled: true,
setCachedStateField: mockSetCachedStateField,
}
render(<UISettings {...props} />)
const checkbox = screen.getByTestId("files-changed-enabled-checkbox")
fireEvent.click(checkbox)
expect(mockSetCachedStateField).toHaveBeenCalledWith("filesChangedEnabled", false)
})
it("handles undefined filesChangedEnabled gracefully", () => {
const propsWithUndefined = {
...defaultProps,
filesChangedEnabled: undefined,
}
expect(() => {
render(<UISettings {...propsWithUndefined} />)
}).not.toThrow()
const checkbox = screen.getByTestId("files-changed-enabled-checkbox")
expect(checkbox).not.toBeChecked() // Should default to false for undefined
})
describe("Accessibility", () => {
it("has proper labels and descriptions", () => {
render(<UISettings {...defaultProps} />)
// Check that labels are present
expect(screen.getByText("settings:ui.filesChanged.label")).toBeInTheDocument()
// Check that descriptions are present
expect(screen.getByText("settings:ui.filesChanged.description")).toBeInTheDocument()
})
it("has proper test ids for all interactive elements", () => {
render(<UISettings {...defaultProps} />)
expect(screen.getByTestId("files-changed-enabled-checkbox")).toBeInTheDocument()
})
it("has proper checkbox role and aria attributes", () => {
render(<UISettings {...defaultProps} />)
const checkbox = screen.getByTestId("files-changed-enabled-checkbox")
expect(checkbox).toHaveAttribute("role", "checkbox")
expect(checkbox).toHaveAttribute("aria-checked", "false")
})
it("updates aria-checked when state changes", () => {
const propsWithEnabled = {
...defaultProps,
filesChangedEnabled: true,
}
render(<UISettings {...propsWithEnabled} />)
const checkbox = screen.getByTestId("files-changed-enabled-checkbox")
expect(checkbox).toHaveAttribute("aria-checked", "true")
})
})
describe("Integration with translation system", () => {
it("uses translation keys for all text content", () => {
render(<UISettings {...defaultProps} />)
// Verify that translation keys are being used (mocked to return the key)
expect(screen.getByText("settings:sections.ui")).toBeInTheDocument()
expect(screen.getByText("settings:ui.description")).toBeInTheDocument()
expect(screen.getByText("settings:ui.filesChanged.label")).toBeInTheDocument()
expect(screen.getByText("settings:ui.filesChanged.description")).toBeInTheDocument()
})
})
describe("Component structure", () => {
it("renders with custom className", () => {
const { container } = render(<UISettings {...defaultProps} className="custom-class" />)
const uiSettingsDiv = container.firstChild as HTMLElement
expect(uiSettingsDiv).toHaveClass("custom-class")
})
it("passes through additional props", () => {
const { container } = render(<UISettings {...defaultProps} data-custom="test-value" />)
const uiSettingsDiv = container.firstChild as HTMLElement
expect(uiSettingsDiv).toHaveAttribute("data-custom", "test-value")
})
})
})

View file

@ -40,6 +40,8 @@ export interface ExtensionStateContextType extends ExtensionState {
organizationSettingsVersion: number
cloudIsAuthenticated: boolean
sharingEnabled: boolean
currentFileChangeset?: import("@roo-code/types").FileChangeset
setCurrentFileChangeset: (changeset: import("@roo-code/types").FileChangeset | undefined) => void
maxConcurrentFileReads?: number
mdmCompliant?: boolean
hasOpenedModeSelector: boolean // New property to track if user has opened mode selector
@ -151,6 +153,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setMaxDiagnosticMessages: (value: number) => void
includeTaskHistoryInEnhance?: boolean
setIncludeTaskHistoryInEnhance: (value: boolean) => void
filesChangedEnabled: boolean
setFilesChangedEnabled: (value: boolean) => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@ -250,6 +254,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
codebaseIndexSearchMinScore: undefined,
},
codebaseIndexModels: { ollama: {}, openai: {} },
filesChangedEnabled: true,
alwaysAllowUpdateTodoList: true,
includeDiagnosticMessages: true,
maxDiagnosticMessages: 50,
@ -269,6 +274,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [marketplaceItems, setMarketplaceItems] = useState<any[]>([])
const [alwaysAllowFollowupQuestions, setAlwaysAllowFollowupQuestions] = useState(false) // Add state for follow-up questions auto-approve
const [followupAutoApproveTimeoutMs, setFollowupAutoApproveTimeoutMs] = useState<number | undefined>(undefined) // Will be set from global settings
const [currentFileChangeset, setCurrentFileChangeset] = useState<
import("@roo-code/types").FileChangeset | undefined
>(undefined)
const [marketplaceInstalledMetadata, setMarketplaceInstalledMetadata] = useState<MarketplaceInstalledMetadata>({
project: {},
global: {},
@ -377,6 +385,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
}
break
}
case "filesChanged": {
if (message.filesChanged) {
setCurrentFileChangeset(message.filesChanged)
} else {
setCurrentFileChangeset(undefined)
}
break
}
}
},
[setListApiConfigMeta],
@ -527,6 +543,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
},
includeTaskHistoryInEnhance,
setIncludeTaskHistoryInEnhance,
currentFileChangeset,
setCurrentFileChangeset,
filesChangedEnabled: state.filesChangedEnabled,
setFilesChangedEnabled: (value) => {
setState((prevState) => ({ ...prevState, filesChangedEnabled: value }))
},
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

@ -211,6 +211,7 @@ describe("mergeExtensionState", () => {
hasOpenedModeSelector: false, // Add the new required property
maxImageFileSize: 5,
maxTotalImageSize: 20,
filesChangedEnabled: true,
}
const prevState: ExtensionState = {

View file

@ -0,0 +1,35 @@
{
"header": {
"files_changed": "Fitxers Modificats",
"expand": "Expandir llista de fitxers",
"collapse": "Contreure 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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -27,6 +27,7 @@
"checkpoints": "Checkpoints",
"notifications": "Notifications",
"contextManagement": "Context",
"ui": "Interface",
"terminal": "Terminal",
"prompts": "Prompts",
"experimental": "Experimental",
@ -598,6 +599,13 @@
"usesGlobal": "(uses global {{threshold}}%)"
}
},
"ui": {
"description": "Configure interface and display settings",
"filesChanged": {
"label": "Enable Files Changed Overview",
"description": "When enabled, displays a panel showing files that have been modified between checkpoints.\nThis allows you to view diffs and accept/reject individual changes."
}
},
"terminal": {
"basic": {
"label": "Terminal Settings: Basic",

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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": "संक्षिप्त"
}
}

View file

@ -0,0 +1,35 @@
{
"header": {
"files_changed": "File yang Diubah",
"expand": "Perluas daftar file",
"collapse": "Ciutkan 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"
}
}

View file

@ -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"
}
}

View file

@ -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": "折りたたみ済み"
}
}

View file

@ -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": "접혀짐"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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": "Свёрнут"
}
}

View file

@ -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ı"
}
}

View file

@ -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"
}
}

View file

@ -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": "已折叠"
}
}

View file

@ -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": "已摺疊"
}
}