WorkspaceTracker.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import * as vscode from "vscode"
  2. import * as path from "path"
  3. import { listFiles } from "../../services/glob/list-files"
  4. import { ClineProvider } from "../../core/webview/ClineProvider"
  5. import { toRelativePath } from "../../utils/path"
  6. const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
  7. const MAX_INITIAL_FILES = 1_000
  8. // Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
  9. class WorkspaceTracker {
  10. private providerRef: WeakRef<ClineProvider>
  11. private disposables: vscode.Disposable[] = []
  12. private filePaths: Set<string> = new Set()
  13. private updateTimer: NodeJS.Timeout | null = null
  14. constructor(provider: ClineProvider) {
  15. this.providerRef = new WeakRef(provider)
  16. this.registerListeners()
  17. }
  18. async initializeFilePaths() {
  19. // should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
  20. if (!cwd) {
  21. return
  22. }
  23. const [files, _] = await listFiles(cwd, true, MAX_INITIAL_FILES)
  24. files.slice(0, MAX_INITIAL_FILES).forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
  25. this.workspaceDidUpdate()
  26. }
  27. private registerListeners() {
  28. const watcher = vscode.workspace.createFileSystemWatcher("**")
  29. this.disposables.push(
  30. watcher.onDidCreate(async (uri) => {
  31. await this.addFilePath(uri.fsPath)
  32. this.workspaceDidUpdate()
  33. }),
  34. )
  35. // Renaming files triggers a delete and create event
  36. this.disposables.push(
  37. watcher.onDidDelete(async (uri) => {
  38. if (await this.removeFilePath(uri.fsPath)) {
  39. this.workspaceDidUpdate()
  40. }
  41. }),
  42. )
  43. this.disposables.push(watcher)
  44. this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(() => this.workspaceDidUpdate()))
  45. }
  46. private getOpenedTabsInfo() {
  47. return vscode.window.tabGroups.all.flatMap((group) =>
  48. group.tabs
  49. .filter((tab) => tab.input instanceof vscode.TabInputText)
  50. .map((tab) => {
  51. const path = (tab.input as vscode.TabInputText).uri.fsPath
  52. return {
  53. label: tab.label,
  54. isActive: tab.isActive,
  55. path: toRelativePath(path, cwd || ""),
  56. }
  57. }),
  58. )
  59. }
  60. private workspaceDidUpdate() {
  61. if (this.updateTimer) {
  62. clearTimeout(this.updateTimer)
  63. }
  64. this.updateTimer = setTimeout(() => {
  65. if (!cwd) {
  66. return
  67. }
  68. const relativeFilePaths = Array.from(this.filePaths).map((file) => toRelativePath(file, cwd))
  69. this.providerRef.deref()?.postMessageToWebview({
  70. type: "workspaceUpdated",
  71. filePaths: relativeFilePaths,
  72. openedTabs: this.getOpenedTabsInfo(),
  73. })
  74. this.updateTimer = null
  75. }, 300) // Debounce for 300ms
  76. }
  77. private normalizeFilePath(filePath: string): string {
  78. const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath)
  79. return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
  80. }
  81. private async addFilePath(filePath: string): Promise<string> {
  82. // Allow for some buffer to account for files being created/deleted during a task
  83. if (this.filePaths.size >= MAX_INITIAL_FILES * 2) {
  84. return filePath
  85. }
  86. const normalizedPath = this.normalizeFilePath(filePath)
  87. try {
  88. const stat = await vscode.workspace.fs.stat(vscode.Uri.file(normalizedPath))
  89. const isDirectory = (stat.type & vscode.FileType.Directory) !== 0
  90. const pathWithSlash = isDirectory && !normalizedPath.endsWith("/") ? normalizedPath + "/" : normalizedPath
  91. this.filePaths.add(pathWithSlash)
  92. return pathWithSlash
  93. } catch {
  94. // If stat fails, assume it's a file (this can happen for newly created files)
  95. this.filePaths.add(normalizedPath)
  96. return normalizedPath
  97. }
  98. }
  99. private async removeFilePath(filePath: string): Promise<boolean> {
  100. const normalizedPath = this.normalizeFilePath(filePath)
  101. return this.filePaths.delete(normalizedPath) || this.filePaths.delete(normalizedPath + "/")
  102. }
  103. public dispose() {
  104. if (this.updateTimer) {
  105. clearTimeout(this.updateTimer)
  106. this.updateTimer = null
  107. }
  108. this.disposables.forEach((d) => d.dispose())
  109. }
  110. }
  111. export default WorkspaceTracker