Sfoglia il codice sorgente

Make diff view editable

Saoud Rizwan 1 anno fa
parent
commit
9d5090397f

+ 96 - 78
src/ClaudeDev.ts

@@ -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")) {
-					newContent += "\n"
+				const eol = originalContent.includes("\r\n") ? "\r\n" : "\n"
+				if (originalContent.endsWith(eol) && !newContent.endsWith(eol)) {
+					newContent += eol
 				}
 				}
-				// condensed patch to return to claude
-				const diffResult = diff.createPatch(absolutePath, originalContent, newContent)
-				// 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
-				const fileName = path.basename(absolutePath)
-				vscode.commands.executeCommand(
-					"vscode.diff",
-					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`
-				)
+			} else {
+				originalContent = ""
+			}
 
 
-				const { response, text, images } = await this.ask(
+			// Create a temporary file with the new content
+			const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "claude-dev-"))
+			const tempFilePath = path.join(tempDir, path.basename(absolutePath))
+			await fs.writeFile(tempFilePath, newContent)
+
+			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)
-				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(
+				userResponse = 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) {
-						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.mkdir(path.dirname(absolutePath), { recursive: true })
-				await fs.writeFile(absolutePath, newContent)
-				await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
+			}
+			const { response, text, images } = userResponse
+
+			if (response !== "yesButtonTapped") {
 				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(
-				(tab) =>
-					tab.input instanceof vscode.TabInputTextDiff && tab.input?.modified?.scheme === "claude-dev-diff"
-			)
+			.filter((tab) => {
+				if (tab.input instanceof vscode.TabInputTextDiff) {
+					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)
 		}
 		}

+ 1 - 1
src/extension.ts

@@ -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.

+ 1 - 0
src/shared/ExtensionMessage.ts

@@ -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"

+ 29 - 0
webview-ui/src/components/ChatRow.tsx

@@ -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 (
 							<>
 							<>

+ 16 - 3
webview-ui/src/components/ChatView.tsx

@@ -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")
-							setSecondaryButtonText("Reject")
+							const tool = JSON.parse(lastMessage.text || "{}") as ClaudeSayTool
+							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)