mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
FCO: resolve conflicts, integrate types + UI, and fix provider.getCurrentTask usage
This commit is contained in:
parent
0ce4e891fd
commit
8c2d125f94
42 changed files with 3897 additions and 88 deletions
21
packages/types/src/file-changes.ts
Normal file
21
packages/types/src/file-changes.ts
Normal 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[]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
458
src/services/file-changes/FCOMessageHandler.ts
Normal file
458
src/services/file-changes/FCOMessageHandler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
192
src/services/file-changes/FileChangeManager.ts
Normal file
192
src/services/file-changes/FileChangeManager.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
463
src/services/file-changes/__tests__/FileChangeManager.test.ts
Normal file
463
src/services/file-changes/__tests__/FileChangeManager.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
518
webview-ui/src/components/file-changes/FilesChangedOverview.tsx
Normal file
518
webview-ui/src/components/file-changes/FilesChangedOverview.tsx
Normal 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
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
45
webview-ui/src/components/settings/UISettings.tsx
Normal file
45
webview-ui/src/components/settings/UISettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
webview-ui/src/components/settings/__tests__/UISettings.spec.tsx
Normal file
192
webview-ui/src/components/settings/__tests__/UISettings.spec.tsx
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ describe("mergeExtensionState", () => {
|
|||
hasOpenedModeSelector: false, // Add the new required property
|
||||
maxImageFileSize: 5,
|
||||
maxTotalImageSize: 20,
|
||||
filesChangedEnabled: true,
|
||||
}
|
||||
|
||||
const prevState: ExtensionState = {
|
||||
|
|
|
|||
35
webview-ui/src/i18n/locales/ca/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/ca/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/de/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/de/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/en/file-changes.json
Normal file
35
webview-ui/src/i18n/locales/en/file-changes.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
35
webview-ui/src/i18n/locales/es/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/es/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/fr/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/fr/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/hi/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/hi/file-changes.json
generated
Normal 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": "संक्षिप्त"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/id/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/id/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/it/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/it/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/ja/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/ja/file-changes.json
generated
Normal 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": "折りたたみ済み"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/ko/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/ko/file-changes.json
generated
Normal 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": "접혀짐"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/nl/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/nl/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/pl/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/pl/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/pt-BR/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/pt-BR/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/ru/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/ru/file-changes.json
generated
Normal 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": "Свёрнут"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/tr/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/tr/file-changes.json
generated
Normal 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ı"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/vi/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/vi/file-changes.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/zh-CN/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/zh-CN/file-changes.json
generated
Normal 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": "已折叠"
|
||||
}
|
||||
}
|
||||
35
webview-ui/src/i18n/locales/zh-TW/file-changes.json
generated
Normal file
35
webview-ui/src/i18n/locales/zh-TW/file-changes.json
generated
Normal 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": "已摺疊"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue