Refactor file editing to DiffViewProvider

This commit is contained in:
Saoud Rizwan
2024-09-29 18:17:54 -04:00
parent 2ed84244d5
commit 8b243fa536
3 changed files with 421 additions and 726 deletions

View File

@@ -1,7 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk" import { Anthropic } from "@anthropic-ai/sdk"
import cloneDeep from "clone-deep" import cloneDeep from "clone-deep"
import delay from "delay" import delay from "delay"
import * as diff from "diff"
import fs from "fs/promises" import fs from "fs/promises"
import os from "os" import os from "os"
import pWaitFor from "p-wait-for" import pWaitFor from "p-wait-for"
@@ -10,7 +9,7 @@ import { serializeError } from "serialize-error"
import * as vscode from "vscode" import * as vscode from "vscode"
import { ApiHandler, buildApiHandler } from "../api" import { ApiHandler, buildApiHandler } from "../api"
import { ApiStream } from "../api/transform/stream" import { ApiStream } from "../api/transform/stream"
import { diagnosticsToProblemsString, getNewDiagnostics } from "../integrations/diagnostics" import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
import { formatContentBlockToMarkdown } from "../integrations/misc/export-markdown" import { formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
import { extractTextFromFile } from "../integrations/misc/extract-text" import { extractTextFromFile } from "../integrations/misc/extract-text"
import { TerminalManager } from "../integrations/terminal/TerminalManager" import { TerminalManager } from "../integrations/terminal/TerminalManager"
@@ -27,7 +26,10 @@ import { getApiMetrics } from "../shared/getApiMetrics"
import { HistoryItem } from "../shared/HistoryItem" import { HistoryItem } from "../shared/HistoryItem"
import { ToolName } from "../shared/Tool" import { ToolName } from "../shared/Tool"
import { ClaudeAskResponse } from "../shared/WebviewMessage" import { ClaudeAskResponse } from "../shared/WebviewMessage"
import { calculateApiCost } from "../utils/cost"
import { fileExistsAtPath } from "../utils/fs"
import { arePathsEqual, getReadablePath } from "../utils/path" import { arePathsEqual, getReadablePath } from "../utils/path"
import { parseMentions } from "./mentions"
import { import {
AssistantMessageContent, AssistantMessageContent,
TextContent, TextContent,
@@ -37,13 +39,10 @@ import {
ToolUseName, ToolUseName,
toolUseNames, toolUseNames,
} from "./prompts/AssistantMessage" } from "./prompts/AssistantMessage"
import { parseMentions } from "./mentions"
import { formatResponse } from "./prompts/responses" import { formatResponse } from "./prompts/responses"
import { addCustomInstructions, SYSTEM_PROMPT } from "./prompts/system" import { addCustomInstructions, SYSTEM_PROMPT } from "./prompts/system"
import { truncateHalfConversation } from "./sliding-window" import { truncateHalfConversation } from "./sliding-window"
import { ClaudeDevProvider, GlobalFileNames } from "./webview/ClaudeDevProvider" import { ClaudeDevProvider, GlobalFileNames } from "./webview/ClaudeDevProvider"
import { calculateApiCost } from "../utils/cost"
import { createDirectoriesForFile, fileExistsAtPath } from "../utils/fs"
const cwd = const cwd =
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -70,6 +69,17 @@ export class ClaudeDev {
private consecutiveMistakeCount: number = 0 private consecutiveMistakeCount: number = 0
private providerRef: WeakRef<ClaudeDevProvider> private providerRef: WeakRef<ClaudeDevProvider>
private abort: boolean = false private abort: boolean = false
private diffViewProvider: DiffViewProvider
// streaming
private currentStreamingContentIndex = 0
private assistantMessageContent: AssistantMessageContent[] = []
private presentAssistantMessageLocked = false
private presentAssistantMessageHasPendingUpdates = false
private userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
private userMessageContentReady = false
private didRejectTool = false
private didCompleteReadingStream = false
constructor( constructor(
provider: ClaudeDevProvider, provider: ClaudeDevProvider,
@@ -84,6 +94,7 @@ export class ClaudeDev {
this.api = buildApiHandler(apiConfiguration) this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager() this.terminalManager = new TerminalManager()
this.urlContentFetcher = new UrlContentFetcher(provider.context) this.urlContentFetcher = new UrlContentFetcher(provider.context)
this.diffViewProvider = new DiffViewProvider(cwd)
this.customInstructions = customInstructions this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
@@ -591,399 +602,6 @@ export class ClaudeDev {
// Tools // Tools
// return is [didUserRejectTool, ToolResponse]
async writeToFile(relPath?: string, newContent?: string): Promise<[boolean, ToolResponse]> {
if (relPath === undefined) {
this.consecutiveMistakeCount++
return [false, await this.sayAndCreateMissingParamError("write_to_file", "path")]
}
if (newContent === undefined) {
this.consecutiveMistakeCount++
// Custom error message for this particular case
await this.say(
"error",
`Claude tried to use write_to_file for '${relPath.toPosix()}' without value for required parameter 'content'. This is likely due to reaching the maximum output token limit. Retrying with suggestion to change response size...`
)
return [
false,
formatResponse.toolError(
`Missing value for required parameter 'content'. This may occur if the file is too large, exceeding output limits. Consider splitting into smaller files or reducing content size. Please retry with all required parameters.`
),
]
}
this.consecutiveMistakeCount = 0
try {
const absolutePath = path.resolve(cwd, relPath)
const fileExists = await fileExistsAtPath(absolutePath)
// if the file is already open, ensure it's not dirty before getting its contents
if (fileExists) {
const existingDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)
if (existingDocument && existingDocument.isDirty) {
await existingDocument.save()
}
}
// get diagnostics before editing the file, we'll compare to diagnostics after editing to see if claude needs to fix anything
const preDiagnostics = vscode.languages.getDiagnostics()
let originalContent: string
if (fileExists) {
originalContent = await fs.readFile(absolutePath, "utf-8")
// fix issue where claude always removes newline from the file
const eol = originalContent.includes("\r\n") ? "\r\n" : "\n"
if (originalContent.endsWith(eol) && !newContent.endsWith(eol)) {
newContent += eol
}
} else {
originalContent = ""
}
const fileName = path.basename(absolutePath)
// for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation
// Keep track of newly created directories
const createdDirs: string[] = await createDirectoriesForFile(absolutePath)
// console.log(`Created directories: ${createdDirs.join(", ")}`)
// make sure the file exists before we open it
if (!fileExists) {
await fs.writeFile(absolutePath, "")
}
// Open the existing file with the new contents
const updatedDocument = await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath))
// await updatedDocument.save()
// const edit = new vscode.WorkspaceEdit()
// const fullRange = new vscode.Range(
// updatedDocument.positionAt(0),
// updatedDocument.positionAt(updatedDocument.getText().length)
// )
// edit.replace(updatedDocument.uri, fullRange, newContent)
// await vscode.workspace.applyEdit(edit)
// Windows file locking issues can prevent temporary files from being saved or closed properly.
// To avoid these problems, we use in-memory TextDocument objects with the `untitled` scheme.
// This method keeps the document entirely in memory, bypassing the filesystem and ensuring
// a consistent editing experience across all platforms. This also has the added benefit of not
// polluting the user's workspace with temporary files.
// Create an in-memory document for the new content
// const inMemoryDocumentUri = vscode.Uri.parse(`untitled:${fileName}`) // untitled scheme is necessary to open a file without it being saved to disk
// const inMemoryDocument = await vscode.workspace.openTextDocument(inMemoryDocumentUri)
// const edit = new vscode.WorkspaceEdit()
// edit.insert(inMemoryDocumentUri, new vscode.Position(0, 0), newContent)
// await vscode.workspace.applyEdit(edit)
// Show diff
await vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
query: Buffer.from(originalContent).toString("base64"),
}),
updatedDocument.uri,
`${fileName}: ${fileExists ? "Original ↔ Claude's Changes" : "New File"} (Editable)`
)
// if the file was already open, close it (must happen after showing the diff view since if it's the only tab the column will close)
let documentWasOpen = false
// close the tab if it's open
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath)
)
for (const tab of tabs) {
await vscode.window.tabGroups.close(tab)
// console.log(`Closed tab for ${absolutePath}`)
documentWasOpen = true
}
// console.log(`Document was open: ${documentWasOpen}`)
// edit needs to happen after we close the original tab
const edit = new vscode.WorkspaceEdit()
if (!fileExists) {
edit.insert(updatedDocument.uri, new vscode.Position(0, 0), newContent)
} else {
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
}
// Apply the edit, but without saving so this doesnt trigger a local save in timeline history
await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
// Find the first range where the content differs and scroll to it
if (fileExists) {
const diffResult = diff.diffLines(originalContent, newContent)
for (let i = 0, lineCount = 0; i < diffResult.length; i++) {
const part = diffResult[i]
if (part.added || part.removed) {
const startLine = lineCount + 1
const endLine = lineCount + (part.count || 0)
const activeEditor = vscode.window.activeTextEditor
if (activeEditor) {
try {
activeEditor.revealRange(
// + 3 to move the editor up slightly as this looks better
new vscode.Range(
new vscode.Position(startLine, 0),
new vscode.Position(
Math.min(endLine + 3, activeEditor.document.lineCount - 1),
0
)
),
vscode.TextEditorRevealType.InCenter
)
} catch (error) {
console.error(`Error revealing range for ${absolutePath}: ${error}`)
}
}
break
}
lineCount += part.count || 0
}
}
// remove cursor from the document
await vscode.commands.executeCommand("workbench.action.focusSideBar")
let userResponse: {
response: ClaudeAskResponse
text?: string
images?: string[]
}
if (fileExists) {
userResponse = await this.ask(
"tool",
JSON.stringify({
tool: "editedExistingFile",
path: getReadablePath(cwd, relPath),
diff: formatResponse.createPrettyPatch(relPath, originalContent, newContent),
} satisfies ClaudeSayTool)
)
} else {
userResponse = await this.ask(
"tool",
JSON.stringify({
tool: "newFileCreated",
path: getReadablePath(cwd, relPath),
content: newContent,
} satisfies ClaudeSayTool)
)
}
const { response, text, images } = userResponse
// const closeInMemoryDocAndDiffViews = async () => {
// // ensure that the in-memory doc is active editor (this seems to fail on windows machines if its already active, so ignoring if there's an error as it's likely it's already active anyways)
// // try {
// // await vscode.window.showTextDocument(inMemoryDocument, {
// // preview: false, // ensures it opens in non-preview tab (preview tabs are easily replaced)
// // preserveFocus: false,
// // })
// // // await vscode.window.showTextDocument(inMemoryDocument.uri, { preview: true, preserveFocus: false })
// // } catch (error) {
// // console.log(`Could not open editor for ${absolutePath}: ${error}`)
// // }
// // await delay(50)
// // // Wait for the in-memory document to become the active editor (sometimes vscode timing issues happen and this would accidentally close claude dev!)
// // await pWaitFor(
// // () => {
// // return vscode.window.activeTextEditor?.document === inMemoryDocument
// // },
// // { timeout: 5000, interval: 50 }
// // )
// // if (vscode.window.activeTextEditor?.document === inMemoryDocument) {
// // await vscode.commands.executeCommand("workbench.action.revertAndCloseActiveEditor") // allows us to close the untitled doc without being prompted to save it
// // }
// await this.closeDiffViews()
// }
if (response !== "yesButtonTapped") {
if (!fileExists) {
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
await this.closeDiffViews()
await fs.unlink(absolutePath)
// Remove only the directories we created, in reverse order
for (let i = createdDirs.length - 1; i >= 0; i--) {
await fs.rmdir(createdDirs[i])
console.log(`Directory ${createdDirs[i]} has been deleted.`)
}
console.log(`File ${absolutePath} has been deleted.`)
} else {
// revert document
const edit = new vscode.WorkspaceEdit()
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, originalContent)
// Apply the edit and save, since contents shouldnt have changed this wont show in local history unless of course the user made changes and saved during the edit
await vscode.workspace.applyEdit(edit)
await updatedDocument.save()
console.log(`File ${absolutePath} has been reverted to its original content.`)
if (documentWasOpen) {
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
}
await this.closeDiffViews()
}
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return [true, formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images)]
}
return [true, formatResponse.toolDenied()]
}
// Save the changes
const editedContent = updatedDocument.getText()
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
this.didEditFile = true
// Read the potentially edited content from the document
// trigger an entry in the local history for the file
// if (fileExists) {
// await fs.writeFile(absolutePath, originalContent)
// const editor = await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// const edit = new vscode.WorkspaceEdit()
// const fullRange = new vscode.Range(
// editor.document.positionAt(0),
// editor.document.positionAt(editor.document.getText().length)
// )
// edit.replace(editor.document.uri, fullRange, editedContent)
// // Apply the edit, this will trigger a local save and timeline history
// await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
// await editor.document.save()
// }
// if (!fileExists) {
// await fs.mkdir(path.dirname(absolutePath), { recursive: true })
// await fs.writeFile(absolutePath, "")
// }
// await closeInMemoryDocAndDiffViews()
// await fs.writeFile(absolutePath, editedContent)
// open file and add text to it, if it fails fallback to using writeFile
// we try doing it this way since it adds to local history for users to see what's changed in the file's timeline
// try {
// const editor = await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// const edit = new vscode.WorkspaceEdit()
// const fullRange = new vscode.Range(
// editor.document.positionAt(0),
// editor.document.positionAt(editor.document.getText().length)
// )
// edit.replace(editor.document.uri, fullRange, editedContent)
// // Apply the edit, this will trigger a local save and timeline history
// await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
// await editor.document.save()
// } catch (saveError) {
// console.log(`Could not open editor for ${absolutePath}: ${saveError}`)
// await fs.writeFile(absolutePath, editedContent)
// // calling showTextDocument would sometimes fail even though changes were applied, so we'll ignore these one-off errors (likely due to vscode locking issues)
// try {
// await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// } catch (openFileError) {
// console.log(`Could not open editor for ${absolutePath}: ${openFileError}`)
// }
// }
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
await this.closeDiffViews()
/*
Getting diagnostics before and after the file edit is a better approach than
automatically tracking problems in real-time. This method ensures we only
report new problems that are a direct result of this specific edit.
Since these are new problems resulting from Claude's edit, we know they're
directly related to the work he's doing. This eliminates the risk of Claude
going off-task or getting distracted by unrelated issues, which was a problem
with the previous auto-debug approach. Some users' machines may be slow to
update diagnostics, so this approach provides a good balance between automation
and avoiding potential issues where Claude might get stuck in loops due to
outdated problem information. If no new problems show up by the time the user
accepts the changes, they can always debug later using the '@problems' mention.
This way, Claude only becomes aware of new problems resulting from his edits
and can address them accordingly. If problems don't change immediately after
applying a fix, Claude won't be notified, which is generally fine since the
initial fix is usually correct and it may just take time for linters to catch up.
*/
const postDiagnostics = vscode.languages.getDiagnostics()
const newProblems = diagnosticsToProblemsString(
getNewDiagnostics(preDiagnostics, postDiagnostics),
[
vscode.DiagnosticSeverity.Error, // only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
],
cwd
) // will be empty string if no errors
const newProblemsMessage =
newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : ""
// await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
const newContentEOL = newContent.includes("\r\n") ? "\r\n" : "\n"
const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL)
const normalizedNewContent = newContent.replace(/\r\n|\n/g, newContentEOL) // just in case the new content has a mix of varying EOL characters
if (normalizedEditedContent !== normalizedNewContent) {
const userDiff = diff.createPatch(relPath.toPosix(), normalizedNewContent, normalizedEditedContent)
await this.say(
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, relPath),
diff: formatResponse.createPrettyPatch(relPath, normalizedNewContent, normalizedEditedContent),
} satisfies ClaudeSayTool)
)
return [
false,
`The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath.toPosix()}. (Note this does not mean you need to re-write the file with the user's changes, as they have already been applied to the file.)${newProblemsMessage}`,
]
} else {
return [false, `The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`]
}
} catch (error) {
const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}`
await this.say(
"error",
`Error writing file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return [false, formatResponse.toolError(errorString)]
}
}
async closeDiffViews() {
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === "claude-dev-diff"
)
for (const tab of tabs) {
// trying to close dirty views results in save popup
if (!tab.isDirty) {
await vscode.window.tabGroups.close(tab)
}
}
}
async executeCommandTool( async executeCommandTool(
command: string, command: string,
returnEmptyStringOnSuccess: boolean = false returnEmptyStringOnSuccess: boolean = false
@@ -1253,330 +871,83 @@ export class ClaudeDev {
} }
// Check if file exists using cached map or fs.access // Check if file exists using cached map or fs.access
let fileExists: boolean let fileExists: boolean
if (this.diffViewProvider.editType !== undefined) {
if (this.isEditingExistingFile !== undefined) { fileExists = this.diffViewProvider.editType === "modify"
fileExists = this.isEditingExistingFile
} else { } else {
const absolutePath = path.resolve(cwd, relPath) const absolutePath = path.resolve(cwd, relPath)
fileExists = await fileExistsAtPath(absolutePath) fileExists = await fileExistsAtPath(absolutePath)
this.diffViewProvider.editType = fileExists ? "modify" : "create"
this.isEditingExistingFile = fileExists
} }
const sharedMessageProps: ClaudeSayTool = { const sharedMessageProps: ClaudeSayTool = {
tool: fileExists ? "editedExistingFile" : "newFileCreated", tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, relPath), path: getReadablePath(cwd, relPath),
} }
try { try {
const absolutePath = path.resolve(cwd, relPath)
if (block.partial) { if (block.partial) {
// update gui message // update gui message
const partialMessage = JSON.stringify(sharedMessageProps) const partialMessage = JSON.stringify(sharedMessageProps)
await this.ask("tool", partialMessage, block.partial).catch(() => {}) await this.ask("tool", partialMessage, block.partial).catch(() => {})
// update editor
if (!this.isEditingFile) { await this.diffViewProvider.update(relPath, newContent)
// open the editor and prepare to stream content in
this.isEditingFile = true
if (fileExists) {
this.editorOriginalContent = await fs.readFile(
path.resolve(cwd, relPath),
"utf-8"
)
} else {
this.editorOriginalContent = ""
}
const fileName = path.basename(absolutePath)
// for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation
// Keep track of newly created directories
this.editFileCreatedDirs = await createDirectoriesForFile(absolutePath)
// console.log(`Created directories: ${createdDirs.join(", ")}`)
// make sure the file exists before we open it
if (!fileExists) {
await fs.writeFile(absolutePath, "")
}
// Open the existing file with the new contents
const updatedDocument = await vscode.workspace.openTextDocument(
vscode.Uri.file(absolutePath)
)
// Show diff
await vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
query: Buffer.from(this.editorOriginalContent).toString("base64"),
}),
updatedDocument.uri,
`${fileName}: ${
fileExists ? "Original ↔ Claude's Changes" : "New File"
} (Editable)`
)
// if the file was already open, close it (must happen after showing the diff view since if it's the only tab the column will close)
this.editFileDocumentWasOpen = false
// close the tab if it's open
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputText &&
arePathsEqual(tab.input.uri.fsPath, absolutePath)
)
for (const tab of tabs) {
await vscode.window.tabGroups.close(tab)
this.editFileDocumentWasOpen = true
}
}
// editor is open, stream content in
const updatedDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)!
const edit = new vscode.WorkspaceEdit()
if (!fileExists) {
// edit.insert(updatedDocument.uri, new vscode.Position(0, 0), newContent)
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
} else {
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
}
await vscode.workspace.applyEdit(edit)
break break
} else { } else {
// if isEditingFile false, that means we have the full contents of the file already. // if isEditingFile false, that means we have the full contents of the file already.
// it's important to note how this function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So this part of the logic will always be called. // it's important to note how this function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So this part of the logic will always be called.
// in other words, you must always repeat the block.partial logic here // in other words, you must always repeat the block.partial logic here
if (!this.isEditingFile) { await this.diffViewProvider.update(relPath, newContent)
// open the editor and prepare to stream content in
this.isEditingFile = true
if (fileExists) {
this.editorOriginalContent = await fs.readFile(
path.resolve(cwd, relPath),
"utf-8"
)
} else {
this.editorOriginalContent = ""
}
const fileName = path.basename(absolutePath)
// for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation
// Keep track of newly created directories
this.editFileCreatedDirs = await createDirectoriesForFile(absolutePath)
// console.log(`Created directories: ${createdDirs.join(", ")}`)
// make sure the file exists before we open it
if (!fileExists) {
await fs.writeFile(absolutePath, "")
}
// Open the existing file with the new contents
const updatedDocument = await vscode.workspace.openTextDocument(
vscode.Uri.file(absolutePath)
)
// Show diff
await vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
query: Buffer.from(this.editorOriginalContent).toString("base64"),
}),
updatedDocument.uri,
`${fileName}: ${
fileExists ? "Original ↔ Claude's Changes" : "New File"
} (Editable)`
)
// if the file was already open, close it (must happen after showing the diff view since if it's the only tab the column will close)
this.editFileDocumentWasOpen = false
// close the tab if it's open
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputText &&
arePathsEqual(tab.input.uri.fsPath, absolutePath)
)
for (const tab of tabs) {
await vscode.window.tabGroups.close(tab)
this.editFileDocumentWasOpen = true
}
// edit needs to happen after we close the original tab
const edit = new vscode.WorkspaceEdit()
if (!fileExists) {
edit.insert(updatedDocument.uri, new vscode.Position(0, 0), newContent) // newContent is partial
} else {
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
}
// Apply the edit, but without saving so this doesnt trigger a local save in timeline history
await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
}
if (!relPath) { if (!relPath) {
this.consecutiveMistakeCount++ this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "path")) pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "path"))
await this.diffViewProvider.reset()
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
break break
} }
if (!newContent) { if (!newContent) {
this.consecutiveMistakeCount++ this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "content")) pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "content"))
await this.diffViewProvider.reset()
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
break break
} }
this.consecutiveMistakeCount = 0 this.consecutiveMistakeCount = 0
// execute tool
const updatedDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)!
const originalContent = this.editorOriginalContent!
const createdDirs = this.editFileCreatedDirs
const documentWasOpen = this.editFileDocumentWasOpen
const completeMessage = JSON.stringify({ const completeMessage = JSON.stringify({
...sharedMessageProps, ...sharedMessageProps,
content: fileExists ? undefined : newContent, content: fileExists ? undefined : newContent,
diff: fileExists diff: fileExists
? formatResponse.createPrettyPatch(relPath, originalContent, newContent) ? formatResponse.createPrettyPatch(
relPath,
this.diffViewProvider.originalContent,
newContent
)
: undefined, : undefined,
} satisfies ClaudeSayTool) } satisfies ClaudeSayTool)
const didApprove = await askApproval("tool", completeMessage) const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) { if (!didApprove) {
if (!fileExists) { await this.diffViewProvider.revertChanges()
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
await this.closeDiffViews()
await fs.unlink(absolutePath)
// Remove only the directories we created, in reverse order
for (let i = createdDirs.length - 1; i >= 0; i--) {
await fs.rmdir(createdDirs[i])
console.log(`Directory ${createdDirs[i]} has been deleted.`)
}
console.log(`File ${absolutePath} has been deleted.`)
} else {
// revert document
const edit = new vscode.WorkspaceEdit()
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, originalContent)
// Apply the edit and save, since contents shouldnt have changed this wont show in local history unless of course the user made changes and saved during the edit
await vscode.workspace.applyEdit(edit)
await updatedDocument.save()
console.log(`File ${absolutePath} has been reverted to its original content.`)
if (documentWasOpen) {
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), {
preview: false,
})
}
await this.closeDiffViews()
}
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
break break
} }
const userEdits = await this.diffViewProvider.saveChanges()
// Save the changes this.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
const editedContent = updatedDocument.getText() if (userEdits) {
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
this.didEditFile = true
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
await this.closeDiffViews()
// If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
const newContentEOL = newContent.includes("\r\n") ? "\r\n" : "\n"
const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL)
const normalizedNewContent = newContent.replace(/\r\n|\n/g, newContentEOL) // just in case the new content has a mix of varying EOL characters
if (normalizedEditedContent !== normalizedNewContent) {
const userDiff = diff.createPatch(
relPath.toPosix(),
normalizedNewContent,
normalizedEditedContent
)
await this.say( await this.say(
"user_feedback_diff", "user_feedback_diff",
JSON.stringify({ JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated", tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, relPath), path: getReadablePath(cwd, relPath),
diff: formatResponse.createPrettyPatch( diff: userEdits,
relPath,
normalizedNewContent,
normalizedEditedContent
),
} satisfies ClaudeSayTool) } satisfies ClaudeSayTool)
) )
pushToolResult( pushToolResult(
`The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath.toPosix()}. (Note this does not mean you need to re-write the file with the user's changes, as they have already been applied to the file.)` `The user made the following updates to your content:\n\n${userEdits}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath.toPosix()}. (Note this does not mean you need to re-write the file with the user's changes, as they have already been applied to the file.)`
) )
} else { } else {
pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.`) pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.`)
} }
await this.diffViewProvider.reset()
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
break break
} }
} catch (error) { } catch (error) {
await handleError("writing file", error) await handleError("writing file", error)
await this.diffViewProvider.reset()
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
break break
} }
} }
@@ -2006,7 +1377,7 @@ export class ClaudeDev {
} }
/* /*
seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present. Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI. When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
*/ */
this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked
@@ -2035,22 +1406,6 @@ export class ClaudeDev {
} }
} }
// streaming
private currentStreamingContentIndex = 0
private assistantMessageContent: AssistantMessageContent[] = []
private didCompleteReadingStream = false
private userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
private userMessageContentReady = false
private didRejectTool = false
private presentAssistantMessageLocked = false
private presentAssistantMessageHasPendingUpdates = false
//edit
private isEditingExistingFile: boolean | undefined
private isEditingFile = false
private editorOriginalContent: string | undefined
private editFileCreatedDirs: string[] = []
private editFileDocumentWasOpen = false
parseAssistantMessage(assistantMessage: string) { parseAssistantMessage(assistantMessage: string) {
// let text = "" // let text = ""
let textContent: TextContent = { let textContent: TextContent = {
@@ -2228,6 +1583,7 @@ export class ClaudeDev {
let outputTokens = 0 let outputTokens = 0
let totalCost: number | undefined let totalCost: number | undefined
// reset streaming state
this.currentStreamingContentIndex = 0 this.currentStreamingContentIndex = 0
this.assistantMessageContent = [] this.assistantMessageContent = []
this.didCompleteReadingStream = false this.didCompleteReadingStream = false
@@ -2236,13 +1592,7 @@ export class ClaudeDev {
this.didRejectTool = false this.didRejectTool = false
this.presentAssistantMessageLocked = false this.presentAssistantMessageLocked = false
this.presentAssistantMessageHasPendingUpdates = false this.presentAssistantMessageHasPendingUpdates = false
await this.diffViewProvider.reset()
// edit
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
let assistantMessage = "" let assistantMessage = ""
// TODO: handle error being thrown in stream // TODO: handle error being thrown in stream

View File

@@ -76,7 +76,7 @@ export const formatResponse = {
} }
}, },
createPrettyPatch: (filename = "file", oldStr: string, newStr: string) => { createPrettyPatch: (filename = "file", oldStr?: string, newStr?: string) => {
// strings cannot be undefined or diff throws exception // strings cannot be undefined or diff throws exception
const patch = diff.createPatch(filename.toPosix(), oldStr || "", newStr || "") const patch = diff.createPatch(filename.toPosix(), oldStr || "", newStr || "")
const lines = patch.split("\n") const lines = patch.split("\n")

View File

@@ -1,51 +1,396 @@
import * as vscode from "vscode" import * as vscode from "vscode"
import * as path from "path"
import * as fs from "fs/promises"
import { createDirectoriesForFile, fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { formatResponse } from "../../core/prompts/responses"
import * as diff from "diff" import * as diff from "diff"
export class DiffViewProvider implements vscode.TextDocumentContentProvider { export class DiffViewProvider {
private _onDidChange = new vscode.EventEmitter<vscode.Uri>() editType?: "create" | "modify"
onDidChange = this._onDidChange.event isEditing = false
// private isEditingExistingFile: boolean | undefined
originalContent: string | undefined
private createdDirs: string[] = []
private documentWasOpen = false
private originalContent: string = "" private relPath?: string
private newContent: string = "" private newContent?: string
private fileName: string = ""
constructor() { constructor(private cwd: string) {}
// Register the provider
vscode.workspace.registerTextDocumentContentProvider("claude-dev-diff", this) async update(relPath: string, newContent: string): Promise<void> {
this.relPath = relPath
this.newContent = newContent
const fileExists = this.editType === "modify"
const absolutePath = path.resolve(this.cwd, relPath)
if (!this.isEditing) {
// starting edit
// open the editor and prepare to stream content in
this.isEditing = true
// if the file is already open, ensure it's not dirty before getting its contents
if (fileExists) {
const existingDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)
if (existingDocument && existingDocument.isDirty) {
await existingDocument.save()
}
} }
initialize(fileName: string, originalContent: string) { // get diagnostics before editing the file, we'll compare to diagnostics after editing to see if claude needs to fix anything
this.fileName = fileName // const preDiagnostics = vscode.languages.getDiagnostics()
this.originalContent = originalContent
this.newContent = originalContent if (fileExists) {
this.originalContent = await fs.readFile(absolutePath, "utf-8")
// fix issue where claude always removes newline from the file
// const eol = this.originalContent.includes("\r\n") ? "\r\n" : "\n"
// if (this.originalContent.endsWith(eol) && !this.newContent.endsWith(eol)) {
// this.newContent += eol
// }
} else {
this.originalContent = ""
} }
updateNewContent(updatedContent: string) { const fileName = path.basename(absolutePath)
this.newContent = updatedContent // for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation
this._onDidChange.fire(this.getDiffUri())
// Keep track of newly created directories
this.createdDirs = await createDirectoriesForFile(absolutePath)
// console.log(`Created directories: ${createdDirs.join(", ")}`)
// make sure the file exists before we open it
if (!fileExists) {
await fs.writeFile(absolutePath, "")
} }
provideTextDocumentContent(uri: vscode.Uri): string { // Open the existing file with the new contents
return this.createDiffContent() const updatedDocument = await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath))
}
private createDiffContent(): string { // await updatedDocument.save()
const diffResult = diff.createPatch(this.fileName, this.originalContent, this.newContent) // const edit = new vscode.WorkspaceEdit()
return diffResult // const fullRange = new vscode.Range(
} // updatedDocument.positionAt(0),
// updatedDocument.positionAt(updatedDocument.getText().length)
// )
// edit.replace(updatedDocument.uri, fullRange, newContent)
// await vscode.workspace.applyEdit(edit)
getDiffUri(): vscode.Uri { // Windows file locking issues can prevent temporary files from being saved or closed properly.
return vscode.Uri.parse(`claude-dev-diff:${this.fileName}`).with({ // To avoid these problems, we use in-memory TextDocument objects with the `untitled` scheme.
query: Buffer.from(this.originalContent).toString("base64"), // This method keeps the document entirely in memory, bypassing the filesystem and ensuring
}) // a consistent editing experience across all platforms. This also has the added benefit of not
} // polluting the user's workspace with temporary files.
async showDiff() { // Create an in-memory document for the new content
// const inMemoryDocumentUri = vscode.Uri.parse(`untitled:${fileName}`) // untitled scheme is necessary to open a file without it being saved to disk
// const inMemoryDocument = await vscode.workspace.openTextDocument(inMemoryDocumentUri)
// const edit = new vscode.WorkspaceEdit()
// edit.insert(inMemoryDocumentUri, new vscode.Position(0, 0), newContent)
// await vscode.workspace.applyEdit(edit)
// Show diff
await vscode.commands.executeCommand( await vscode.commands.executeCommand(
"vscode.diff", "vscode.diff",
this.getDiffUri(), vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
vscode.Uri.file(this.fileName), query: Buffer.from(this.originalContent).toString("base64"),
`${this.fileName}: Original ↔ Claude's Changes (Editable)` }),
updatedDocument.uri,
`${fileName}: ${fileExists ? "Original ↔ Claude's Changes" : "New File"} (Editable)`
) )
// if the file was already open, close it (must happen after showing the diff view since if it's the only tab the column will close)
this.documentWasOpen = false
// close the tab if it's open
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath)
)
for (const tab of tabs) {
await vscode.window.tabGroups.close(tab)
this.documentWasOpen = true
} }
} }
// editor is open, stream content in
const updatedDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)!
// edit needs to happen after we close the original tab
const edit = new vscode.WorkspaceEdit()
if (!fileExists) {
// edit.insert(updatedDocument.uri, new vscode.Position(0, 0), newContent)
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
} else {
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
}
// Apply the edit, but without saving so this doesnt trigger a local save in timeline history
await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
// Find the first range where the content differs and scroll to it
// if (fileExists) {
// const diffResult = diff.diffLines(originalContent, newContent)
// for (let i = 0, lineCount = 0; i < diffResult.length; i++) {
// const part = diffResult[i]
// if (part.added || part.removed) {
// const startLine = lineCount + 1
// const endLine = lineCount + (part.count || 0)
// const activeEditor = vscode.window.activeTextEditor
// if (activeEditor) {
// try {
// activeEditor.revealRange(
// // + 3 to move the editor up slightly as this looks better
// new vscode.Range(
// new vscode.Position(startLine, 0),
// new vscode.Position(Math.min(endLine + 3, activeEditor.document.lineCount - 1), 0)
// ),
// vscode.TextEditorRevealType.InCenter
// )
// } catch (error) {
// console.error(`Error revealing range for ${absolutePath}: ${error}`)
// }
// }
// break
// }
// lineCount += part.count || 0
// }
// }
// remove cursor from the document
// await vscode.commands.executeCommand("workbench.action.focusSideBar")
// const closeInMemoryDocAndDiffViews = async () => {
// // ensure that the in-memory doc is active editor (this seems to fail on windows machines if its already active, so ignoring if there's an error as it's likely it's already active anyways)
// // try {
// // await vscode.window.showTextDocument(inMemoryDocument, {
// // preview: false, // ensures it opens in non-preview tab (preview tabs are easily replaced)
// // preserveFocus: false,
// // })
// // // await vscode.window.showTextDocument(inMemoryDocument.uri, { preview: true, preserveFocus: false })
// // } catch (error) {
// // console.log(`Could not open editor for ${absolutePath}: ${error}`)
// // }
// // await delay(50)
// // // Wait for the in-memory document to become the active editor (sometimes vscode timing issues happen and this would accidentally close claude dev!)
// // await pWaitFor(
// // () => {
// // return vscode.window.activeTextEditor?.document === inMemoryDocument
// // },
// // { timeout: 5000, interval: 50 }
// // )
// // if (vscode.window.activeTextEditor?.document === inMemoryDocument) {
// // await vscode.commands.executeCommand("workbench.action.revertAndCloseActiveEditor") // allows us to close the untitled doc without being prompted to save it
// // }
// await this.closeDiffViews()
// }
}
// async applyEdit(relPath: string, newContent: string): Promise<void> {}
async saveChanges() {
if (!this.relPath || !this.newContent) {
return
}
const absolutePath = path.resolve(this.cwd, this.relPath)
const updatedDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)!
const editedContent = updatedDocument.getText()
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
// Read the potentially edited content from the document
// trigger an entry in the local history for the file
// if (fileExists) {
// await fs.writeFile(absolutePath, originalContent)
// const editor = await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// const edit = new vscode.WorkspaceEdit()
// const fullRange = new vscode.Range(
// editor.document.positionAt(0),
// editor.document.positionAt(editor.document.getText().length)
// )
// edit.replace(editor.document.uri, fullRange, editedContent)
// // Apply the edit, this will trigger a local save and timeline history
// await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
// await editor.document.save()
// }
// if (!fileExists) {
// await fs.mkdir(path.dirname(absolutePath), { recursive: true })
// await fs.writeFile(absolutePath, "")
// }
// await closeInMemoryDocAndDiffViews()
// await fs.writeFile(absolutePath, editedContent)
// open file and add text to it, if it fails fallback to using writeFile
// we try doing it this way since it adds to local history for users to see what's changed in the file's timeline
// try {
// const editor = await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// const edit = new vscode.WorkspaceEdit()
// const fullRange = new vscode.Range(
// editor.document.positionAt(0),
// editor.document.positionAt(editor.document.getText().length)
// )
// edit.replace(editor.document.uri, fullRange, editedContent)
// // Apply the edit, this will trigger a local save and timeline history
// await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
// await editor.document.save()
// } catch (saveError) {
// console.log(`Could not open editor for ${absolutePath}: ${saveError}`)
// await fs.writeFile(absolutePath, editedContent)
// // calling showTextDocument would sometimes fail even though changes were applied, so we'll ignore these one-off errors (likely due to vscode locking issues)
// try {
// await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// } catch (openFileError) {
// console.log(`Could not open editor for ${absolutePath}: ${openFileError}`)
// }
// }
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
await this.closeDiffViews()
/*
Getting diagnostics before and after the file edit is a better approach than
automatically tracking problems in real-time. This method ensures we only
report new problems that are a direct result of this specific edit.
Since these are new problems resulting from Claude's edit, we know they're
directly related to the work he's doing. This eliminates the risk of Claude
going off-task or getting distracted by unrelated issues, which was a problem
with the previous auto-debug approach. Some users' machines may be slow to
update diagnostics, so this approach provides a good balance between automation
and avoiding potential issues where Claude might get stuck in loops due to
outdated problem information. If no new problems show up by the time the user
accepts the changes, they can always debug later using the '@problems' mention.
This way, Claude only becomes aware of new problems resulting from his edits
and can address them accordingly. If problems don't change immediately after
applying a fix, Claude won't be notified, which is generally fine since the
initial fix is usually correct and it may just take time for linters to catch up.
*/
// const postDiagnostics = vscode.languages.getDiagnostics()
// const newProblems = diagnosticsToProblemsString(
// getNewDiagnostics(preDiagnostics, postDiagnostics),
// [
// vscode.DiagnosticSeverity.Error, // only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
// ],
// cwd
// ) // will be empty string if no errors
// const newProblemsMessage =
// newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : ""
// // await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
const newContentEOL = this.newContent.includes("\r\n") ? "\r\n" : "\n"
const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL)
const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL) // just in case the new content has a mix of varying EOL characters
if (normalizedEditedContent !== normalizedNewContent) {
// user made changes before approving edit
return formatResponse.createPrettyPatch(
this.relPath.toPosix(),
normalizedNewContent,
normalizedEditedContent
)
} else {
// no changes to claude's edits
return undefined
}
}
async revertChanges(): Promise<void> {
if (!this.relPath) {
return
}
const fileExists = this.editType === "modify"
const updatedDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)!
const absolutePath = path.resolve(this.cwd, this.relPath)
if (!fileExists) {
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
await this.closeDiffViews()
await fs.unlink(absolutePath)
// Remove only the directories we created, in reverse order
for (let i = this.createdDirs.length - 1; i >= 0; i--) {
await fs.rmdir(this.createdDirs[i])
console.log(`Directory ${this.createdDirs[i]} has been deleted.`)
}
console.log(`File ${absolutePath} has been deleted.`)
} else {
// revert document
const edit = new vscode.WorkspaceEdit()
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, this.originalContent ?? "")
// Apply the edit and save, since contents shouldnt have changed this wont show in local history unless of course the user made changes and saved during the edit
await vscode.workspace.applyEdit(edit)
await updatedDocument.save()
console.log(`File ${absolutePath} has been reverted to its original content.`)
if (this.documentWasOpen) {
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), {
preview: false,
})
}
await this.closeDiffViews()
}
// edit is done
this.reset()
}
async closeDiffViews() {
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === "claude-dev-diff"
)
for (const tab of tabs) {
// trying to close dirty views results in save popup
if (!tab.isDirty) {
await vscode.window.tabGroups.close(tab)
}
}
}
// close editor if open?
async reset() {
this.editType = undefined
this.isEditing = false
this.originalContent = undefined
this.createdDirs = []
this.documentWasOpen = false
}
// ... (other helper methods like showDiffView, closeExistingTab, deleteNewFile, revertExistingFile, etc.)
}