Make diff view editable

This commit is contained in:
Saoud Rizwan
2024-08-31 05:56:38 -04:00
parent d355660c2e
commit 9d5090397f
5 changed files with 142 additions and 81 deletions

View File

@@ -796,77 +796,51 @@ export class ClaudeDev {
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false)
let originalContent: string
if (fileExists) { if (fileExists) {
const originalContent = await fs.readFile(absolutePath, "utf-8") originalContent = await fs.readFile(absolutePath, "utf-8")
// fix issue where claude always removes newline from the file // fix issue where claude always removes newline from the file
if (originalContent.endsWith("\n") && !newContent.endsWith("\n")) { const eol = originalContent.includes("\r\n") ? "\r\n" : "\n"
newContent += "\n" if (originalContent.endsWith(eol) && !newContent.endsWith(eol)) {
newContent += eol
} }
// condensed patch to return to claude } else {
const diffResult = diff.createPatch(absolutePath, originalContent, newContent) originalContent = ""
// full diff representation for webview }
const diffRepresentation = diff
.diffLines(originalContent, newContent)
.map((part) => {
const prefix = part.added ? "+" : part.removed ? "-" : " "
return (part.value || "")
.split("\n")
.map((line) => (line ? prefix + line : ""))
.join("\n")
})
.join("")
// Create virtual document with new file, then open diff editor // Create a temporary file with the new content
const fileName = path.basename(absolutePath) const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "claude-dev-"))
vscode.commands.executeCommand( const tempFilePath = path.join(tempDir, path.basename(absolutePath))
"vscode.diff", await fs.writeFile(tempFilePath, newContent)
vscode.Uri.file(absolutePath),
// to create a virtual doc we use a uri scheme registered in extension.ts, which then converts this base64 content into a text document
// (providing file name with extension in the uri lets vscode know the language of the file and apply syntax highlighting)
vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
query: Buffer.from(newContent).toString("base64"),
}),
`${fileName}: Original ↔ Suggested Changes`
)
const { response, text, images } = await this.ask( vscode.commands.executeCommand(
"vscode.diff",
fileExists
? vscode.Uri.file(absolutePath)
: vscode.Uri.parse(`claude-dev-diff:${path.basename(absolutePath)}`).with({
query: Buffer.from("").toString("base64"),
}),
vscode.Uri.file(tempFilePath),
`${path.basename(absolutePath)}: ${fileExists ? "Original ↔ Suggested Changes" : "New File"} (Editable)`
)
let userResponse: {
response: ClaudeAskResponse
text?: string
images?: string[]
}
if (fileExists) {
const suggestedDiff = diff.createPatch(relPath, originalContent, newContent)
userResponse = await this.ask(
"tool", "tool",
JSON.stringify({ JSON.stringify({
tool: "editedExistingFile", tool: "editedExistingFile",
path: this.getReadablePath(relPath), path: this.getReadablePath(relPath),
diff: diffRepresentation, diff: suggestedDiff,
} as ClaudeSayTool) } as ClaudeSayTool)
) )
if (response !== "yesButtonTapped") {
if (isLast) {
await this.closeDiffViews()
}
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
}
return "The user denied this operation."
}
await fs.writeFile(absolutePath, newContent)
// Finish by opening the edited file in the editor
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
if (isLast) {
await this.closeDiffViews()
}
return `Changes applied to ${relPath}:\n${diffResult}`
} else { } else {
const fileName = path.basename(absolutePath) userResponse = await this.ask(
vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
query: Buffer.from("").toString("base64"),
}),
vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
query: Buffer.from(newContent).toString("base64"),
}),
`${fileName}: New File`
)
const { response, text, images } = await this.ask(
"tool", "tool",
JSON.stringify({ JSON.stringify({
tool: "newFileCreated", tool: "newFileCreated",
@@ -874,23 +848,62 @@ export class ClaudeDev {
content: newContent, content: newContent,
} as ClaudeSayTool) } as ClaudeSayTool)
) )
if (response !== "yesButtonTapped") { }
if (isLast) { const { response, text, images } = userResponse
await this.closeDiffViews()
} if (response !== "yesButtonTapped") {
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
}
return "The user denied this operation."
}
await fs.mkdir(path.dirname(absolutePath), { recursive: true })
await fs.writeFile(absolutePath, newContent)
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
if (isLast) { if (isLast) {
await this.closeDiffViews() await this.closeDiffViews()
} }
return `New file created and content written to ${relPath}` // Clean up the temporary file
await fs.rm(tempDir, { recursive: true, force: true })
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return this.formatIntoToolResponse(await this.formatGenericToolFeedback(text), images)
}
return "The user denied this operation."
}
// Save any unsaved changes in the diff editor
const diffDocument = vscode.workspace.textDocuments.find((doc) => doc.uri.fsPath === tempFilePath)
if (diffDocument && diffDocument.isDirty) {
console.log("saving diff document")
await diffDocument.save()
}
// Read the potentially edited content from the temp file
const editedContent = await fs.readFile(tempFilePath, "utf-8")
if (!fileExists) {
await fs.mkdir(path.dirname(absolutePath), { recursive: true })
}
await fs.writeFile(absolutePath, editedContent)
// Clean up the temporary file
await fs.rm(tempDir, { recursive: true, force: true })
// Finish by opening the edited file in the editor
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
if (isLast) {
await this.closeDiffViews()
}
if (editedContent !== newContent) {
const diffResult = diff.createPatch(relPath, originalContent, editedContent)
const userDiff = diff.createPatch(relPath, newContent, editedContent)
await this.say(
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: this.getReadablePath(relPath),
diff: userDiff,
} as ClaudeSayTool)
)
return `${
fileExists ? "Changes applied" : "New file written"
} to ${relPath}:\n${diffResult}, the user applied these changes:\n${userDiff}`
} else {
const diffResult = diff.createPatch(relPath, originalContent, newContent)
return `${fileExists ? "Changes applied" : "New file written"} to ${relPath}:\n${diffResult}`
} }
} catch (error) { } catch (error) {
const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}` const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}`
@@ -906,10 +919,15 @@ export class ClaudeDev {
const tabs = vscode.window.tabGroups.all const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs) .map((tg) => tg.tabs)
.flat() .flat()
.filter( .filter((tab) => {
(tab) => if (tab.input instanceof vscode.TabInputTextDiff) {
tab.input instanceof vscode.TabInputTextDiff && tab.input?.modified?.scheme === "claude-dev-diff" const originalPath = (tab.input.original as vscode.Uri).toString()
) const modifiedPath = (tab.input.modified as vscode.Uri).toString()
return originalPath.includes("claude-dev-") || modifiedPath.includes("claude-dev-")
}
return false
})
for (const tab of tabs) { for (const tab of tabs) {
await vscode.window.tabGroups.close(tab) await vscode.window.tabGroups.close(tab)
} }

