|
| 1 | +// |
| 2 | +// CEWorkspaceFileManager+DirectoryEvents.swift |
| 3 | +// CodeEdit |
| 4 | +// |
| 5 | +// Created by Axel Martinez on 5/8/24. |
| 6 | +// |
| 7 | + |
| 8 | +import Foundation |
| 9 | + |
| 10 | +/// This extension handles the file system events triggered by changes in the root folder. |
| 11 | +extension CEWorkspaceFileManager { |
| 12 | + /// Called by `fsEventStream` when an event occurs. |
| 13 | + /// |
| 14 | + /// This method may be called on a background thread, but all work done by this function will be queued on the main |
| 15 | + /// thread. |
| 16 | + /// - Parameter events: An array of events that occurred. |
| 17 | + func fileSystemEventReceived(events: [DirectoryEventStream.Event]) { |
| 18 | + DispatchQueue.main.async { |
| 19 | + var files: Set<CEWorkspaceFile> = [] |
| 20 | + for event in events { |
| 21 | + // Event returns file/folder that was changed, but in tree we need to update it's parent |
| 22 | + let parentUrl = "/" + event.path.split(separator: "/").dropLast().joined(separator: "/") |
| 23 | + // Find all folders pointing to the parent's file url. |
| 24 | + let fileItems = self.flattenedFileItems.filter({ |
| 25 | + $0.value.resolvedURL.path == parentUrl |
| 26 | + }).map { $0.value } |
| 27 | + |
| 28 | + switch event.eventType { |
| 29 | + case .changeInDirectory, .itemChangedOwner, .itemModified: |
| 30 | + // Can be ignored for now, these I think not related to tree changes |
| 31 | + continue |
| 32 | + case .rootChanged: |
| 33 | + // TODO: Handle workspace root changing. |
| 34 | + continue |
| 35 | + case .itemCreated, .itemCloned, .itemRemoved, .itemRenamed: |
| 36 | + for fileItem in fileItems { |
| 37 | + do { |
| 38 | + try self.rebuildFiles(fromItem: fileItem) |
| 39 | + } catch { |
| 40 | + // swiftlint:disable:next line_length |
| 41 | + self.logger.error("Failed to rebuild files for event: \(event.eventType.rawValue), path: \(event.path, privacy: .sensitive)") |
| 42 | + } |
| 43 | + files.insert(fileItem) |
| 44 | + } |
| 45 | + } |
| 46 | + } |
| 47 | + if !files.isEmpty { |
| 48 | + self.notifyObservers(updatedItems: files) |
| 49 | + } |
| 50 | + |
| 51 | + self.handleGitEvents(events: events) |
| 52 | + } |
| 53 | + } |
| 54 | + |
| 55 | + func handleGitEvents(events: [DirectoryEventStream.Event]) { |
| 56 | + // Changes excluding .git folder |
| 57 | + let notGitChanges = events.filter({ !$0.path.contains(".git/") }) |
| 58 | + |
| 59 | + // .git folder was changed |
| 60 | + let gitFolderChange = events.first(where: { |
| 61 | + $0.path == "\(self.folderUrl.relativePath)/.git" |
| 62 | + }) |
| 63 | + |
| 64 | + // Change made to git index file, staged/unstaged files |
| 65 | + let gitIndexChange = events.first(where: { |
| 66 | + $0.path == "\(self.folderUrl.relativePath)/.git/index" |
| 67 | + }) |
| 68 | + |
| 69 | + // Change made to git stash |
| 70 | + let gitStashChange = events.first(where: { |
| 71 | + $0.path == "\(self.folderUrl.relativePath)/.git/refs/stash" |
| 72 | + }) |
| 73 | + |
| 74 | + // Changes made to git branches |
| 75 | + let gitBranchChange = events.first(where: { |
| 76 | + $0.path.contains("\(self.folderUrl.relativePath)/.git/refs/heads") |
| 77 | + }) |
| 78 | + |
| 79 | + // Changes made to git HEAD - current branch changed |
| 80 | + let gitHeadChange = events.first(where: { |
| 81 | + $0.path.contains("\(self.folderUrl.relativePath)/.git/HEAD") |
| 82 | + }) |
| 83 | + |
| 84 | + // Change made to remotes by looking at .git/config |
| 85 | + let gitConfigChange = events.first(where: { |
| 86 | + $0.path == "\(self.folderUrl.relativePath)/.git/config" |
| 87 | + }) |
| 88 | + |
| 89 | + // If changes were made to project OR files were staged, refresh changes |
| 90 | + if !notGitChanges.isEmpty || gitIndexChange != nil { |
| 91 | + Task { |
| 92 | + await self.sourceControlManager?.refreshAllChangedFiles() |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + // If changes were stashed, refresh stashed entries |
| 97 | + if gitStashChange != nil { |
| 98 | + Task { |
| 99 | + try await self.sourceControlManager?.refreshStashEntries() |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + // If branches were added or removed, refresh branches |
| 104 | + if gitBranchChange != nil { |
| 105 | + Task { |
| 106 | + await self.sourceControlManager?.refreshBranches() |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + // If HEAD was changed, refresh the current branch |
| 111 | + if gitHeadChange != nil { |
| 112 | + Task { |
| 113 | + await self.sourceControlManager?.refreshCurrentBranch() |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + // If git config changed, refresh remotes |
| 118 | + if gitConfigChange != nil { |
| 119 | + Task { |
| 120 | + try await self.sourceControlManager?.refreshRemotes() |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + // If .git folder was added or removed, check if repository is valid |
| 125 | + if gitFolderChange != nil { |
| 126 | + Task { |
| 127 | + try await self.sourceControlManager?.validate() |
| 128 | + } |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + /// Creates or deletes children of the ``CEWorkspaceFile`` so that they are accurate with the file system, |
| 133 | + /// instead of creating an entirely new ``CEWorkspaceFile``. Can optionally run a deep rebuild. |
| 134 | + /// |
| 135 | + /// This method will return immediately if the given file item is not a directory. |
| 136 | + /// This will also only rebuild *already cached* directories. |
| 137 | + /// - Parameters: |
| 138 | + /// - fileItem: The ``CEWorkspaceFile`` to correct the children of |
| 139 | + /// - deep: Set to `true` if this should perform the rebuild recursively. |
| 140 | + func rebuildFiles(fromItem fileItem: CEWorkspaceFile, deep: Bool = false) throws { |
| 141 | + // Do not index directories that are not already loaded. |
| 142 | + guard childrenMap[fileItem.id] != nil else { return } |
| 143 | + |
| 144 | + // get the actual directory children |
| 145 | + let directoryContentsUrls = try fileManager.contentsOfDirectory( |
| 146 | + at: fileItem.resolvedURL, |
| 147 | + includingPropertiesForKeys: nil |
| 148 | + ) |
| 149 | + |
| 150 | + // test for deleted children, and remove them from the index |
| 151 | + // Folders may or may not have slash at the end, this will normalize check |
| 152 | + let directoryContentsUrlsRelativePaths = directoryContentsUrls.map({ $0.relativePath }) |
| 153 | + for (idx, oldURL) in (childrenMap[fileItem.id] ?? []).map({ URL(filePath: $0) }).enumerated().reversed() |
| 154 | + where !directoryContentsUrlsRelativePaths.contains(oldURL.relativePath) { |
| 155 | + flattenedFileItems.removeValue(forKey: oldURL.relativePath) |
| 156 | + childrenMap[fileItem.id]?.remove(at: idx) |
| 157 | + } |
| 158 | + |
| 159 | + // test for new children, and index them |
| 160 | + for newContent in directoryContentsUrls { |
| 161 | + // if the child has already been indexed, continue to the next item. |
| 162 | + guard !ignoredFilesAndFolders.contains(newContent.lastPathComponent) && |
| 163 | + !(childrenMap[fileItem.id]?.contains(newContent.relativePath) ?? true) else { continue } |
| 164 | + |
| 165 | + if fileManager.fileExists(atPath: newContent.path) { |
| 166 | + let newFileItem = createChild(newContent, forParent: fileItem) |
| 167 | + flattenedFileItems[newFileItem.id] = newFileItem |
| 168 | + childrenMap[fileItem.id]?.append(newFileItem.id) |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + childrenMap[fileItem.id] = childrenMap[fileItem.id]? |
| 173 | + .map { URL(filePath: $0) } |
| 174 | + .sortItems(foldersOnTop: true) |
| 175 | + .map { $0.relativePath } |
| 176 | + |
| 177 | + if deep && childrenMap[fileItem.id] != nil { |
| 178 | + for child in (childrenMap[fileItem.id] ?? []).compactMap({ flattenedFileItems[$0] }) { |
| 179 | + try rebuildFiles(fromItem: child) |
| 180 | + } |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + /// Notify observers that an update occurred in the watched files. |
| 185 | + func notifyObservers(updatedItems: Set<CEWorkspaceFile>) { |
| 186 | + observers.allObjects.reversed().forEach { delegate in |
| 187 | + guard let delegate = delegate as? CEWorkspaceFileManagerObserver else { |
| 188 | + observers.remove(delegate) |
| 189 | + return |
| 190 | + } |
| 191 | + delegate.fileManagerUpdated(updatedItems: updatedItems) |
| 192 | + } |
| 193 | + } |
| 194 | + |
| 195 | + /// Add an observer for file system events. |
| 196 | + /// - Parameter observer: The observer to add. |
| 197 | + func addObserver(_ observer: CEWorkspaceFileManagerObserver) { |
| 198 | + observers.add(observer as AnyObject) |
| 199 | + } |
| 200 | + |
| 201 | + /// Remove an observer for file system events. |
| 202 | + /// - Parameter observer: The observer to remove. |
| 203 | + func removeObserver(_ observer: CEWorkspaceFileManagerObserver) { |
| 204 | + observers.remove(observer as AnyObject) |
| 205 | + } |
| 206 | +} |
0 commit comments