From 1726301e950120aeaff9d37815450410893c62fe Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 29 Jun 2023 23:33:40 -0500 Subject: [PATCH 1/3] Add `DirectoryEventStream` --- CodeEdit.xcodeproj/project.pbxproj | 38 ++-- .../CEWorkspace/Models/CEWorkspaceFile.swift | 30 --- .../Models/CEWorkspaceFileManager.swift | 182 +++++++----------- .../Models/DirectoryEventStream.swift | 123 ++++++++++++ .../Documents/WorkspaceDocument.swift | 9 +- .../OutlineView/FileSystemTableViewCell.swift | 10 +- .../ProjectNavigatorOutlineView.swift | 15 +- 7 files changed, 227 insertions(+), 180 deletions(-) create mode 100644 CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 31d501fabd..89d41cd7ed 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -243,7 +243,9 @@ 58FD7608291EA1CB0051D6E4 /* CommandPaletteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD7605291EA1CB0051D6E4 /* CommandPaletteViewModel.swift */; }; 58FD7609291EA1CB0051D6E4 /* CommandPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD7607291EA1CB0051D6E4 /* CommandPaletteView.swift */; }; 5C4BB1E128212B1E00A92FB2 /* World.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4BB1E028212B1E00A92FB2 /* World.swift */; }; + 6C049A372A49E2DB00D42923 /* DirectoryEventStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C049A362A49E2DB00D42923 /* DirectoryEventStream.swift */; }; 6C05A8AF284D0CA3007F4EAA /* WorkspaceDocument+Listeners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C05A8AE284D0CA3007F4EAA /* WorkspaceDocument+Listeners.swift */; }; + 6C092EC62A4E803300489202 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 6C092EC52A4E803300489202 /* CodeEditTextView */; }; 6C0D0C6829E861B000AE4D3F /* SettingsSidebarFix.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C0D0C6729E861B000AE4D3F /* SettingsSidebarFix.swift */; }; 6C0F3A3C2A1D0D5000223D19 /* CodeEditKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6C0F3A3B2A1D0D5000223D19 /* CodeEditKit */; }; 6C147C4029A328BC0089B630 /* SplitViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C147C3F29A328560089B630 /* SplitViewData.swift */; }; @@ -314,7 +316,6 @@ 6CC9E4B229B5669900C97388 /* Environment+ActiveTabGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC9E4B129B5669900C97388 /* Environment+ActiveTabGroup.swift */; }; 6CD0375F2A3504540071C4DA /* FuzzySearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD0375E2A3504540071C4DA /* FuzzySearch.swift */; }; 6CD03B6A29FC773F001BD1D0 /* SettingsInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD03B6929FC773F001BD1D0 /* SettingsInjector.swift */; }; - 6CD601B52A420E0900E8C324 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD601B42A420E0900E8C324 /* CodeEditTextView */; }; 6CDA84AD284C1BA000C1CC3A /* TabBarContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDA84AC284C1BA000C1CC3A /* TabBarContextMenu.swift */; }; 6CDEFC9629E22C2700B7C684 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 6CDEFC9529E22C2700B7C684 /* Introspect */; }; 6CE622692A2A174A0013085C /* InspectorTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE622682A2A174A0013085C /* InspectorTab.swift */; }; @@ -700,6 +701,7 @@ 58FD7605291EA1CB0051D6E4 /* CommandPaletteViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandPaletteViewModel.swift; sourceTree = ""; }; 58FD7607291EA1CB0051D6E4 /* CommandPaletteView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandPaletteView.swift; sourceTree = ""; }; 5C4BB1E028212B1E00A92FB2 /* World.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = World.swift; sourceTree = ""; }; + 6C049A362A49E2DB00D42923 /* DirectoryEventStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryEventStream.swift; sourceTree = ""; }; 6C05A8AE284D0CA3007F4EAA /* WorkspaceDocument+Listeners.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceDocument+Listeners.swift"; sourceTree = ""; }; 6C0D0C6729E861B000AE4D3F /* SettingsSidebarFix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSidebarFix.swift; sourceTree = ""; }; 6C147C3D29A3281D0089B630 /* TabGroupData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupData.swift; sourceTree = ""; }; @@ -861,7 +863,7 @@ 6C0F3A3C2A1D0D5000223D19 /* CodeEditKit in Frameworks */, 6C5BE5222A3D5666002DA0FC /* WindowManagement in Frameworks */, 6C66C31329D05CDC00DE9ED2 /* GRDB in Frameworks */, - 6CD601B52A420E0900E8C324 /* CodeEditTextView in Frameworks */, + 6C092EC62A4E803300489202 /* CodeEditTextView in Frameworks */, 58F2EB1E292FB954004A9BDE /* Sparkle in Frameworks */, 6C2149412A1BB9AB00748382 /* LogStream in Frameworks */, 6C147C4529A329350089B630 /* OrderedCollections in Frameworks */, @@ -1856,6 +1858,7 @@ 5894E59629FEF7740077E59C /* CEWorkspaceFile+Recursion.swift */, 58A2E40629C3975D005CB615 /* CEWorkspaceFileIcon.swift */, 58710158298EB80000951BA4 /* CEWorkspaceFileManager.swift */, + 6C049A362A49E2DB00D42923 /* DirectoryEventStream.swift */, ); path = Models; sourceTree = ""; @@ -2482,7 +2485,7 @@ 6CDEFC9529E22C2700B7C684 /* Introspect */, 6C0F3A3B2A1D0D5000223D19 /* CodeEditKit */, 6C5BE5212A3D5666002DA0FC /* WindowManagement */, - 6CD601B42A420E0900E8C324 /* CodeEditTextView */, + 6C092EC52A4E803300489202 /* CodeEditTextView */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -2578,7 +2581,7 @@ 6CDEFC9429E22C2700B7C684 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 6C0F3A3A2A1D0D5000223D19 /* XCRemoteSwiftPackageReference "CodeEditKit" */, 6C5BE5202A3D5666002DA0FC /* XCRemoteSwiftPackageReference "SwiftUI-WindowManagement" */, - 6CD601B32A420E0900E8C324 /* XCRemoteSwiftPackageReference "CodeEditTextView" */, + 6C092EC42A4E803300489202 /* XCRemoteSwiftPackageReference "CodeEditTextView" */, ); productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; projectDirPath = ""; @@ -2935,6 +2938,7 @@ 286471AB27ED51FD0039369D /* ProjectNavigatorView.swift in Sources */, B6E41C7C29DE2B110088F9F4 /* AccountsSettingsProviderRow.swift in Sources */, B62AEDB52A1FE295009A9F52 /* DebugAreaDebugView.swift in Sources */, + 6C049A372A49E2DB00D42923 /* DirectoryEventStream.swift in Sources */, 6CAAF68A29BC9C2300A1F48A /* (null) in Sources */, 6C6BD6EF29CD12E900235D17 /* ExtensionManagerWindow.swift in Sources */, 6CFF967629BEBCD900182D6F /* FileCommands.swift in Sources */, @@ -3963,6 +3967,14 @@ version = 2.3.0; }; }; + 6C092EC42A4E803300489202 /* XCRemoteSwiftPackageReference "CodeEditTextView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/CodeEditApp/CodeEditTextView"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.6.6; + }; + }; 6C0F3A3A2A1D0D5000223D19 /* XCRemoteSwiftPackageReference "CodeEditKit" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/CodeEditApp/CodeEditKit"; @@ -4011,14 +4023,6 @@ minimumVersion = 0.2.0; }; }; - 6CD601B32A420E0900E8C324 /* XCRemoteSwiftPackageReference "CodeEditTextView" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/CodeEditApp/CodeEditTextView"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.6.6; - }; - }; 6CDEFC9429E22C2700B7C684 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; @@ -4050,6 +4054,11 @@ package = 58F2EB1C292FB954004A9BDE /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 6C092EC52A4E803300489202 /* CodeEditTextView */ = { + isa = XCSwiftPackageProductDependency; + package = 6C092EC42A4E803300489202 /* XCRemoteSwiftPackageReference "CodeEditTextView" */; + productName = CodeEditTextView; + }; 6C0F3A3B2A1D0D5000223D19 /* CodeEditKit */ = { isa = XCSwiftPackageProductDependency; package = 6C0F3A3A2A1D0D5000223D19 /* XCRemoteSwiftPackageReference "CodeEditKit" */; @@ -4094,11 +4103,6 @@ package = 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = DequeModule; }; - 6CD601B42A420E0900E8C324 /* CodeEditTextView */ = { - isa = XCSwiftPackageProductDependency; - package = 6CD601B32A420E0900E8C324 /* XCRemoteSwiftPackageReference "CodeEditTextView" */; - productName = CodeEditTextView; - }; 6CDEFC9529E22C2700B7C684 /* Introspect */ = { isa = XCSwiftPackageProductDependency; package = 6CDEFC9429E22C2700B7C684 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index 7edea75f87..782b1504b0 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift @@ -51,9 +51,6 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, TabBar var fileIdentifier = UUID().uuidString - var watcher: DispatchSourceFileSystemObject? - var watcherCode: ((CEWorkspaceFile) -> Void)? - /// Returns the Git status of a file as ``GitType`` var gitStatus: GitType? @@ -124,33 +121,6 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, TabBar try container.encode(gitStatus, forKey: .changeType) } - func activateWatcher() -> Bool { - guard let watcherCode else { return false } - - let descriptor = open(self.url.path, O_EVTONLY) - guard descriptor > 0 else { return false } - - // create the source - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: descriptor, - eventMask: .write, - queue: DispatchQueue.global() - ) - - if descriptor > 2000 { - print("Watcher \(descriptor) used up on \(url.path)") - } - - source.setEventHandler { watcherCode(self) } - source.setCancelHandler { close(descriptor) } - source.resume() - self.watcher = source - - // TODO: reindex the current item, because the files in the item may have changed - // since the initial load on startup. - return true - } - /// Returns a string describing a SFSymbol for folders /// /// If it is the top-level folder this will return `"square.dashed.inset.filled"`. diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 1a76719448..b3db7c463f 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -8,29 +8,27 @@ import Combine import Foundation +protocol CEWorkspaceFileManagerObserver: AnyObject { + func fileManagerUpdated() +} + /// This class is used to load the files of the machine into a CodeEdit workspace. final class CEWorkspaceFileManager { - enum FileSystemClientError: Error { - case fileNotExist - } - - // TODO: See if this needs to be removed, it isn't used anymore - private var subject = CurrentValueSubject<[CEWorkspaceFile], Never>([]) - private var isRunning = false - private var anotherInstanceRan = 0 + private var lock: NSLock = NSLock() private(set) var fileManager = FileManager.default - private(set) var ignoredFilesAndFolders: [String] + private(set) var ignoredFilesAndFolders: Set private(set) var flattenedFileItems: [String: CEWorkspaceFile] + private var fsEventStream: DirectoryEventStream? - var onRefresh: () -> Void = {} - var getFiles: AnyPublisher<[CEWorkspaceFile], Never> = - CurrentValueSubject<[CEWorkspaceFile], Never>([]).eraseToAnyPublisher() + private var observers: NSHashTable let folderUrl: URL let workspaceItem: CEWorkspaceFile - init(folderUrl: URL, ignoredFilesAndFolders: [String]) { + init(folderUrl: URL, ignoredFilesAndFolders: Set) { + self.observers = NSHashTable.weakObjects() + self.folderUrl = folderUrl self.ignoredFilesAndFolders = ignoredFilesAndFolders @@ -41,7 +39,7 @@ final class CEWorkspaceFileManager { } private func setup() { - // initial load + // initial load, get all files & directories under workspace URL var workspaceFiles: [CEWorkspaceFile] do { workspaceFiles = try loadFiles(fromUrl: self.folderUrl) @@ -49,32 +47,16 @@ final class CEWorkspaceFileManager { fatalError("Failed to loadFiles") } - // workspace fileItem - let workspaceFile = CEWorkspaceFile(url: self.folderUrl, children: workspaceFiles) + fsEventStream = DirectoryEventStream(directory: self.folderUrl.path) { [weak self] path, event, deepRebuild in + self?.fileSystemEventReceived(directory: path, event: event, deepRebuild: deepRebuild) + } + + // Root workspace fileItem + let workspaceFile = CEWorkspaceFile(url: self.folderUrl, children: workspaceFiles.sortItems(foldersOnTop: true)) flattenedFileItems[workspaceFile.id] = workspaceFile workspaceFiles.forEach { item in item.parent = workspaceFile } - - // By using `CurrentValueSubject` we can define a starting value. - // The value passed during init it's going to be send as soon as the - // consumer subscribes to the publisher. - let subject = CurrentValueSubject<[CEWorkspaceFile], Never>(workspaceFiles) - - self.getFiles = subject - .handleEvents(receiveCancel: { - for item in self.flattenedFileItems.values { - item.watcher?.cancel() - item.watcher = nil - } - }) - .receive(on: RunLoop.main) - .eraseToAnyPublisher() - - workspaceFile.watcherCode = { sourceFileItem in - self.reloadFromWatcher(sourceFileItem: sourceFileItem) - } - reloadFromWatcher(sourceFileItem: workspaceFile) } /// Recursive loading of files into `FileItem`s @@ -105,10 +87,6 @@ final class CEWorkspaceFileManager { children: subItems?.sortItems(foldersOnTop: true) ) - // note: watcher code will be applied after the workspaceItem is created - newFileItem.watcherCode = { sourceFileItem in - self.reloadFromWatcher(sourceFileItem: sourceFileItem) - } subItems?.forEach { $0.parent = newFileItem } items.append(newFileItem) flattenedFileItems[newFileItem.id] = newFileItem @@ -120,84 +98,51 @@ final class CEWorkspaceFileManager { /// A function that, given a file's path, returns a `FileItem` if it exists /// within the scope of the `FileSystemClient`. - /// - Parameter id: The file's full path + /// - Parameter path: The file's full path /// - Returns: The file item corresponding to the file - func getFile(_ id: String) throws -> CEWorkspaceFile { - guard let item = flattenedFileItems[id] else { - throw FileSystemClientError.fileNotExist - } - - return item + func getFile(_ path: String) -> CEWorkspaceFile? { + flattenedFileItems[path] } /// Usually run when the owner of the `FileSystemClient` doesn't need it anymore. /// This de-inits most functions in the `FileSystemClient`, so that in case it isn't de-init'd it does not use up /// significant amounts of RAM. func cleanUp() { - stopListeningToDirectory() + fsEventStream?.cancel() workspaceItem.children = [] flattenedFileItems = [workspaceItem.id: workspaceItem] - print("Cleaned up watchers and file items") } - // run by dispatchsource watchers. Multiple instances may be concurrent, - // so we need to be careful to avoid EXC_BAD_ACCESS errors. - /// This is a function run by `DispatchSource` file watchers. Due to the nature of watchers, multiple - /// instances may be running concurrently, so the function prevents more than one instance of it from - /// running the main code body. - /// - Parameter sourceFileItem: The `FileItem` corresponding to the file that triggered the `DispatchSource` - func reloadFromWatcher(sourceFileItem: CEWorkspaceFile) { - // Something has changed inside the directory - // We should reload the files. - guard !isRunning else { // this runs when a file change is detected but is already running - anotherInstanceRan += 1 - return - } - isRunning = true - - // inital reload of files - _ = try? rebuildFiles(fromItem: sourceFileItem) - - // re-reload if another instance tried to run while this instance was running - // TODO: optimise - while anotherInstanceRan > 0 { - let somethingChanged = try? rebuildFiles(fromItem: workspaceItem) - anotherInstanceRan = !(somethingChanged ?? false) ? 0 : anotherInstanceRan - 1 - } - - subject.send(workspaceItem.children ?? []) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.isRunning = false - } - anotherInstanceRan = 0 - - // reload data in outline view controller through the main thread + /// Called by `fsEventStream` when an event occurs. + /// + /// This method can be called by separate threads, though measures are taken to minimize instances where this is + /// called more than once at a time. + /// This will always obtain a lock before modifying any of the file tree. + /// - Parameters: + /// - directory: The directory where the event occurred. + /// - event: The event that occurred. + /// - deepRebuild: Whether or not the directory needs to be recursively rebuilt. + private func fileSystemEventReceived(directory: String, event: FSEvent, deepRebuild: Bool) { DispatchQueue.main.async { - self.onRefresh() - } - } - - /// A function to kill the watcher of a specific directory, or all directories. - /// - Parameter directory: The directory to stop watching, or nil to stop watching everything. - func stopListeningToDirectory(directory: URL? = nil) { - if directory != nil { - flattenedFileItems[directory!.relativePath]?.watcher?.cancel() - } else { - for item in flattenedFileItems.values { - item.watcher?.cancel() - item.watcher = nil + var directory = directory + if directory.last == "/" { + directory.removeLast() + } + guard let item = self.getFile(directory) else { + return } + try? self.rebuildFiles(fromItem: item) + self.notifyObservers() } } - /// Recursive function similar to `loadFiles`, but creates or deletes children of the + /// Similar to `loadFiles`, but creates or deletes children of the /// `FileItem` so that they are accurate with the file system, instead of creating an - /// entirely new `FileItem`, to prevent the `OutlineView` from going crazy with folding. - /// - Parameter fileItem: The `FileItem` to correct the children of - @discardableResult - func rebuildFiles(fromItem fileItem: CEWorkspaceFile) throws -> Bool { - var didChangeSomething = false - + /// entirely new `FileItem`. Can optionally run a deep rebuild. + /// - Parameters: + /// - fileItem: The `FileItem` to correct the children of + /// - deep: Set to `true` if this should perform the rebuild recursively. + func rebuildFiles(fromItem fileItem: CEWorkspaceFile, deep: Bool = false) throws { // get the actual directory children let directoryContentsUrls = try fileManager.contentsOfDirectory( at: fileItem.url.resolvingSymlinksInPath(), @@ -207,10 +152,8 @@ final class CEWorkspaceFileManager { // test for deleted children, and remove them from the index for oldContent in fileItem.children ?? [] where !directoryContentsUrls.contains(oldContent.url) { if let removeAt = fileItem.children?.firstIndex(of: oldContent) { - fileItem.children?[removeAt].watcher?.cancel() fileItem.children?.remove(at: removeAt) flattenedFileItems.removeValue(forKey: oldContent.id) - didChangeSomething = true } } @@ -232,29 +175,44 @@ final class CEWorkspaceFileManager { children: subItems?.sortItems(foldersOnTop: true) ) - newFileItem.watcherCode = { sourceFileItem in - self.reloadFromWatcher(sourceFileItem: sourceFileItem) - } - subItems?.forEach { $0.parent = newFileItem } newFileItem.parent = fileItem flattenedFileItems[newFileItem.id] = newFileItem fileItem.children?.append(newFileItem) - didChangeSomething = true } } fileItem.children = fileItem.children?.sortItems(foldersOnTop: true) fileItem.children?.forEach({ if $0.isFolder { - let childChanged = try? rebuildFiles(fromItem: $0) - didChangeSomething = (childChanged ?? false) ? true : didChangeSomething + try? rebuildFiles(fromItem: $0, deep: deep) + } + if deep { + flattenedFileItems[$0.id] = $0 } - flattenedFileItems[$0.id] = $0 }) + } - return didChangeSomething + func notifyObservers() { + observers.allObjects.reversed().forEach { delegate in + guard let delegate = delegate as? CEWorkspaceFileManagerObserver else { + observers.remove(delegate) + return + } + delegate.fileManagerUpdated() + } } + func addObserver(_ observer: CEWorkspaceFileManagerObserver) { + observers.add(observer as AnyObject) + } + + func removeObserver(_ observer: CEWorkspaceFileManagerObserver) { + observers.remove(observer as AnyObject) + } + + deinit { + observers.removeAllObjects() + } } diff --git a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift new file mode 100644 index 0000000000..ca75f2499c --- /dev/null +++ b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift @@ -0,0 +1,123 @@ +// +// DirectoryEventStream.swift +// CodeEdit +// +// Created by Khan Winter on 6/26/23. +// + +import Foundation + +enum FSEvent { + case changeInDirectory + case rootChanged + case itemChangedOwner + case itemCreated + case itemCloned + case itemModified + case itemRemoved + case itemRenamed +} + +class DirectoryEventStream { + typealias EventCallback = (String, FSEvent, Bool) -> Void + + private var streamRef: FSEventStreamRef? + private var callback: EventCallback + private let debounceDuration: TimeInterval = 0.05 + + init(directory: String, callback: @escaping EventCallback) { + self.callback = callback + let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + + var context = FSEventStreamContext( + version: 0, + info: selfPtr, + retain: nil, + release: nil, + copyDescription: nil + ) + let contextPtr = withUnsafeMutablePointer(to: &context) { ptr in UnsafeMutablePointer(ptr) } + + let cfDirectory = directory as CFString + let pathsToWatch = [cfDirectory] as CFArray + + if let ref = FSEventStreamCreate( + kCFAllocatorDefault, + // swiflint:ignore:next opening_brace + { streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIds in + guard let clientCallBackInfo else { return } + Unmanaged + .fromOpaque(clientCallBackInfo) + .takeUnretainedValue() + .eventStreamHandler(streamRef, numEvents, eventPaths, eventFlags, eventIds) + }, + contextPtr, + pathsToWatch, + UInt64(kFSEventStreamEventIdSinceNow), + debounceDuration, + UInt32( + kFSEventStreamCreateFlagNoDefer + & kFSEventStreamCreateFlagWatchRoot + ) + ) { + self.streamRef = ref + FSEventStreamSetDispatchQueue(ref, DispatchQueue(label: "com.CodeEdit.app.fseventsqueue", qos: .default)) + FSEventStreamStart(ref) + } + } + + deinit { + streamRef = nil + } + + /// Cancels the fs events watcher. + /// This class will have to be re-initialized to begin streaming events again. + public func cancel() { + streamRef = nil + } + + private func eventStreamHandler( + _ streamRef: ConstFSEventStreamRef, + _ numEvents: Int, + _ eventPaths: UnsafeMutableRawPointer, + _ eventFlags: UnsafePointer, + _ eventIds: UnsafePointer + ) { + var eventPaths = eventPaths.bindMemory(to: UnsafePointer.self, capacity: numEvents) + for idx in 0.. 0 ? true : false // Deep scan? + ) + } + } + + func getEventFromFlags(_ raw: FSEventStreamEventFlags) -> FSEvent? { + if raw == 0 { + return .changeInDirectory + } else if raw & UInt32(kFSEventStreamEventFlagRootChanged) > 0 { + return .rootChanged + } else if raw & UInt32(kFSEventStreamEventFlagItemChangeOwner) > 0 { + return .itemChangedOwner + } else if raw & UInt32(kFSEventStreamEventFlagItemCreated) > 0 { + return .itemCreated + } else if raw & UInt32(kFSEventStreamEventFlagItemCloned) > 0 { + return .itemCloned + } else if raw & UInt32(kFSEventStreamEventFlagItemModified) > 0 { + return .itemModified + } else if raw & UInt32(kFSEventStreamEventFlagItemRemoved) > 0 { + return .itemRemoved + } else if raw & UInt32(kFSEventStreamEventFlagItemRenamed) > 0 { + return .itemRenamed + } else { + return nil + } + } +} diff --git a/CodeEdit/Features/Documents/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument.swift index 3233d57fbd..79ec7c6d09 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument.swift @@ -32,7 +32,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { } public var filter: String = "" { - didSet { workspaceFileManager?.onRefresh() } + didSet { workspaceFileManager?.notifyObservers() } } var debugAreaModel = DebugAreaViewModel() @@ -118,14 +118,9 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { // MARK: Set Up Workspace private func initWorkspaceState(_ url: URL) throws { -// self.workspaceClient = try .default( -// fileManager: .default, -// folderURL: url, -// ignoredFilesAndFolders: ignoredFilesAndDirectory -// ) self.workspaceFileManager = .init( folderUrl: url, - ignoredFilesAndFolders: ignoredFilesAndDirectory + ignoredFilesAndFolders: Set(ignoredFilesAndDirectory) ) self.searchState = .init(self) self.quickOpenViewModel = .init(fileURL: url) diff --git a/CodeEdit/Features/NavigatorSidebar/OutlineView/FileSystemTableViewCell.swift b/CodeEdit/Features/NavigatorSidebar/OutlineView/FileSystemTableViewCell.swift index de9cfa58c4..7bdfe5f3bf 100644 --- a/CodeEdit/Features/NavigatorSidebar/OutlineView/FileSystemTableViewCell.swift +++ b/CodeEdit/Features/NavigatorSidebar/OutlineView/FileSystemTableViewCell.swift @@ -37,15 +37,7 @@ class FileSystemTableViewCell: StandardTableViewCell { } func addIcon(item: CEWorkspaceFile) { - var imageName = item.systemImage - if item.watcherCode == nil { - imageName = "exclamationmark.arrow.triangle.2.circlepath" - } - if item.watcher == nil && !item.activateWatcher() { - // watcher failed to activate - imageName = "eye.trianglebadge.exclamationmark" - } - let image = NSImage(systemSymbolName: imageName, accessibilityDescription: nil)! + let image = NSImage(systemSymbolName: item.systemImage, accessibilityDescription: nil)! fileItem = item icon.image = image icon.contentTintColor = color(for: item) diff --git a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index 9b5d6a93c5..b5cc585ef9 100644 --- a/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorSidebar/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -21,10 +21,7 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { let controller = ProjectNavigatorViewController() controller.workspace = workspace controller.iconColor = prefs.preferences.general.fileIconStyle - workspace.workspaceFileManager?.onRefresh = { - controller.outlineView.reloadData() - controller.updateSelection(itemID: workspace.tabManager.activeTabGroup.selected?.id) - } + workspace.workspaceFileManager?.addObserver(context.coordinator) context.coordinator.controller = controller @@ -46,7 +43,7 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { Coordinator(workspace) } - class Coordinator: NSObject { + class Coordinator: NSObject, CEWorkspaceFileManagerObserver { init(_ workspace: WorkspaceDocument) { self.workspace = workspace super.init() @@ -70,5 +67,13 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { var workspace: WorkspaceDocument var controller: ProjectNavigatorViewController? + func fileManagerUpdated() { + controller?.outlineView.reloadData() + controller?.updateSelection(itemID: workspace.tabManager.activeTabGroup.selected?.id) + } + + deinit { + workspace.workspaceFileManager?.removeObserver(self) + } } } From a662760f2758addbc96f6ba8415124caeee10557 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 30 Jun 2023 15:59:41 -0500 Subject: [PATCH 2/3] Remove unnecessary lock --- .../CEWorkspace/Models/CEWorkspaceFileManager.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index b3db7c463f..803277db8d 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -14,8 +14,6 @@ protocol CEWorkspaceFileManagerObserver: AnyObject { /// This class is used to load the files of the machine into a CodeEdit workspace. final class CEWorkspaceFileManager { - private var lock: NSLock = NSLock() - private(set) var fileManager = FileManager.default private(set) var ignoredFilesAndFolders: Set private(set) var flattenedFileItems: [String: CEWorkspaceFile] @@ -115,9 +113,8 @@ final class CEWorkspaceFileManager { /// Called by `fsEventStream` when an event occurs. /// - /// This method can be called by separate threads, though measures are taken to minimize instances where this is - /// called more than once at a time. - /// This will always obtain a lock before modifying any of the file tree. + /// This method may be called on a background thread, but all work done by this function will be queued on the main + /// thread. /// - Parameters: /// - directory: The directory where the event occurred. /// - event: The event that occurred. From cb4a2aabfc0dfe2e6a836898a216f32d5d1268f4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 30 Jun 2023 21:12:34 -0500 Subject: [PATCH 3/3] Fix bugs, docs, tests --- .../Models/CEWorkspaceFileManager.swift | 6 +- .../Models/DirectoryEventStream.swift | 42 ++++++++- .../WorkspaceClientTests.swift | 89 ++++++++----------- 3 files changed, 77 insertions(+), 60 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 803277db8d..670019662f 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -182,12 +182,10 @@ final class CEWorkspaceFileManager { fileItem.children = fileItem.children?.sortItems(foldersOnTop: true) fileItem.children?.forEach({ - if $0.isFolder { + if deep && $0.isFolder { try? rebuildFiles(fromItem: $0, deep: deep) } - if deep { - flattenedFileItems[$0.id] = $0 - } + flattenedFileItems[$0.id] = $0 }) } diff --git a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift index ca75f2499c..e6fbbb8881 100644 --- a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift +++ b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift @@ -18,14 +18,32 @@ enum FSEvent { case itemRenamed } +/// Creates a stream of events using the File System Events API. +/// +/// The stream of events is started immediately upon initialization, and will only be stopped when either `cancel` +/// is called, or the object is deallocated. The stream is also configured to debounce notifications to happen +/// according to the `debounceDuration` parameter. This directly corresponds with the `latency` parameter in +/// `FSEventStreamCreate`, which will delay notifications until `latency` has passed at which point it will send all +/// the notifications built up during that period of time. +/// +/// Use the `callback` parameter to listen for notifications. +/// Notifications are automatically filtered to include certain events, but the FS event API doesn't always correctly +/// flag events so use caution when handling events as they can come frequently. class DirectoryEventStream { typealias EventCallback = (String, FSEvent, Bool) -> Void private var streamRef: FSEventStreamRef? private var callback: EventCallback - private let debounceDuration: TimeInterval = 0.05 + private let debounceDuration: TimeInterval - init(directory: String, callback: @escaping EventCallback) { + /// Initialize the event stream and begin listening for events. + /// - Parameters: + /// - directory: The directory to monitor. The listener may receive a `FSEvent.rootChanged` event if this + /// directory is modified or moved. + /// - debounceDuration: The duration to delay notifications for to let the FS events API accumulates events. + /// - callback: A callback provided that `DirectoryEventStream` will send events to. + init(directory: String, debounceDuration: TimeInterval = 0.05, callback: @escaping EventCallback) { + self.debounceDuration = debounceDuration self.callback = callback let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) @@ -61,18 +79,28 @@ class DirectoryEventStream { ) ) { self.streamRef = ref - FSEventStreamSetDispatchQueue(ref, DispatchQueue(label: "com.CodeEdit.app.fseventsqueue", qos: .default)) + FSEventStreamSetDispatchQueue(ref, DispatchQueue.global(qos: .default)) FSEventStreamStart(ref) } } deinit { + if let streamRef { + FSEventStreamStop(streamRef) + FSEventStreamInvalidate(streamRef) + FSEventStreamRelease(streamRef) + } streamRef = nil } - /// Cancels the fs events watcher. + /// Cancels the events watcher. /// This class will have to be re-initialized to begin streaming events again. public func cancel() { + if let streamRef { + FSEventStreamStop(streamRef) + FSEventStreamInvalidate(streamRef) + FSEventStreamRelease(streamRef) + } streamRef = nil } @@ -99,6 +127,12 @@ class DirectoryEventStream { } } + /// Parses an `FSEvent` from the raw flag value. + /// + /// This more often than not returns `.changeInDirectory` as `FSEventStream` more often than not + /// returns `kFSEventStreamEventFlagNone (0x00000000)`. + /// - Parameter raw: The int value received from the FSEventStream + /// - Returns: An FSEvent if a valid one was found. func getEventFromFlags(_ raw: FSEventStreamEventFlags) -> FSEvent? { if raw == 0 { return .changeInDirectory diff --git a/CodeEditTests/Utils/WorkspaceClient/WorkspaceClientTests.swift b/CodeEditTests/Utils/WorkspaceClient/WorkspaceClientTests.swift index 0cd27fa6f2..3a1813dbea 100644 --- a/CodeEditTests/Utils/WorkspaceClient/WorkspaceClientTests.swift +++ b/CodeEditTests/Utils/WorkspaceClient/WorkspaceClientTests.swift @@ -11,21 +11,38 @@ import XCTest final class WorkspaceClientUnitTests: XCTestCase { let typeOfExtensions = ["json", "txt", "swift", "js", "py", "md"] + var directory: URL! - func testListFile() throws { - let directory = try FileManager.default.url( + class DummyObserver: CEWorkspaceFileManagerObserver { + var completion: (() -> Void)? + + init(completion: @escaping () -> Void) { + self.completion = completion + } + + func fileManagerUpdated() { + completion?() + } + } + + override func setUp() async throws { + directory = try FileManager.default.url( for: .developerApplicationDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) - .appendingPathComponent("CodeEdit", isDirectory: true) - .appendingPathComponent("WorkspaceClientTests", isDirectory: true) + .appendingPathComponent("CodeEdit", isDirectory: true) + .appendingPathComponent("WorkspaceClientTests", isDirectory: true) try? FileManager.default.removeItem(at: directory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } - var cancellable: AnyCancellable? - let expectation = expectation(description: "wait for files") + override func tearDown() async throws { + try? FileManager.default.removeItem(at: directory) + } + + func testListFile() throws { let randomCount = Int.random(in: 1 ... 100) let files = generateRandomFiles(amount: randomCount) try files.forEach { @@ -39,63 +56,30 @@ final class WorkspaceClientUnitTests: XCTestCase { ignoredFilesAndFolders: [] ) - var newFiles: [CEWorkspaceFile] = [] - - cancellable = client - .getFiles - .sink { files in - newFiles = files - expectation.fulfill() - } - - waitForExpectations(timeout: 0.5) - - XCTAssertEqual(files.count, newFiles.count) + // Compare to flattened files - 1 cause root is in there + XCTAssertEqual(files.count, client.flattenedFileItems.count - 1) try FileManager.default.removeItem(at: directory) - cancellable?.cancel() } func testDirectoryChanges() throws { - let directory = try FileManager.default.url( - for: .developerApplicationDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appendingPathComponent("CodeEdit", isDirectory: true) - .appendingPathComponent("WorkspaceClientTests", isDirectory: true) - try? FileManager.default.removeItem(at: directory) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - - var cancellable: AnyCancellable? - let expectation = XCTestExpectation(description: "wait for files") - - let randomCount = Int.random(in: 1 ... 100) - var files = generateRandomFiles(amount: randomCount) - try files.forEach { - let fakeData = "fake string".data(using: .utf8) - let fileUrl = directory - .appendingPathComponent($0) - try fakeData!.write(to: fileUrl) - } - let client = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [] ) - var newFiles: [CEWorkspaceFile] = [] + let newFile = generateRandomFiles(amount: 1)[0] + let expectation = XCTestExpectation(description: "wait for files") - cancellable = client - .getFiles - .sink { files in - newFiles = files + let observer = DummyObserver { + let url = client.folderUrl.appending(path: newFile).path + if client.flattenedFileItems[url] != nil { expectation.fulfill() } - wait(for: [expectation], timeout: 0.5) + } + client.addObserver(observer) - let nextBatchOfFiles = generateRandomFiles(amount: 1) - files.append(contentsOf: nextBatchOfFiles) + var files = client.flattenedFileItems.map { $0.value.name } + files.append(newFile) try files.forEach { let fakeData = "fake string".data(using: .utf8) let fileUrl = directory @@ -103,9 +87,10 @@ final class WorkspaceClientUnitTests: XCTestCase { try fakeData!.write(to: fileUrl) } - XCTAssertEqual(files.count, newFiles.count + 1) + wait(for: [expectation]) + XCTAssertEqual(files.count, client.flattenedFileItems.count - 1) try FileManager.default.removeItem(at: directory) - cancellable?.cancel() + client.removeObserver(observer) } func generateRandomFiles(amount: Int) -> [String] {