View File

@@ -94,7 +94,7 @@ export function activate(context: vscode.ExtensionContext) {
) )
/* /*
We use the text document content provider API to show a diff view for new files/edits by creating a virtual document for the new content. We use the text document content provider API to show an empty text doc for diff view for new files by creating a virtual document for the new content.
- This API allows you to create readonly documents in VSCode from arbitrary sources, and works by claiming an uri-scheme for which your provider then returns text contents. The scheme must be provided when registering a provider and cannot change afterwards. - This API allows you to create readonly documents in VSCode from arbitrary sources, and works by claiming an uri-scheme for which your provider then returns text contents. The scheme must be provided when registering a provider and cannot change afterwards.
- Note how the provider doesn't create uris for virtual documents - its role is to provide contents given such an uri. In return, content providers are wired into the open document logic so that providers are always considered. - Note how the provider doesn't create uris for virtual documents - its role is to provide contents given such an uri. In return, content providers are wired into the open document logic so that providers are always considered.

View File

@@ -53,6 +53,7 @@ export type ClaudeSay =
| "text" | "text"
| "completion_result" | "completion_result"
| "user_feedback" | "user_feedback"
| "user_feedback_diff"
| "api_req_retried" | "api_req_retried"
| "command_output" | "command_output"
| "tool" | "tool"

View File

@@ -338,6 +338,35 @@ const ChatRow: React.FC<ChatRowProps> = ({
)} )}
</div> </div>
) )
case "user_feedback_diff":
const tool = JSON.parse(message.text || "{}") as ClaudeSayTool
return (
<div
style={{
backgroundColor: "var(--vscode-editor-inactiveSelectionBackground)",
borderRadius: "3px",
padding: "8px",
whiteSpace: "pre-line",
wordWrap: "break-word",
}}>
<span
style={{
display: "block",
fontStyle: "italic",
marginBottom: "8px",
opacity: 0.8,
}}>
The user made the following changes:
</span>
<CodeBlock
diff={tool.diff!}
path={tool.path!}
syntaxHighlighterStyle={syntaxHighlighterStyle}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
/>
</div>
)
case "error": case "error":
return ( return (
<> <>

View File

@@ -4,7 +4,7 @@ import vsDarkPlus from "react-syntax-highlighter/dist/esm/styles/prism/vsc-dark-
import DynamicTextArea from "react-textarea-autosize" import DynamicTextArea from "react-textarea-autosize"
import { useEvent, useMount } from "react-use" import { useEvent, useMount } from "react-use"
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
import { ClaudeAsk, ExtensionMessage } from "../../../src/shared/ExtensionMessage" import { ClaudeAsk, ClaudeSayTool, ExtensionMessage } from "../../../src/shared/ExtensionMessage"
import { combineApiRequests } from "../../../src/shared/combineApiRequests" import { combineApiRequests } from "../../../src/shared/combineApiRequests"
import { combineCommandSequences } from "../../../src/shared/combineCommandSequences" import { combineCommandSequences } from "../../../src/shared/combineCommandSequences"
import { getApiMetrics } from "../../../src/shared/getApiMetrics" import { getApiMetrics } from "../../../src/shared/getApiMetrics"
@@ -121,8 +121,21 @@ const ChatView = ({
setTextAreaDisabled(false) setTextAreaDisabled(false)
setClaudeAsk("tool") setClaudeAsk("tool")
setEnableButtons(true) setEnableButtons(true)
setPrimaryButtonText("Approve") const tool = JSON.parse(lastMessage.text || "{}") as ClaudeSayTool
setSecondaryButtonText("Reject") switch (tool.tool) {
case "editedExistingFile":
setPrimaryButtonText("Save")
setSecondaryButtonText("Reject")
break
case "newFileCreated":
setPrimaryButtonText("Create")
setSecondaryButtonText("Reject")
break
default:
setPrimaryButtonText("Approve")
setSecondaryButtonText("Reject")
break
}
break break
case "command": case "command":
setTextAreaDisabled(false) setTextAreaDisabled(false)