DiffViewProvider.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  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 diffEditor = this.activeDiffEditor
  85. const document = diffEditor?.document
  86. if (!diffEditor || !document) {
  87. throw new Error("User closed text editor, unable to edit file...")
  88. }
  89. // Place cursor at the beginning of the diff editor to keep it out of the way of the stream animation
  90. const beginningOfDocument = new vscode.Position(0, 0)
  91. diffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument)
  92. const endLine = accumulatedLines.length
  93. // Replace all content up to the current line with accumulated lines
  94. const edit = new vscode.WorkspaceEdit()
  95. const rangeToReplace = new vscode.Range(0, 0, endLine + 1, 0)
  96. const contentToReplace = accumulatedLines.slice(0, endLine + 1).join("\n") + "\n"
  97. edit.replace(document.uri, rangeToReplace, contentToReplace)
  98. await vscode.workspace.applyEdit(edit)
  99. // Update decorations
  100. this.activeLineController.setActiveLine(endLine)
  101. this.fadedOverlayController.updateOverlayAfterLine(endLine, document.lineCount)
  102. // Scroll to the current line
  103. this.scrollEditorToLine(endLine)
  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. // Preserve empty last line if original content had one
  114. const hasEmptyLastLine = this.originalContent?.endsWith("\n")
  115. if (hasEmptyLastLine && !accumulatedContent.endsWith("\n")) {
  116. accumulatedContent += "\n"
  117. }
  118. // Apply the final content
  119. const finalEdit = new vscode.WorkspaceEdit()
  120. finalEdit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), accumulatedContent)
  121. await vscode.workspace.applyEdit(finalEdit)
  122. // Clear all decorations at the end (after applying final edit)
  123. this.fadedOverlayController.clear()
  124. this.activeLineController.clear()
  125. }
  126. }
  127. async saveChanges(): Promise<{
  128. newProblemsMessage: string | undefined
  129. userEdits: string | undefined
  130. finalContent: string | undefined
  131. }> {
  132. if (!this.relPath || !this.newContent || !this.activeDiffEditor) {
  133. return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined }
  134. }
  135. const absolutePath = path.resolve(this.cwd, this.relPath)
  136. const updatedDocument = this.activeDiffEditor.document
  137. const editedContent = updatedDocument.getText()
  138. if (updatedDocument.isDirty) {
  139. await updatedDocument.save()
  140. }
  141. await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
  142. await this.closeAllDiffViews()
  143. /*
  144. Getting diagnostics before and after the file edit is a better approach than
  145. automatically tracking problems in real-time. This method ensures we only
  146. report new problems that are a direct result of this specific edit.
  147. Since these are new problems resulting from Roo's edit, we know they're
  148. directly related to the work he's doing. This eliminates the risk of Roo
  149. going off-task or getting distracted by unrelated issues, which was a problem
  150. with the previous auto-debug approach. Some users' machines may be slow to
  151. update diagnostics, so this approach provides a good balance between automation
  152. and avoiding potential issues where Roo might get stuck in loops due to
  153. outdated problem information. If no new problems show up by the time the user
  154. accepts the changes, they can always debug later using the '@problems' mention.
  155. This way, Roo only becomes aware of new problems resulting from his edits
  156. and can address them accordingly. If problems don't change immediately after
  157. applying a fix, won't be notified, which is generally fine since the
  158. initial fix is usually correct and it may just take time for linters to catch up.
  159. */
  160. const postDiagnostics = vscode.languages.getDiagnostics()
  161. const newProblems = diagnosticsToProblemsString(
  162. getNewDiagnostics(this.preDiagnostics, postDiagnostics),
  163. [
  164. vscode.DiagnosticSeverity.Error, // only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
  165. ],
  166. this.cwd,
  167. ) // will be empty string if no errors
  168. const newProblemsMessage =
  169. newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : ""
  170. // If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
  171. const newContentEOL = this.newContent.includes("\r\n") ? "\r\n" : "\n"
  172. const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // trimEnd to fix issue where editor adds in extra new line automatically
  173. // just in case the new content has a mix of varying EOL characters
  174. const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL
  175. if (normalizedEditedContent !== normalizedNewContent) {
  176. // user made changes before approving edit
  177. const userEdits = formatResponse.createPrettyPatch(
  178. this.relPath.toPosix(),
  179. normalizedNewContent,
  180. normalizedEditedContent,
  181. )
  182. return { newProblemsMessage, userEdits, finalContent: normalizedEditedContent }
  183. } else {
  184. // no changes to cline's edits
  185. return { newProblemsMessage, userEdits: undefined, finalContent: normalizedEditedContent }
  186. }
  187. }
  188. async revertChanges(): Promise<void> {
  189. if (!this.relPath || !this.activeDiffEditor) {
  190. return
  191. }
  192. const fileExists = this.editType === "modify"
  193. const updatedDocument = this.activeDiffEditor.document
  194. const absolutePath = path.resolve(this.cwd, this.relPath)
  195. if (!fileExists) {
  196. if (updatedDocument.isDirty) {
  197. await updatedDocument.save()
  198. }
  199. await this.closeAllDiffViews()
  200. await fs.unlink(absolutePath)
  201. // Remove only the directories we created, in reverse order
  202. for (let i = this.createdDirs.length - 1; i >= 0; i--) {
  203. await fs.rmdir(this.createdDirs[i])
  204. console.log(`Directory ${this.createdDirs[i]} has been deleted.`)
  205. }
  206. console.log(`File ${absolutePath} has been deleted.`)
  207. } else {
  208. // revert document
  209. const edit = new vscode.WorkspaceEdit()
  210. const fullRange = new vscode.Range(
  211. updatedDocument.positionAt(0),
  212. updatedDocument.positionAt(updatedDocument.getText().length),
  213. )
  214. edit.replace(updatedDocument.uri, fullRange, this.originalContent ?? "")
  215. // 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
  216. await vscode.workspace.applyEdit(edit)
  217. await updatedDocument.save()
  218. console.log(`File ${absolutePath} has been reverted to its original content.`)
  219. if (this.documentWasOpen) {
  220. await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), {
  221. preview: false,
  222. })
  223. }
  224. await this.closeAllDiffViews()
  225. }
  226. // edit is done
  227. await this.reset()
  228. }
  229. private async closeAllDiffViews() {
  230. const tabs = vscode.window.tabGroups.all
  231. .flatMap((tg) => tg.tabs)
  232. .filter(
  233. (tab) =>
  234. tab.input instanceof vscode.TabInputTextDiff &&
  235. tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME,
  236. )
  237. for (const tab of tabs) {
  238. // trying to close dirty views results in save popup
  239. if (!tab.isDirty) {
  240. await vscode.window.tabGroups.close(tab)
  241. }
  242. }
  243. }
  244. private async openDiffEditor(): Promise<vscode.TextEditor> {
  245. if (!this.relPath) {
  246. throw new Error("No file path set")
  247. }
  248. const uri = vscode.Uri.file(path.resolve(this.cwd, this.relPath))
  249. // 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
  250. const diffTab = vscode.window.tabGroups.all
  251. .flatMap((group) => group.tabs)
  252. .find(
  253. (tab) =>
  254. tab.input instanceof vscode.TabInputTextDiff &&
  255. tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME &&
  256. arePathsEqual(tab.input.modified.fsPath, uri.fsPath),
  257. )
  258. if (diffTab && diffTab.input instanceof vscode.TabInputTextDiff) {
  259. const editor = await vscode.window.showTextDocument(diffTab.input.modified)
  260. return editor
  261. }
  262. // Open new diff editor
  263. return new Promise<vscode.TextEditor>((resolve, reject) => {
  264. const fileName = path.basename(uri.fsPath)
  265. const fileExists = this.editType === "modify"
  266. const disposable = vscode.window.onDidChangeActiveTextEditor((editor) => {
  267. if (editor && arePathsEqual(editor.document.uri.fsPath, uri.fsPath)) {
  268. disposable.dispose()
  269. resolve(editor)
  270. }
  271. })
  272. vscode.commands.executeCommand(
  273. "vscode.diff",
  274. vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${fileName}`).with({
  275. query: Buffer.from(this.originalContent ?? "").toString("base64"),
  276. }),
  277. uri,
  278. `${fileName}: ${fileExists ? "Original ↔ Roo's Changes" : "New File"} (Editable)`,
  279. )
  280. // This may happen on very slow machines ie project idx
  281. setTimeout(() => {
  282. disposable.dispose()
  283. reject(new Error("Failed to open diff editor, please try again..."))
  284. }, 10_000)
  285. })
  286. }
  287. private scrollEditorToLine(line: number) {
  288. if (this.activeDiffEditor) {
  289. const scrollLine = line + 4
  290. this.activeDiffEditor.revealRange(
  291. new vscode.Range(scrollLine, 0, scrollLine, 0),
  292. vscode.TextEditorRevealType.InCenter,
  293. )
  294. }
  295. }
  296. scrollToFirstDiff() {
  297. if (!this.activeDiffEditor) {
  298. return
  299. }
  300. const currentContent = this.activeDiffEditor.document.getText()
  301. const diffs = diff.diffLines(this.originalContent || "", currentContent)
  302. let lineCount = 0
  303. for (const part of diffs) {
  304. if (part.added || part.removed) {
  305. // Found the first diff, scroll to it
  306. this.activeDiffEditor.revealRange(
  307. new vscode.Range(lineCount, 0, lineCount, 0),
  308. vscode.TextEditorRevealType.InCenter,
  309. )
  310. return
  311. }
  312. if (!part.removed) {
  313. lineCount += part.count || 0
  314. }
  315. }
  316. }
  317. // close editor if open?
  318. async reset() {
  319. this.editType = undefined
  320. this.isEditing = false
  321. this.originalContent = undefined
  322. this.createdDirs = []
  323. this.documentWasOpen = false
  324. this.activeDiffEditor = undefined
  325. this.fadedOverlayController = undefined
  326. this.activeLineController = undefined
  327. this.streamedLines = []
  328. this.preDiagnostics = []
  329. }
  330. }