diff --git a/src/core/ClaudeDev.ts b/src/core/ClaudeDev.ts index fab6b37..a875df6 100644 --- a/src/core/ClaudeDev.ts +++ b/src/core/ClaudeDev.ts @@ -893,16 +893,9 @@ export class ClaudeDev { await this.diffViewProvider.open(relPath) } // editor is open, stream content in - await this.diffViewProvider.update(newContent) + await this.diffViewProvider.update(newContent, false) break } else { - // 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. - // in other words, you must always repeat the block.partial logic here - if (!this.diffViewProvider.isEditing) { - await this.diffViewProvider.open(relPath) - } - await this.diffViewProvider.update(newContent) if (!relPath) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "path")) @@ -917,6 +910,16 @@ export class ClaudeDev { } this.consecutiveMistakeCount = 0 + // 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. + // in other words, you must always repeat the block.partial logic here + if (!this.diffViewProvider.isEditing) { + await this.diffViewProvider.open(relPath) + } + await this.diffViewProvider.update(newContent, true) + await delay(300) // wait for diff view to update + this.diffViewProvider.scrollToFirstDiff() + const completeMessage = JSON.stringify({ ...sharedMessageProps, content: fileExists ? undefined : newContent, @@ -933,7 +936,7 @@ export class ClaudeDev { await this.diffViewProvider.revertChanges() break } - const userEdits = await this.diffViewProvider.saveChanges() + const { newProblemsMessage, userEdits } = await this.diffViewProvider.saveChanges() this.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request if (userEdits) { await this.say( @@ -945,10 +948,12 @@ export class ClaudeDev { } satisfies ClaudeSayTool) ) pushToolResult( - `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.)` + `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.)${newProblemsMessage}` ) } else { - pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.`) + pushToolResult( + `The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}` + ) } await this.diffViewProvider.reset() break diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 7d1be82..cb6e902 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -89,13 +89,13 @@ Remember: - Formulate your tool use using the XML format specified for each tool. - After using a tool, you will receive the tool use result in the user's next message. This result will provide you with the necessary information to continue your task or make further decisions. -CRITICAL RULE: You must use only one tool at a time. Multiple tool uses in a single message are strictly prohibited. +CRITICAL RULE: You are only allowed to use one tool per message. Multiple tool uses in a single message is STRICTLY FORBIDDEN. Even if it may seem efficient to use multiple tools at once, the system will crash and burn if you do so. To ensure compliance: 1. After each tool use, wait for the result before proceeding. 2. Analyze the result of each tool use before deciding on the next step. -3. If multiple actions are needed, break them into separate, sequential steps, each using a single tool. +3. If multiple actions are needed, break them into separate, sequential messages, each using a single tool. Remember: *One tool use per message. No exceptions.* diff --git a/src/integrations/editor/DecorationController.ts b/src/integrations/editor/DecorationController.ts new file mode 100644 index 0000000..c1b6458 --- /dev/null +++ b/src/integrations/editor/DecorationController.ts @@ -0,0 +1,81 @@ +import * as vscode from "vscode" + +const fadedOverlayDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(255, 255, 0, 0.1)", + opacity: "0.4", + isWholeLine: true, +}) + +const activeLineDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: "rgba(255, 255, 0, 0.3)", + opacity: "1", + isWholeLine: true, + border: "1px solid rgba(255, 255, 0, 0.5)", +}) + +type DecorationType = "fadedOverlay" | "activeLine" + +export class DecorationController { + private decorationType: DecorationType + private editor: vscode.TextEditor + private ranges: vscode.Range[] = [] + + constructor(decorationType: DecorationType, editor: vscode.TextEditor) { + this.decorationType = decorationType + this.editor = editor + } + + getDecoration() { + switch (this.decorationType) { + case "fadedOverlay": + return fadedOverlayDecorationType + case "activeLine": + return activeLineDecorationType + } + } + + addLines(startIndex: number, numLines: number) { + // Guard against invalid inputs + if (startIndex < 0 || numLines <= 0) { + return + } + + const lastRange = this.ranges[this.ranges.length - 1] + if (lastRange && lastRange.end.line === startIndex - 1) { + this.ranges[this.ranges.length - 1] = lastRange.with(undefined, lastRange.end.translate(numLines)) + } else { + const endLine = startIndex + numLines - 1 + this.ranges.push(new vscode.Range(startIndex, 0, endLine, Number.MAX_SAFE_INTEGER)) + } + + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + clear() { + this.ranges = [] + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + updateOverlayAfterLine(line: number, totalLines: number) { + // Remove any existing ranges that start at or after the current line + this.ranges = this.ranges.filter((range) => range.end.line < line) + + // Add a new range for all lines after the current line + if (line < totalLines - 1) { + this.ranges.push( + new vscode.Range( + new vscode.Position(line + 1, 0), + new vscode.Position(totalLines - 1, Number.MAX_SAFE_INTEGER) + ) + ) + } + + // Apply the updated decorations + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + setActiveLine(line: number) { + this.ranges = [new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER)] + this.editor.setDecorations(this.getDecoration(), this.ranges) + } +} diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index a83d8d2..a8df883 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -4,6 +4,9 @@ import * as fs from "fs/promises" import { createDirectoriesForFile } from "../../utils/fs" import { arePathsEqual } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" +import { DecorationController } from "./DecorationController" +import * as diff from "diff" +import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" export class DiffViewProvider { editType?: "create" | "modify" @@ -13,6 +16,11 @@ export class DiffViewProvider { private documentWasOpen = false private relPath?: string private newContent?: string + private activeDiffEditor?: vscode.TextEditor + private fadedOverlayController?: DecorationController + private activeLineController?: DecorationController + private streamedLines: string[] = [] + private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [] constructor(private cwd: string) {} @@ -20,9 +28,7 @@ export class DiffViewProvider { this.relPath = relPath const fileExists = this.editType === "modify" const absolutePath = path.resolve(this.cwd, relPath) - 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) => @@ -34,69 +40,22 @@ export class DiffViewProvider { } // 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() + this.preDiagnostics = vscode.languages.getDiagnostics() 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 = "" } - - 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.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, "") } - - // 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(this.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) this.documentWasOpen = false - - // close the tab if it's open + // close the tab if it's open (it's already saved above) const tabs = vscode.window.tabGroups.all .map((tg) => tg.tabs) .flat() @@ -104,229 +63,145 @@ export class DiffViewProvider { (tab) => tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath) ) for (const tab of tabs) { - await vscode.window.tabGroups.close(tab) + if (!tab.isDirty) { + await vscode.window.tabGroups.close(tab) + } this.documentWasOpen = true } + this.activeDiffEditor = await this.openDiffEditor() + this.fadedOverlayController = new DecorationController("fadedOverlay", this.activeDiffEditor) + this.activeLineController = new DecorationController("activeLine", this.activeDiffEditor) + // Apply faded overlay to all lines initially + this.fadedOverlayController.addLines(0, this.activeDiffEditor.document.lineCount) + this.scrollEditorToLine(0) // will this crash for new files? + this.streamedLines = [] } - async update(newContent: string): Promise { - if (!this.relPath) { + async update(accumulatedContent: string, isFinal: boolean) { + if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) { + throw new Error("Required values not set") + } + this.newContent = accumulatedContent + const accumulatedLines = accumulatedContent.split("\n") + if (!isFinal) { + accumulatedLines.pop() // remove the last partial line only if it's not the final update + } + const diffLines = accumulatedLines.slice(this.streamedLines.length) + const document = vscode.window.activeTextEditor?.document + if (!document) { + console.error("No active text editor") return } - this.newContent = newContent - - const fileExists = this.editType === "modify" - const absolutePath = path.resolve(this.cwd, this.relPath) - - 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, this.newContent) - } else { - const fullRange = new vscode.Range( - updatedDocument.positionAt(0), - updatedDocument.positionAt(updatedDocument.getText().length) - ) - edit.replace(updatedDocument.uri, fullRange, this.newContent) + const diffViewEditor = vscode.window.activeTextEditor + if (!diffViewEditor) { + console.error("No active diff view editor") + return + } + for (let i = 0; i < diffLines.length; i++) { + const currentLine = this.streamedLines.length + i + // Replace all content up to the current line with accumulated lines + // This is necessary (as compared to inserting one line at a time) to handle cases where html tags on previous lines are auto closed for example + const edit = new vscode.WorkspaceEdit() + const rangeToReplace = new vscode.Range(0, 0, currentLine + 1, 0) + const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n" + edit.replace(document.uri, rangeToReplace, contentToReplace) + await vscode.workspace.applyEdit(edit) + // Update decorations + this.activeLineController.setActiveLine(currentLine) + this.fadedOverlayController.updateOverlayAfterLine(currentLine, document.lineCount) + // Scroll to the current line + this.scrollEditorToLine(currentLine) + } + // Update the streamedLines with the new accumulated content + this.streamedLines = accumulatedLines + if (isFinal) { + // Handle any remaining lines if the new content is shorter than the original + if (this.streamedLines.length < document.lineCount) { + const edit = new vscode.WorkspaceEdit() + edit.delete(document.uri, new vscode.Range(this.streamedLines.length, 0, document.lineCount, 0)) + await vscode.workspace.applyEdit(edit) + } + // Add empty last line if original content had one + const hasEmptyLastLine = this.originalContent?.endsWith("\n") + if (hasEmptyLastLine) { + const accumulatedLines = accumulatedContent.split("\n") + if (accumulatedLines[accumulatedLines.length - 1] !== "") { + accumulatedContent += "\n" + } + } + // Clear all decorations at the end (before applying final edit) + this.fadedOverlayController.clear() + this.activeLineController.clear() } - // 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 {} - - async saveChanges() { - if (!this.relPath || !this.newContent) { - return + async saveChanges(): Promise<{ newProblemsMessage: string | undefined; userEdits: string | undefined }> { + if (!this.relPath || !this.newContent || !this.activeDiffEditor) { + return { newProblemsMessage: undefined, userEdits: undefined } } - const absolutePath = path.resolve(this.cwd, this.relPath) - - const updatedDocument = vscode.workspace.textDocuments.find((doc) => - arePathsEqual(doc.uri.fsPath, absolutePath) - )! - + const updatedDocument = this.activeDiffEditor.document 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.closeAllDiffViews() /* - 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 }) + 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(this.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) + ], + this.cwd + ) // will be empty string if no errors + const newProblemsMessage = + newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : "" // 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 - + const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // trimEnd to fix issue where editor adds in extra new line automatically + // just in case the new content has a mix of varying EOL characters + const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL if (normalizedEditedContent !== normalizedNewContent) { // user made changes before approving edit - return formatResponse.createPrettyPatch( + const userEdits = formatResponse.createPrettyPatch( this.relPath.toPosix(), normalizedNewContent, normalizedEditedContent ) + return { newProblemsMessage, userEdits } } else { // no changes to claude's edits - return undefined + return { newProblemsMessage, userEdits: undefined } } } async revertChanges(): Promise { - if (!this.relPath) { + if (!this.relPath || !this.activeDiffEditor) { return } const fileExists = this.editType === "modify" - const updatedDocument = vscode.workspace.textDocuments.find((doc) => - arePathsEqual(doc.uri.fsPath, absolutePath) - )! + const updatedDocument = this.activeDiffEditor.document const absolutePath = path.resolve(this.cwd, this.relPath) if (!fileExists) { if (updatedDocument.isDirty) { @@ -366,13 +241,11 @@ export class DiffViewProvider { private async closeAllDiffViews() { const tabs = vscode.window.tabGroups.all - .map((tg) => tg.tabs) - .flat() + .flatMap((tg) => tg.tabs) .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) { @@ -381,6 +254,82 @@ export class DiffViewProvider { } } + private async openDiffEditor(): Promise { + if (!this.relPath) { + throw new Error("No file path set") + } + const uri = vscode.Uri.file(path.resolve(this.cwd, this.relPath)) + // If this diff editor is already open (ie if a previous write file was interrupted) then we should activate that instead of opening a new diff + const diffTab = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .find( + (tab) => + tab.input instanceof vscode.TabInputTextDiff && + tab.input?.original?.scheme === "claude-dev-diff" && + arePathsEqual(tab.input.modified.fsPath, uri.fsPath) + ) + if (diffTab && diffTab.input instanceof vscode.TabInputTextDiff) { + const editor = await vscode.window.showTextDocument(diffTab.input.modified) + return editor + } + // Open new diff editor + return new Promise((resolve, reject) => { + const fileName = path.basename(uri.fsPath) + const fileExists = this.editType === "modify" + const disposable = vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor && arePathsEqual(editor.document.uri.fsPath, uri.fsPath)) { + disposable.dispose() + resolve(editor) + } + }) + vscode.commands.executeCommand( + "vscode.diff", + vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({ + query: Buffer.from(this.originalContent ?? "").toString("base64"), + }), + uri, + `${fileName}: ${fileExists ? "Original ↔ Claude's Changes" : "New File"} (Editable)` + ) + // This should never happen but if it does it's worth investigating + setTimeout(() => { + disposable.dispose() + reject(new Error("Failed to open diff editor")) + }, 5_000) + }) + } + + private scrollEditorToLine(line: number) { + if (this.activeDiffEditor) { + const scrollLine = line + 4 + this.activeDiffEditor.revealRange( + new vscode.Range(scrollLine, 0, scrollLine, 0), + vscode.TextEditorRevealType.InCenter + ) + } + } + + scrollToFirstDiff() { + if (!this.activeDiffEditor) { + return + } + const currentContent = this.activeDiffEditor.document.getText() + const diffs = diff.diffLines(this.originalContent || "", currentContent) + let lineCount = 0 + for (const part of diffs) { + if (part.added || part.removed) { + // Found the first diff, scroll to it + this.activeDiffEditor.revealRange( + new vscode.Range(lineCount, 0, lineCount, 0), + vscode.TextEditorRevealType.InCenter + ) + return + } + if (!part.removed) { + lineCount += part.count || 0 + } + } + } + // close editor if open? async reset() { this.editType = undefined @@ -388,7 +337,10 @@ export class DiffViewProvider { this.originalContent = undefined this.createdDirs = [] this.documentWasOpen = false + this.activeDiffEditor = undefined + this.fadedOverlayController = undefined + this.activeLineController = undefined + this.streamedLines = [] + this.preDiagnostics = [] } - - // ... (other helper methods like showDiffView, closeExistingTab, deleteNewFile, revertExistingFile, etc.) }