DiffViewProvider.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. import * as vscode from "vscode"
  2. import * as path from "path"
  3. import * as fs from "fs/promises"
  4. import { createDirectoriesForFile } from "../../utils/fs"
  5. import { arePathsEqual } from "../../utils/path"
  6. import { formatResponse } from "../../core/prompts/responses"
  7. import { DecorationController } from "./DecorationController"
  8. import * as diff from "diff"
  9. import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics"
  10. export const DIFF_VIEW_URI_SCHEME = "cline-diff"
  11. export class DiffViewProvider {
  12. editType?: "create" | "modify"
  13. isEditing = false
  14. originalContent: string | undefined
  15. private createdDirs: string[] = []
  16. private documentWasOpen = false
  17. private relPath?: string
  18. private newContent?: string
  19. private activeDiffEditor?: vscode.TextEditor
  20. private fadedOverlayController?: DecorationController
  21. private activeLineController?: DecorationController
  22. private streamedLines: string[] = []
  23. private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = []
  24. constructor(private cwd: string) {}
  25. async open(relPath: string): Promise<void> {
  26. this.relPath = relPath
  27. const fileExists = this.editType === "modify"
  28. const absolutePath = path.resolve(this.cwd, relPath)
  29. this.isEditing = true
  30. // if the file is already open, ensure it's not dirty before getting its contents
  31. if (fileExists) {
  32. const existingDocument = vscode.workspace.textDocuments.find((doc) =>
  33. arePathsEqual(doc.uri.fsPath, absolutePath)
  34. )
  35. if (existingDocument && existingDocument.isDirty) {
  36. await existingDocument.save()
  37. }
  38. }
  39. // get diagnostics before editing the file, we'll compare to diagnostics after editing to see if cline needs to fix anything
  40. this.preDiagnostics = vscode.languages.getDiagnostics()
  41. if (fileExists) {
  42. this.originalContent = await fs.readFile(absolutePath, "utf-8")
  43. } else {
  44. this.originalContent = ""
  45. }
  46. // for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation
  47. this.createdDirs = await createDirectoriesForFile(absolutePath)
  48. // make sure the file exists before we open it
  49. if (!fileExists) {
  50. await fs.writeFile(absolutePath, "")
  51. }
  52. // 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)
  53. this.documentWasOpen = false
  54. // close the tab if it's open (it's already saved above)
  55. const tabs = vscode.window.tabGroups.all
  56. .map((tg) => tg.tabs)
  57. .flat()
  58. .filter(
  59. (tab) => tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath)
  60. )
  61. for (const tab of tabs) {
  62. if (!tab.isDirty) {
  63. await vscode.window.tabGroups.close(tab)
  64. }
  65. this.documentWasOpen = true
  66. }
  67. this.activeDiffEditor = await this.openDiffEditor()
  68. this.fadedOverlayController = new DecorationController("fadedOverlay", this.activeDiffEditor)
  69. this.activeLineController = new DecorationController("activeLine", this.activeDiffEditor)
  70. // Apply faded overlay to all lines initially
  71. this.fadedOverlayController.addLines(0, this.activeDiffEditor.document.lineCount)
  72. this.scrollEditorToLine(0) // will this crash for new files?
  73. this.streamedLines = []
  74. }
  75. async update(accumulatedContent: string, isFinal: boolean) {
  76. if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) {
  77. throw new Error("Required values not set")
  78. }
  79. this.newContent = accumulatedContent
  80. const accumulatedLines = accumulatedContent.split("\n")
  81. if (!isFinal) {
  82. accumulatedLines.pop() // remove the last partial line only if it's not the final update
  83. }
  84. const diffLines = accumulatedLines.slice(this.streamedLines.length)
  85. const document = this.activeDiffEditor?.document
  86. if (!document) {
  87. throw new Error("User closed text editor, unable to edit file...")
  88. }
  89. for (let i = 0; i < diffLines.length; i++) {
  90. const currentLine = this.streamedLines.length + i
  91. // Replace all content up to the current line with accumulated lines
  92. // 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
  93. const edit = new vscode.WorkspaceEdit()
  94. const rangeToReplace = new vscode.Range(0, 0, currentLine + 1, 0)
  95. const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n"
  96. edit.replace(document.uri, rangeToReplace, contentToReplace)
  97. await vscode.workspace.applyEdit(edit)
  98. // Update decorations
  99. this.activeLineController.setActiveLine(currentLine)
  100. this.fadedOverlayController.updateOverlayAfterLine(currentLine, document.lineCount)
  101. // Scroll to the current line
  102. this.scrollEditorToLine(currentLine)
  103. }
  104. // Update the streamedLines with the new accumulated content
  105. this.streamedLines = accumulatedLines
  106. if (isFinal) {
  107. // Handle any remaining lines if the new content is shorter than the original
  108. if (this.streamedLines.length < document.lineCount) {
  109. const edit = new vscode.WorkspaceEdit()
  110. edit.delete(document.uri, new vscode.Range(this.streamedLines.length, 0, document.lineCount, 0))
  111. await vscode.workspace.applyEdit(edit)
  112. }
  113. // Add empty last line if original content had one
  114. const hasEmptyLastLine = this.originalContent?.endsWith("\n")
  115. if (hasEmptyLastLine) {
  116. const accumulatedLines = accumulatedContent.split("\n")
  117. if (accumulatedLines[accumulatedLines.length - 1] !== "") {
  118. accumulatedContent += "\n"
  119. }
  120. }
  121. // Clear all decorations at the end (before applying final edit)
  122. this.fadedOverlayController.clear()
  123. this.activeLineController.clear()
  124. }
  125. }
  126. async saveChanges(): Promise<{ newProblemsMessage: string | undefined; userEdits: string | undefined }> {
  127. if (!this.relPath || !this.newContent || !this.activeDiffEditor) {
  128. return { newProblemsMessage: undefined, userEdits: undefined }
  129. }
  130. const absolutePath = path.resolve(this.cwd, this.relPath)
  131. const updatedDocument = this.activeDiffEditor.document
  132. const editedContent = updatedDocument.getText()
  133. if (updatedDocument.isDirty) {
  134. await updatedDocument.save()
  135. }
  136. await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
  137. await this.closeAllDiffViews()
  138. /*
  139. Getting diagnostics before and after the file edit is a better approach than
  140. automatically tracking problems in real-time. This method ensures we only
  141. report new problems that are a direct result of this specific edit.
  142. Since these are new problems resulting from Cline's edit, we know they're
  143. directly related to the work he's doing. This eliminates the risk of Cline
  144. going off-task or getting distracted by unrelated issues, which was a problem
  145. with the previous auto-debug approach. Some users' machines may be slow to
  146. update diagnostics, so this approach provides a good balance between automation
  147. and avoiding potential issues where Cline might get stuck in loops due to
  148. outdated problem information. If no new problems show up by the time the user
  149. accepts the changes, they can always debug later using the '@problems' mention.
  150. This way, Cline only becomes aware of new problems resulting from his edits
  151. and can address them accordingly. If problems don't change immediately after
  152. applying a fix, Cline won't be notified, which is generally fine since the
  153. initial fix is usually correct and it may just take time for linters to catch up.
  154. */
  155. const postDiagnostics = vscode.languages.getDiagnostics()
  156. const newProblems = diagnosticsToProblemsString(
  157. getNewDiagnostics(this.preDiagnostics, postDiagnostics),
  158. [
  159. vscode.DiagnosticSeverity.Error, // only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
  160. ],
  161. this.cwd
  162. ) // will be empty string if no errors
  163. const newProblemsMessage =
  164. newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : ""
  165. // If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
  166. const newContentEOL = this.newContent.includes("\r\n") ? "\r\n" : "\n"
  167. const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // trimEnd to fix issue where editor adds in extra new line automatically
  168. // just in case the new content has a mix of varying EOL characters
  169. const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL
  170. if (normalizedEditedContent !== normalizedNewContent) {
  171. // user made changes before approving edit
  172. const userEdits = formatResponse.createPrettyPatch(
  173. this.relPath.toPosix(),
  174. normalizedNewContent,
  175. normalizedEditedContent
  176. )
  177. return { newProblemsMessage, userEdits }
  178. } else {
  179. // no changes to cline's edits
  180. return { newProblemsMessage, userEdits: undefined }
  181. }
  182. }
  183. async revertChanges(): Promise<void> {
  184. if (!this.relPath || !this.activeDiffEditor) {
  185. return
  186. }
  187. const fileExists = this.editType === "modify"
  188. const updatedDocument = this.activeDiffEditor.document
  189. const absolutePath = path.resolve(this.cwd, this.relPath)
  190. if (!fileExists) {
  191. if (updatedDocument.isDirty) {
  192. await updatedDocument.save()
  193. }
  194. await this.closeAllDiffViews()
  195. await fs.unlink(absolutePath)
  196. // Remove only the directories we created, in reverse order
  197. for (let i = this.createdDirs.length - 1; i >= 0; i--) {
  198. await fs.rmdir(this.createdDirs[i])
  199. console.log(`Directory ${this.createdDirs[i]} has been deleted.`)
  200. }
  201. console.log(`File ${absolutePath} has been deleted.`)
  202. } else {
  203. // revert document
  204. const edit = new vscode.WorkspaceEdit()
  205. const fullRange = new vscode.Range(
  206. updatedDocument.positionAt(0),
  207. updatedDocument.positionAt(updatedDocument.getText().length)
  208. )
  209. edit.replace(updatedDocument.uri, fullRange, this.originalContent ?? "")
  210. // 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
  211. await vscode.workspace.applyEdit(edit)
  212. await updatedDocument.save()
  213. console.log(`File ${absolutePath} has been reverted to its original content.`)
  214. if (this.documentWasOpen) {
  215. await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), {
  216. preview: false,
  217. })
  218. }
  219. await this.closeAllDiffViews()
  220. }
  221. // edit is done
  222. await this.reset()
  223. }
  224. private async closeAllDiffViews() {
  225. const tabs = vscode.window.tabGroups.all
  226. .flatMap((tg) => tg.tabs)
  227. .filter(
  228. (tab) =>
  229. tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME
  230. )
  231. for (const tab of tabs) {
  232. // trying to close dirty views results in save popup
  233. if (!tab.isDirty) {
  234. await vscode.window.tabGroups.close(tab)
  235. }
  236. }
  237. }
  238. private async openDiffEditor(): Promise<vscode.TextEditor> {
  239. if (!this.relPath) {
  240. throw new Error("No file path set")
  241. }
  242. const uri = vscode.Uri.file(path.resolve(this.cwd, this.relPath))
  243. // 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
  244. const diffTab = vscode.window.tabGroups.all
  245. .flatMap((group) => group.tabs)
  246. .find(
  247. (tab) =>
  248. tab.input instanceof vscode.TabInputTextDiff &&
  249. tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME &&
  250. arePathsEqual(tab.input.modified.fsPath, uri.fsPath)
  251. )
  252. if (diffTab && diffTab.input instanceof vscode.TabInputTextDiff) {
  253. const editor = await vscode.window.showTextDocument(diffTab.input.modified)
  254. return editor
  255. }
  256. // Open new diff editor
  257. return new Promise<vscode.TextEditor>((resolve, reject) => {
  258. const fileName = path.basename(uri.fsPath)
  259. const fileExists = this.editType === "modify"
  260. const disposable = vscode.window.onDidChangeActiveTextEditor((editor) => {
  261. if (editor && arePathsEqual(editor.document.uri.fsPath, uri.fsPath)) {
  262. disposable.dispose()
  263. resolve(editor)
  264. }
  265. })
  266. vscode.commands.executeCommand(
  267. "vscode.diff",
  268. vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${fileName}`).with({
  269. query: Buffer.from(this.originalContent ?? "").toString("base64"),
  270. }),
  271. uri,
  272. `${fileName}: ${fileExists ? "Original ↔ Cline's Changes" : "New File"} (Editable)`
  273. )
  274. // This may happen on very slow machines ie project idx
  275. setTimeout(() => {
  276. disposable.dispose()
  277. reject(new Error("Failed to open diff editor, please try again..."))
  278. }, 10_000)
  279. })
  280. }
  281. private scrollEditorToLine(line: number) {
  282. if (this.activeDiffEditor) {
  283. const scrollLine = line + 4
  284. this.activeDiffEditor.revealRange(
  285. new vscode.Range(scrollLine, 0, scrollLine, 0),
  286. vscode.TextEditorRevealType.InCenter
  287. )
  288. }
  289. }
  290. scrollToFirstDiff() {
  291. if (!this.activeDiffEditor) {
  292. return
  293. }
  294. const currentContent = this.activeDiffEditor.document.getText()
  295. const diffs = diff.diffLines(this.originalContent || "", currentContent)
  296. let lineCount = 0
  297. for (const part of diffs) {
  298. if (part.added || part.removed) {
  299. // Found the first diff, scroll to it
  300. this.activeDiffEditor.revealRange(
  301. new vscode.Range(lineCount, 0, lineCount, 0),
  302. vscode.TextEditorRevealType.InCenter
  303. )
  304. return
  305. }
  306. if (!part.removed) {
  307. lineCount += part.count || 0
  308. }
  309. }
  310. }
  311. // close editor if open?
  312. async reset() {
  313. this.editType = undefined
  314. this.isEditing = false
  315. this.originalContent = undefined
  316. this.createdDirs = []
  317. this.documentWasOpen = false
  318. this.activeDiffEditor = undefined
  319. this.fadedOverlayController = undefined
  320. this.activeLineController = undefined
  321. this.streamedLines = []
  322. this.preDiagnostics = []
  323. }
  324. }