mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
753 lines
25 KiB
TypeScript
753 lines
25 KiB
TypeScript
import path from "path"
|
|
import fs from "fs/promises"
|
|
|
|
import { TelemetryService } from "@roo-code/telemetry"
|
|
import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types"
|
|
|
|
import { ClineSayTool } from "../../shared/ExtensionMessage"
|
|
import { getReadablePath } from "../../utils/path"
|
|
import { Task } from "../task/Task"
|
|
import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } from "../../shared/tools"
|
|
import { formatResponse } from "../prompts/responses"
|
|
import { fileExistsAtPath } from "../../utils/fs"
|
|
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
|
|
import { unescapeHtmlEntities } from "../../utils/text-normalization"
|
|
import { parseXmlForDiff } from "../../utils/xml"
|
|
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
|
|
import { applyDiffTool as applyDiffToolClass } from "./ApplyDiffTool"
|
|
import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats"
|
|
import * as vscode from "vscode"
|
|
import { ToolProtocol, isNativeProtocol } from "@roo-code/types"
|
|
import { Package } from "../../shared/package"
|
|
|
|
interface DiffOperation {
|
|
path: string
|
|
diff: Array<{
|
|
content: string
|
|
startLine?: number
|
|
}>
|
|
}
|
|
|
|
// Track operation status
|
|
interface OperationResult {
|
|
path: string
|
|
status: "pending" | "approved" | "denied" | "blocked" | "error"
|
|
error?: string
|
|
result?: string
|
|
diffItems?: Array<{ content: string; startLine?: number }>
|
|
absolutePath?: string
|
|
fileExists?: boolean
|
|
}
|
|
|
|
// Add proper type definitions
|
|
interface ParsedFile {
|
|
path: string
|
|
diff: ParsedDiff | ParsedDiff[]
|
|
}
|
|
|
|
interface ParsedDiff {
|
|
content: string
|
|
start_line?: string
|
|
}
|
|
|
|
interface ParsedXmlResult {
|
|
file: ParsedFile | ParsedFile[]
|
|
}
|
|
|
|
export async function applyDiffTool(
|
|
cline: Task,
|
|
block: ToolUse,
|
|
askApproval: AskApproval,
|
|
handleError: HandleError,
|
|
pushToolResult: PushToolResult,
|
|
removeClosingTag: RemoveClosingTag,
|
|
) {
|
|
// Check if native protocol is enabled - if so, always use single-file class-based tool
|
|
const toolProtocol = vscode.workspace.getConfiguration(Package.name).get<ToolProtocol>("toolProtocol", "xml")
|
|
if (isNativeProtocol(toolProtocol)) {
|
|
return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
})
|
|
}
|
|
|
|
// Check if MULTI_FILE_APPLY_DIFF experiment is enabled
|
|
const provider = cline.providerRef.deref()
|
|
if (provider) {
|
|
const state = await provider.getState()
|
|
const isMultiFileApplyDiffEnabled = experiments.isEnabled(
|
|
state.experiments ?? {},
|
|
EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
|
|
)
|
|
|
|
// If experiment is disabled, use single-file class-based tool
|
|
if (!isMultiFileApplyDiffEnabled) {
|
|
return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Otherwise, continue with new multi-file implementation
|
|
const argsXmlTag: string | undefined = block.params.args
|
|
const legacyPath: string | undefined = block.params.path
|
|
const legacyDiffContent: string | undefined = block.params.diff
|
|
const legacyStartLineStr: string | undefined = block.params.start_line
|
|
|
|
let operationsMap: Record<string, DiffOperation> = {}
|
|
let usingLegacyParams = false
|
|
let filteredOperationErrors: string[] = []
|
|
|
|
// Handle partial message first
|
|
if (block.partial) {
|
|
let filePath = ""
|
|
if (argsXmlTag) {
|
|
const match = argsXmlTag.match(/<file>.*?<path>([^<]+)<\/path>/s)
|
|
if (match) {
|
|
filePath = match[1]
|
|
}
|
|
} else if (legacyPath) {
|
|
// Use legacy path if argsXmlTag is not present for partial messages
|
|
filePath = legacyPath
|
|
}
|
|
|
|
const sharedMessageProps: ClineSayTool = {
|
|
tool: "appliedDiff",
|
|
path: getReadablePath(cline.cwd, filePath),
|
|
}
|
|
const partialMessage = JSON.stringify(sharedMessageProps)
|
|
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
|
|
return
|
|
}
|
|
|
|
if (argsXmlTag) {
|
|
// Parse file entries from XML (new way)
|
|
try {
|
|
// IMPORTANT: We use parseXmlForDiff here instead of parseXml to prevent HTML entity decoding
|
|
// This ensures exact character matching when comparing parsed content against original file content
|
|
// Without this, special characters like & would be decoded to & causing diff mismatches
|
|
const parsed = parseXmlForDiff(argsXmlTag, ["file.diff.content"]) as ParsedXmlResult
|
|
const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean)
|
|
|
|
for (const file of files) {
|
|
if (!file.path || !file.diff) continue
|
|
|
|
const filePath = file.path
|
|
|
|
// Initialize the operation in the map if it doesn't exist
|
|
if (!operationsMap[filePath]) {
|
|
operationsMap[filePath] = {
|
|
path: filePath,
|
|
diff: [],
|
|
}
|
|
}
|
|
|
|
// Handle diff as either array or single element
|
|
const diffs = Array.isArray(file.diff) ? file.diff : [file.diff]
|
|
|
|
for (let i = 0; i < diffs.length; i++) {
|
|
const diff = diffs[i]
|
|
let diffContent: string
|
|
let startLine: number | undefined
|
|
|
|
// Ensure content is a string before storing it
|
|
diffContent = typeof diff.content === "string" ? diff.content : ""
|
|
startLine = diff.start_line ? parseInt(diff.start_line) : undefined
|
|
|
|
// Only add to operations if we have valid content
|
|
if (diffContent) {
|
|
operationsMap[filePath].diff.push({
|
|
content: diffContent,
|
|
startLine,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
const detailedError = `Failed to parse apply_diff XML. This usually means:
|
|
1. The XML structure is malformed or incomplete
|
|
2. Missing required <file>, <path>, or <diff> tags
|
|
3. Invalid characters or encoding in the XML
|
|
|
|
Expected structure:
|
|
<args>
|
|
<file>
|
|
<path>relative/path/to/file.ext</path>
|
|
<diff>
|
|
<content>diff content here</content>
|
|
<start_line>line number</start_line>
|
|
</diff>
|
|
</file>
|
|
</args>
|
|
|
|
Original error: ${errorMessage}`
|
|
cline.consecutiveMistakeCount++
|
|
cline.recordToolError("apply_diff")
|
|
TelemetryService.instance.captureDiffApplicationError(cline.taskId, cline.consecutiveMistakeCount)
|
|
await cline.say("diff_error", `Failed to parse apply_diff XML: ${errorMessage}`)
|
|
pushToolResult(detailedError)
|
|
cline.processQueuedMessages()
|
|
return
|
|
}
|
|
} else if (legacyPath && typeof legacyDiffContent === "string") {
|
|
// Handle legacy parameters (old way)
|
|
usingLegacyParams = true
|
|
operationsMap[legacyPath] = {
|
|
path: legacyPath,
|
|
diff: [
|
|
{
|
|
content: legacyDiffContent, // Unescaping will be handled later like new diffs
|
|
startLine: legacyStartLineStr ? parseInt(legacyStartLineStr) : undefined,
|
|
},
|
|
],
|
|
}
|
|
} else {
|
|
// Neither new XML args nor old path/diff params are sufficient
|
|
cline.consecutiveMistakeCount++
|
|
cline.recordToolError("apply_diff")
|
|
const errorMsg = await cline.sayAndCreateMissingParamError(
|
|
"apply_diff",
|
|
"args (or legacy 'path' and 'diff' parameters)",
|
|
)
|
|
pushToolResult(errorMsg)
|
|
cline.processQueuedMessages()
|
|
return
|
|
}
|
|
|
|
// If no operations were extracted, bail out
|
|
if (Object.keys(operationsMap).length === 0) {
|
|
cline.consecutiveMistakeCount++
|
|
cline.recordToolError("apply_diff")
|
|
pushToolResult(
|
|
await cline.sayAndCreateMissingParamError(
|
|
"apply_diff",
|
|
usingLegacyParams
|
|
? "legacy 'path' and 'diff' (must be valid and non-empty)"
|
|
: "args (must contain at least one valid file element)",
|
|
),
|
|
)
|
|
cline.processQueuedMessages()
|
|
return
|
|
}
|
|
|
|
// Convert map to array of operations for processing
|
|
const operations = Object.values(operationsMap)
|
|
|
|
const operationResults: OperationResult[] = operations.map((op) => ({
|
|
path: op.path,
|
|
status: "pending",
|
|
diffItems: op.diff,
|
|
}))
|
|
|
|
// Function to update operation result
|
|
const updateOperationResult = (path: string, updates: Partial<OperationResult>) => {
|
|
const index = operationResults.findIndex((result) => result.path === path)
|
|
if (index !== -1) {
|
|
operationResults[index] = { ...operationResults[index], ...updates }
|
|
}
|
|
}
|
|
|
|
try {
|
|
// First validate all files and prepare for batch approval
|
|
const operationsToApprove: OperationResult[] = []
|
|
const allDiffErrors: string[] = [] // Collect all diff errors
|
|
|
|
for (const operation of operations) {
|
|
const { path: relPath, diff: diffItems } = operation
|
|
|
|
// Verify file access is allowed
|
|
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
|
|
if (!accessAllowed) {
|
|
await cline.say("rooignore_error", relPath)
|
|
updateOperationResult(relPath, {
|
|
status: "blocked",
|
|
error: formatResponse.rooIgnoreError(relPath),
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Check if file is write-protected
|
|
const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false
|
|
|
|
// Verify file exists
|
|
const absolutePath = path.resolve(cline.cwd, relPath)
|
|
const fileExists = await fileExistsAtPath(absolutePath)
|
|
if (!fileExists) {
|
|
updateOperationResult(relPath, {
|
|
status: "blocked",
|
|
error: `File does not exist at path: ${absolutePath}`,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Add to operations that need approval
|
|
const opResult = operationResults.find((r) => r.path === relPath)
|
|
if (opResult) {
|
|
opResult.absolutePath = absolutePath
|
|
opResult.fileExists = fileExists
|
|
operationsToApprove.push(opResult)
|
|
}
|
|
}
|
|
|
|
// Handle batch approval if there are multiple files
|
|
if (operationsToApprove.length > 1) {
|
|
// Check if any files are write-protected
|
|
const hasProtectedFiles = operationsToApprove.some(
|
|
(opResult) => cline.rooProtectedController?.isWriteProtected(opResult.path) || false,
|
|
)
|
|
|
|
// Stream batch diffs progressively for better UX
|
|
const batchDiffs: Array<{
|
|
path: string
|
|
changeCount: number
|
|
key: string
|
|
content: string
|
|
diffStats?: { added: number; removed: number }
|
|
diffs?: Array<{ content: string; startLine?: number }>
|
|
}> = []
|
|
|
|
for (const opResult of operationsToApprove) {
|
|
const readablePath = getReadablePath(cline.cwd, opResult.path)
|
|
const changeCount = opResult.diffItems?.length || 0
|
|
const changeText = changeCount === 1 ? "1 change" : `${changeCount} changes`
|
|
|
|
let unified = ""
|
|
try {
|
|
const original = await fs.readFile(opResult.absolutePath!, "utf-8")
|
|
const processed = !cline.api.getModel().id.includes("claude")
|
|
? (opResult.diffItems || []).map((item) => ({
|
|
...item,
|
|
content: item.content ? unescapeHtmlEntities(item.content) : item.content,
|
|
}))
|
|
: opResult.diffItems || []
|
|
|
|
const applyRes =
|
|
(await cline.diffStrategy?.applyDiff(original, processed)) ?? ({ success: false } as any)
|
|
const newContent = applyRes.success && applyRes.content ? applyRes.content : original
|
|
unified = formatResponse.createPrettyPatch(opResult.path, original, newContent)
|
|
} catch {
|
|
unified = ""
|
|
}
|
|
|
|
const unifiedSanitized = sanitizeUnifiedDiff(unified)
|
|
const stats = computeDiffStats(unifiedSanitized) || undefined
|
|
batchDiffs.push({
|
|
path: readablePath,
|
|
changeCount,
|
|
key: `${readablePath} (${changeText})`,
|
|
content: unifiedSanitized,
|
|
diffStats: stats,
|
|
diffs: opResult.diffItems?.map((item) => ({
|
|
content: item.content,
|
|
startLine: item.startLine,
|
|
})),
|
|
})
|
|
|
|
// Send a partial update after each file preview is ready
|
|
const partialMessage = JSON.stringify({
|
|
tool: "appliedDiff",
|
|
batchDiffs,
|
|
isProtected: hasProtectedFiles,
|
|
} satisfies ClineSayTool)
|
|
await cline.ask("tool", partialMessage, true).catch(() => {})
|
|
}
|
|
|
|
// Final approval message (non-partial)
|
|
const completeMessage = JSON.stringify({
|
|
tool: "appliedDiff",
|
|
batchDiffs,
|
|
isProtected: hasProtectedFiles,
|
|
} satisfies ClineSayTool)
|
|
|
|
const { response, text, images } = await cline.ask("tool", completeMessage, false)
|
|
|
|
// Process batch response
|
|
if (response === "yesButtonClicked") {
|
|
// Approve all files
|
|
if (text) {
|
|
await cline.say("user_feedback", text, images)
|
|
}
|
|
operationsToApprove.forEach((opResult) => {
|
|
updateOperationResult(opResult.path, { status: "approved" })
|
|
})
|
|
} else if (response === "noButtonClicked") {
|
|
// Deny all files
|
|
if (text) {
|
|
await cline.say("user_feedback", text, images)
|
|
}
|
|
cline.didRejectTool = true
|
|
operationsToApprove.forEach((opResult) => {
|
|
updateOperationResult(opResult.path, {
|
|
status: "denied",
|
|
result: `Changes to ${opResult.path} were not approved by user`,
|
|
})
|
|
})
|
|
} else {
|
|
// Handle individual permissions from objectResponse
|
|
try {
|
|
const parsedResponse = JSON.parse(text || "{}")
|
|
// Check if this is our batch diff approval response
|
|
if (parsedResponse.action === "applyDiff" && parsedResponse.approvedFiles) {
|
|
const approvedFiles = parsedResponse.approvedFiles
|
|
let hasAnyDenial = false
|
|
|
|
operationsToApprove.forEach((opResult) => {
|
|
const approved = approvedFiles[opResult.path] === true
|
|
|
|
if (approved) {
|
|
updateOperationResult(opResult.path, { status: "approved" })
|
|
} else {
|
|
hasAnyDenial = true
|
|
updateOperationResult(opResult.path, {
|
|
status: "denied",
|
|
result: `Changes to ${opResult.path} were not approved by user`,
|
|
})
|
|
}
|
|
})
|
|
|
|
if (hasAnyDenial) {
|
|
cline.didRejectTool = true
|
|
}
|
|
} else {
|
|
// Legacy individual permissions format
|
|
const individualPermissions = parsedResponse
|
|
let hasAnyDenial = false
|
|
|
|
batchDiffs.forEach((batchDiff, index) => {
|
|
const opResult = operationsToApprove[index]
|
|
const approved = individualPermissions[batchDiff.key] === true
|
|
|
|
if (approved) {
|
|
updateOperationResult(opResult.path, { status: "approved" })
|
|
} else {
|
|
hasAnyDenial = true
|
|
updateOperationResult(opResult.path, {
|
|
status: "denied",
|
|
result: `Changes to ${opResult.path} were not approved by user`,
|
|
})
|
|
}
|
|
})
|
|
|
|
if (hasAnyDenial) {
|
|
cline.didRejectTool = true
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Fallback: if JSON parsing fails, deny all files
|
|
console.error("Failed to parse individual permissions:", error)
|
|
cline.didRejectTool = true
|
|
operationsToApprove.forEach((opResult) => {
|
|
updateOperationResult(opResult.path, {
|
|
status: "denied",
|
|
result: `Changes to ${opResult.path} were not approved by user`,
|
|
})
|
|
})
|
|
}
|
|
}
|
|
} else if (operationsToApprove.length === 1) {
|
|
// Single file approval - process immediately
|
|
const opResult = operationsToApprove[0]
|
|
updateOperationResult(opResult.path, { status: "approved" })
|
|
}
|
|
|
|
// Process approved operations
|
|
const results: string[] = []
|
|
|
|
for (const opResult of operationResults) {
|
|
// Skip operations that weren't approved or were blocked
|
|
if (opResult.status !== "approved") {
|
|
if (opResult.result) {
|
|
results.push(opResult.result)
|
|
} else if (opResult.error) {
|
|
results.push(opResult.error)
|
|
}
|
|
continue
|
|
}
|
|
|
|
const relPath = opResult.path
|
|
const diffItems = opResult.diffItems || []
|
|
const absolutePath = opResult.absolutePath!
|
|
const fileExists = opResult.fileExists!
|
|
|
|
try {
|
|
let originalContent: string | null = await fs.readFile(absolutePath, "utf-8")
|
|
let beforeContent: string | null = originalContent
|
|
let successCount = 0
|
|
let formattedError = ""
|
|
|
|
// Pre-process all diff items for HTML entity unescaping if needed
|
|
const processedDiffItems = !cline.api.getModel().id.includes("claude")
|
|
? diffItems.map((item) => ({
|
|
...item,
|
|
content: item.content ? unescapeHtmlEntities(item.content) : item.content,
|
|
}))
|
|
: diffItems
|
|
|
|
// Apply all diffs at once with the array-based method
|
|
const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, processedDiffItems)) ?? {
|
|
success: false,
|
|
error: "No diff strategy available - please ensure a valid diff strategy is configured",
|
|
}
|
|
|
|
// Release the original content from memory as it's no longer needed
|
|
originalContent = null
|
|
|
|
if (!diffResult.success) {
|
|
cline.consecutiveMistakeCount++
|
|
const currentCount = (cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1
|
|
cline.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount)
|
|
|
|
TelemetryService.instance.captureDiffApplicationError(cline.taskId, currentCount)
|
|
|
|
if (diffResult.failParts && diffResult.failParts.length > 0) {
|
|
for (let i = 0; i < diffResult.failParts.length; i++) {
|
|
const failPart = diffResult.failParts[i]
|
|
if (failPart.success) {
|
|
continue
|
|
}
|
|
|
|
// Collect error for later reporting
|
|
allDiffErrors.push(`${relPath} - Diff ${i + 1}: ${failPart.error}`)
|
|
|
|
const errorDetails = failPart.details ? JSON.stringify(failPart.details, null, 2) : ""
|
|
formattedError += `<error_details>
|
|
Diff ${i + 1} failed for file: ${relPath}
|
|
Error: ${failPart.error}
|
|
|
|
Suggested fixes:
|
|
1. Verify the search content exactly matches the file content (including whitespace and case)
|
|
2. Check for correct indentation and line endings
|
|
3. Use the read_file tool to verify the file's current contents
|
|
4. Consider breaking complex changes into smaller diffs
|
|
5. Ensure start_line parameter matches the actual content location
|
|
${errorDetails ? `\nDetailed error information:\n${errorDetails}\n` : ""}
|
|
</error_details>\n\n`
|
|
}
|
|
} else {
|
|
const errorDetails = diffResult.details ? JSON.stringify(diffResult.details, null, 2) : ""
|
|
formattedError += `<error_details>
|
|
Unable to apply diffs to file: ${absolutePath}
|
|
Error: ${diffResult.error}
|
|
|
|
Recovery suggestions:
|
|
1. Use the read_file tool to verify the file's current contents
|
|
2. Verify the diff format matches the expected search/replace pattern
|
|
3. Check that the search content exactly matches what's in the file
|
|
4. Consider using line numbers with start_line parameter
|
|
5. Break large changes into smaller, more specific diffs
|
|
${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""}
|
|
</error_details>\n\n`
|
|
}
|
|
} else {
|
|
// Get the content from the result and update success count
|
|
originalContent = diffResult.content || originalContent
|
|
successCount = diffItems.length - (diffResult.failParts?.length || 0)
|
|
}
|
|
|
|
// If no diffs were successfully applied, continue to next file
|
|
if (successCount === 0) {
|
|
if (formattedError) {
|
|
const currentCount = cline.consecutiveMistakeCountForApplyDiff.get(relPath) || 0
|
|
if (currentCount >= 2) {
|
|
await cline.say("diff_error", formattedError)
|
|
}
|
|
cline.recordToolError("apply_diff", formattedError)
|
|
results.push(formattedError)
|
|
|
|
// For single file operations, we need to send a complete message to stop the spinner
|
|
if (operationsToApprove.length === 1) {
|
|
const sharedMessageProps: ClineSayTool = {
|
|
tool: "appliedDiff",
|
|
path: getReadablePath(cline.cwd, relPath),
|
|
diff: diffItems.map((item) => item.content).join("\n\n"),
|
|
}
|
|
// Send a complete message (partial: false) to update the UI and stop the spinner
|
|
await cline.ask("tool", JSON.stringify(sharedMessageProps), false).catch(() => {})
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
cline.consecutiveMistakeCount = 0
|
|
cline.consecutiveMistakeCountForApplyDiff.delete(relPath)
|
|
|
|
// Check if preventFocusDisruption experiment is enabled
|
|
const provider = cline.providerRef.deref()
|
|
const state = await provider?.getState()
|
|
const diagnosticsEnabled = state?.diagnosticsEnabled ?? true
|
|
const writeDelayMs = state?.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS
|
|
const isPreventFocusDisruptionEnabled = experiments.isEnabled(
|
|
state?.experiments ?? {},
|
|
EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION,
|
|
)
|
|
|
|
// For batch operations, we've already gotten approval
|
|
const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false
|
|
const sharedMessageProps: ClineSayTool = {
|
|
tool: "appliedDiff",
|
|
path: getReadablePath(cline.cwd, relPath),
|
|
isProtected: isWriteProtected,
|
|
}
|
|
|
|
// If single file, handle based on PREVENT_FOCUS_DISRUPTION setting
|
|
let didApprove = true
|
|
if (operationsToApprove.length === 1) {
|
|
// Prepare common data for single file operation
|
|
const diffContents = diffItems.map((item) => item.content).join("\n\n")
|
|
const unifiedPatchRaw = formatResponse.createPrettyPatch(relPath, beforeContent!, originalContent!)
|
|
const unifiedPatch = sanitizeUnifiedDiff(unifiedPatchRaw)
|
|
const operationMessage = JSON.stringify({
|
|
...sharedMessageProps,
|
|
diff: diffContents,
|
|
content: unifiedPatch,
|
|
diffStats: computeDiffStats(unifiedPatch) || undefined,
|
|
} satisfies ClineSayTool)
|
|
|
|
let toolProgressStatus
|
|
if (cline.diffStrategy && cline.diffStrategy.getProgressStatus) {
|
|
toolProgressStatus = cline.diffStrategy.getProgressStatus(
|
|
{
|
|
...block,
|
|
params: { ...block.params, diff: diffContents },
|
|
},
|
|
{ success: true },
|
|
)
|
|
}
|
|
|
|
// Set up diff view
|
|
cline.diffViewProvider.editType = "modify"
|
|
|
|
// Show diff view if focus disruption prevention is disabled
|
|
if (!isPreventFocusDisruptionEnabled) {
|
|
await cline.diffViewProvider.open(relPath)
|
|
await cline.diffViewProvider.update(originalContent!, true)
|
|
cline.diffViewProvider.scrollToFirstDiff()
|
|
} else {
|
|
// For direct save, we still need to set originalContent
|
|
cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8")
|
|
}
|
|
|
|
// Ask for approval (same for both flows)
|
|
const isWriteProtected = cline.rooProtectedController?.isWriteProtected(relPath) || false
|
|
didApprove = await askApproval("tool", operationMessage, toolProgressStatus, isWriteProtected)
|
|
|
|
if (!didApprove) {
|
|
// Revert changes if diff view was shown
|
|
if (!isPreventFocusDisruptionEnabled) {
|
|
await cline.diffViewProvider.revertChanges()
|
|
}
|
|
results.push(`Changes to ${relPath} were not approved by user`)
|
|
continue
|
|
}
|
|
|
|
// Save the changes
|
|
if (isPreventFocusDisruptionEnabled) {
|
|
// Direct file write without diff view or opening the file
|
|
await cline.diffViewProvider.saveDirectly(
|
|
relPath,
|
|
originalContent!,
|
|
false,
|
|
diagnosticsEnabled,
|
|
writeDelayMs,
|
|
)
|
|
} else {
|
|
// Call saveChanges to update the DiffViewProvider properties
|
|
await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
|
|
}
|
|
} else {
|
|
// Batch operations - already approved above
|
|
if (isPreventFocusDisruptionEnabled) {
|
|
// Direct file write without diff view or opening the file
|
|
cline.diffViewProvider.editType = "modify"
|
|
cline.diffViewProvider.originalContent = await fs.readFile(absolutePath, "utf-8")
|
|
await cline.diffViewProvider.saveDirectly(
|
|
relPath,
|
|
originalContent!,
|
|
false,
|
|
diagnosticsEnabled,
|
|
writeDelayMs,
|
|
)
|
|
} else {
|
|
// Original behavior with diff view
|
|
cline.diffViewProvider.editType = "modify"
|
|
await cline.diffViewProvider.open(relPath)
|
|
await cline.diffViewProvider.update(originalContent!, true)
|
|
cline.diffViewProvider.scrollToFirstDiff()
|
|
|
|
// Call saveChanges to update the DiffViewProvider properties
|
|
await cline.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs)
|
|
}
|
|
}
|
|
|
|
// Track file edit operation
|
|
await cline.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource)
|
|
|
|
// Used to determine if we should wait for busy terminal to update before sending api request
|
|
cline.didEditFile = true
|
|
let partFailHint = ""
|
|
|
|
if (successCount < diffItems.length) {
|
|
partFailHint = `Unable to apply all diff parts to file: ${absolutePath}`
|
|
}
|
|
|
|
// Get the formatted response message
|
|
const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists)
|
|
|
|
if (partFailHint) {
|
|
results.push(partFailHint + "\n" + message)
|
|
} else {
|
|
results.push(message)
|
|
}
|
|
|
|
await cline.diffViewProvider.reset()
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
|
updateOperationResult(relPath, {
|
|
status: "error",
|
|
error: `Error processing ${relPath}: ${errorMsg}`,
|
|
})
|
|
results.push(`Error processing ${relPath}: ${errorMsg}`)
|
|
}
|
|
}
|
|
|
|
// Add filtered operation errors to results
|
|
if (filteredOperationErrors.length > 0) {
|
|
results.push(...filteredOperationErrors)
|
|
}
|
|
|
|
// Report all diff errors at once if any
|
|
if (allDiffErrors.length > 0) {
|
|
await cline.say("diff_error", allDiffErrors.join("\n"))
|
|
}
|
|
|
|
// Check for single SEARCH/REPLACE block warning
|
|
let totalSearchBlocks = 0
|
|
for (const operation of operations) {
|
|
for (const diffItem of operation.diff) {
|
|
const searchBlocks = (diffItem.content.match(/<<<<<<< SEARCH/g) || []).length
|
|
totalSearchBlocks += searchBlocks
|
|
}
|
|
}
|
|
|
|
const singleBlockNotice =
|
|
totalSearchBlocks === 1
|
|
? "\n<notice>Making multiple related changes in a single apply_diff is more efficient. If other changes are needed in this file, please include them as additional SEARCH/REPLACE blocks.</notice>"
|
|
: ""
|
|
|
|
// Push the final result combining all operation results
|
|
pushToolResult(results.join("\n\n") + singleBlockNotice)
|
|
cline.processQueuedMessages()
|
|
return
|
|
} catch (error) {
|
|
await handleError("applying diff", error)
|
|
await cline.diffViewProvider.reset()
|
|
cline.processQueuedMessages()
|
|
return
|
|
}
|
|
}
|