FileContextTracker.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import { safeWriteJson } from "../../utils/safeWriteJson"
  2. import * as path from "path"
  3. import * as vscode from "vscode"
  4. import { getTaskDirectoryPath } from "../../utils/storage"
  5. import { GlobalFileNames } from "../../shared/globalFileNames"
  6. import { fileExistsAtPath } from "../../utils/fs"
  7. import fs from "fs/promises"
  8. import { ContextProxy } from "../config/ContextProxy"
  9. import type { FileMetadataEntry, RecordSource, TaskMetadata } from "./FileContextTrackerTypes"
  10. import { ClineProvider } from "../webview/ClineProvider"
  11. // This class is responsible for tracking file operations that may result in stale context.
  12. // If a user modifies a file outside of Roo, the context may become stale and need to be updated.
  13. // We do not want Roo to reload the context every time a file is modified, so we use this class merely
  14. // to inform Roo that the change has occurred, and tell Roo to reload the file before making
  15. // any changes to it. This fixes an issue with diff editing, where Roo was unable to complete a diff edit.
  16. // FileContextTracker
  17. //
  18. // This class is responsible for tracking file operations.
  19. // If the full contents of a file are passed to Roo via a tool, mention, or edit, the file is marked as active.
  20. // If a file is modified outside of Roo, we detect and track this change to prevent stale context.
  21. export class FileContextTracker {
  22. readonly taskId: string
  23. private providerRef: WeakRef<ClineProvider>
  24. // File tracking and watching
  25. private fileWatchers = new Map<string, vscode.FileSystemWatcher>()
  26. private recentlyModifiedFiles = new Set<string>()
  27. private recentlyEditedByRoo = new Set<string>()
  28. private checkpointPossibleFiles = new Set<string>()
  29. constructor(provider: ClineProvider, taskId: string) {
  30. this.providerRef = new WeakRef(provider)
  31. this.taskId = taskId
  32. }
  33. // Gets the current working directory or returns undefined if it cannot be determined
  34. private getCwd(): string | undefined {
  35. const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
  36. if (!cwd) {
  37. console.info("No workspace folder available - cannot determine current working directory")
  38. }
  39. return cwd
  40. }
  41. // File watchers are set up for each file that is tracked in the task metadata.
  42. async setupFileWatcher(filePath: string) {
  43. // Only setup watcher if it doesn't already exist for this file
  44. if (this.fileWatchers.has(filePath)) {
  45. return
  46. }
  47. const cwd = this.getCwd()
  48. if (!cwd) {
  49. return
  50. }
  51. // Create a file system watcher for this specific file
  52. const fileUri = vscode.Uri.file(path.resolve(cwd, filePath))
  53. const watcher = vscode.workspace.createFileSystemWatcher(
  54. new vscode.RelativePattern(path.dirname(fileUri.fsPath), path.basename(fileUri.fsPath)),
  55. )
  56. // Track file changes
  57. watcher.onDidChange(() => {
  58. if (this.recentlyEditedByRoo.has(filePath)) {
  59. this.recentlyEditedByRoo.delete(filePath) // This was an edit by Roo, no need to inform Roo
  60. } else {
  61. this.recentlyModifiedFiles.add(filePath) // This was a user edit, we will inform Roo
  62. this.trackFileContext(filePath, "user_edited") // Update the task metadata with file tracking
  63. }
  64. })
  65. // Store the watcher so we can dispose it later
  66. this.fileWatchers.set(filePath, watcher)
  67. }
  68. // Tracks a file operation in metadata and sets up a watcher for the file
  69. // This is the main entry point for FileContextTracker and is called when a file is passed to Roo via a tool, mention, or edit.
  70. async trackFileContext(filePath: string, operation: RecordSource) {
  71. try {
  72. const cwd = this.getCwd()
  73. if (!cwd) {
  74. return
  75. }
  76. await this.addFileToFileContextTracker(this.taskId, filePath, operation)
  77. // Set up file watcher for this file
  78. await this.setupFileWatcher(filePath)
  79. } catch (error) {
  80. console.error("Failed to track file operation:", error)
  81. }
  82. }
  83. public getContextProxy(): ContextProxy | undefined {
  84. const provider = this.providerRef.deref()
  85. if (!provider) {
  86. console.error("ClineProvider reference is no longer valid")
  87. return undefined
  88. }
  89. const context = provider.contextProxy
  90. if (!context) {
  91. console.error("Context is not available")
  92. return undefined
  93. }
  94. return context
  95. }
  96. // Gets task metadata from storage
  97. async getTaskMetadata(taskId: string): Promise<TaskMetadata> {
  98. const globalStoragePath = this.getContextProxy()?.globalStorageUri.fsPath ?? ""
  99. const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
  100. const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
  101. try {
  102. if (await fileExistsAtPath(filePath)) {
  103. return JSON.parse(await fs.readFile(filePath, "utf8"))
  104. }
  105. } catch (error) {
  106. console.error("Failed to read task metadata:", error)
  107. }
  108. return { files_in_context: [] }
  109. }
  110. // Saves task metadata to storage
  111. async saveTaskMetadata(taskId: string, metadata: TaskMetadata) {
  112. try {
  113. const globalStoragePath = this.getContextProxy()!.globalStorageUri.fsPath
  114. const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
  115. const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
  116. await safeWriteJson(filePath, metadata)
  117. } catch (error) {
  118. console.error("Failed to save task metadata:", error)
  119. }
  120. }
  121. // Adds a file to the metadata tracker
  122. // This handles the business logic of determining if the file is new, stale, or active.
  123. // It also updates the metadata with the latest read/edit dates.
  124. async addFileToFileContextTracker(taskId: string, filePath: string, source: RecordSource) {
  125. try {
  126. const metadata = await this.getTaskMetadata(taskId)
  127. const now = Date.now()
  128. // Mark existing entries for this file as stale
  129. metadata.files_in_context.forEach((entry) => {
  130. if (entry.path === filePath && entry.record_state === "active") {
  131. entry.record_state = "stale"
  132. }
  133. })
  134. // Helper to get the latest date for a specific field and file
  135. const getLatestDateForField = (path: string, field: keyof FileMetadataEntry): number | null => {
  136. const relevantEntries = metadata.files_in_context
  137. .filter((entry) => entry.path === path && entry[field])
  138. .sort((a, b) => (b[field] as number) - (a[field] as number))
  139. return relevantEntries.length > 0 ? (relevantEntries[0][field] as number) : null
  140. }
  141. let newEntry: FileMetadataEntry = {
  142. path: filePath,
  143. record_state: "active",
  144. record_source: source,
  145. roo_read_date: getLatestDateForField(filePath, "roo_read_date"),
  146. roo_edit_date: getLatestDateForField(filePath, "roo_edit_date"),
  147. user_edit_date: getLatestDateForField(filePath, "user_edit_date"),
  148. }
  149. switch (source) {
  150. // user_edited: The user has edited the file
  151. case "user_edited":
  152. newEntry.user_edit_date = now
  153. this.recentlyModifiedFiles.add(filePath)
  154. break
  155. // roo_edited: Roo has edited the file
  156. case "roo_edited":
  157. newEntry.roo_read_date = now
  158. newEntry.roo_edit_date = now
  159. this.checkpointPossibleFiles.add(filePath)
  160. this.markFileAsEditedByRoo(filePath)
  161. break
  162. // read_tool/file_mentioned: Roo has read the file via a tool or file mention
  163. case "read_tool":
  164. case "file_mentioned":
  165. newEntry.roo_read_date = now
  166. break
  167. }
  168. metadata.files_in_context.push(newEntry)
  169. await this.saveTaskMetadata(taskId, metadata)
  170. } catch (error) {
  171. console.error("Failed to add file to metadata:", error)
  172. }
  173. }
  174. // Returns (and then clears) the set of recently modified files
  175. getAndClearRecentlyModifiedFiles(): string[] {
  176. const files = Array.from(this.recentlyModifiedFiles)
  177. this.recentlyModifiedFiles.clear()
  178. return files
  179. }
  180. getAndClearCheckpointPossibleFile(): string[] {
  181. const files = Array.from(this.checkpointPossibleFiles)
  182. this.checkpointPossibleFiles.clear()
  183. return files
  184. }
  185. // Marks a file as edited by Roo to prevent false positives in file watchers
  186. markFileAsEditedByRoo(filePath: string): void {
  187. this.recentlyEditedByRoo.add(filePath)
  188. }
  189. // Disposes all file watchers
  190. dispose(): void {
  191. for (const watcher of this.fileWatchers.values()) {
  192. watcher.dispose()
  193. }
  194. this.fileWatchers.clear()
  195. }
  196. }