From 67711df0307933a01bdf054d9aadfa44967efae5 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 27 Jun 2025 11:22:24 -0500 Subject: [PATCH 01/14] Reflect External File Changes --- .../CodeFileDocument/CodeFileDocument.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 61b32428f..6b71fd771 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -164,6 +164,11 @@ final class CodeFileDocument: NSDocument, ObservableObject { if let validEncoding = FileEncoding(rawEncoding), let nsString { self.sourceEncoding = validEncoding self.content = NSTextStorage(string: nsString as String) + if let content { + content.mutableString.setString(nsString as String) + } else { + self.content = NSTextStorage(string: nsString as String) + } } else { Self.logger.error("Failed to read file from data using encoding: \(rawEncoding)") } @@ -217,6 +222,32 @@ final class CodeFileDocument: NSDocument, ObservableObject { } } + // MARK: - External Changes + + /// Handle the notification that the represented file item changed. + override func presentedItemDidChange() { + // Grab the file saved date + if fileModificationDate != getModificationDate() { + Self.logger.debug("Detected outside change to file") + guard isDocumentEdited else { + fileModificationDate = getModificationDate() + if let fileURL, let fileType { + DispatchQueue.main.asyncAndWait { + try? self.read(from: fileURL, ofType: fileType) + } + } + return + } + } + + super.presentedItemDidChange() + } + + private func getModificationDate() -> Date? { + guard let path = fileURL?.absolutePath else { return nil } + return try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date + } + // MARK: - Close override func close() { From b0c86c8ce6c928695aa053f890bab089ad9cb9e5 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 27 Jun 2025 11:22:40 -0500 Subject: [PATCH 02/14] Don't Set Content --- .../Features/Documents/CodeFileDocument/CodeFileDocument.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 6b71fd771..f7fa21987 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -163,7 +163,6 @@ final class CodeFileDocument: NSDocument, ObservableObject { ) if let validEncoding = FileEncoding(rawEncoding), let nsString { self.sourceEncoding = validEncoding - self.content = NSTextStorage(string: nsString as String) if let content { content.mutableString.setString(nsString as String) } else { From 1e084bf699acd0e02e1f8381f8efeca8627d2727 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Jul 2025 10:58:29 -0500 Subject: [PATCH 03/14] Remove Logging --- .../Features/Documents/CodeFileDocument/CodeFileDocument.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index f7fa21987..a60981070 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -227,7 +227,6 @@ final class CodeFileDocument: NSDocument, ObservableObject { override func presentedItemDidChange() { // Grab the file saved date if fileModificationDate != getModificationDate() { - Self.logger.debug("Detected outside change to file") guard isDocumentEdited else { fileModificationDate = getModificationDate() if let fileURL, let fileType { From a229e281f0323e8923e81e1286d991adb98ca279 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:15:32 -0500 Subject: [PATCH 04/14] Document Changes --- .../CodeFileDocument/CodeFileDocument.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index a60981070..59389cbf4 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -224,12 +224,20 @@ final class CodeFileDocument: NSDocument, ObservableObject { // MARK: - External Changes /// Handle the notification that the represented file item changed. + /// + /// We check if a file has been modified and can be read again to display to the user. + /// To determine if a file has changed, we check the modification date. If it's different from the stored one, + /// we continue. + /// To determine if we can reload the file, we check if the document has outstanding edits. If not, we reload the + /// file. override func presentedItemDidChange() { - // Grab the file saved date if fileModificationDate != getModificationDate() { guard isDocumentEdited else { fileModificationDate = getModificationDate() if let fileURL, let fileType { + // This blocks the presented item thread intentionally. If we don't wait, we'll receive more updates + // that the file has changed and we'll end up dispatching multiple reads. + // The presented item thread expects this operation to by synchronous anyways. DispatchQueue.main.asyncAndWait { try? self.read(from: fileURL, ofType: fileType) } @@ -241,6 +249,10 @@ final class CodeFileDocument: NSDocument, ObservableObject { super.presentedItemDidChange() } + /// Helper to find the last modified date of the represented file item. + /// + /// Different from `NSDocument.fileModificationDate`. This returns the *current* modification date, whereas the + /// alternative stores the date that existed when we last read the file. private func getModificationDate() -> Date? { guard let path = fileURL?.absolutePath else { return nil } return try? FileManager.default.attributesOfItem(atPath: path)[.modificationDate] as? Date From bc9d74bba452913559e039cc2e04e50171d8d6ee Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:49:31 -0500 Subject: [PATCH 05/14] Add Tests! --- .../CodeFileDocument+UTTypeTests.swift | 0 .../CodeFile/CodeFileDocumentTests.swift | 103 ++++++++++++++++++ .../Features/CodeFile/CodeFileTests.swift | 63 ----------- CodeEditTests/Utils/withTempDir.swift | 48 ++++++++ 4 files changed, 151 insertions(+), 63 deletions(-) rename CodeEditTests/Features/{Documents => CodeFile}/CodeFileDocument+UTTypeTests.swift (100%) create mode 100644 CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift delete mode 100644 CodeEditTests/Features/CodeFile/CodeFileTests.swift create mode 100644 CodeEditTests/Utils/withTempDir.swift diff --git a/CodeEditTests/Features/Documents/CodeFileDocument+UTTypeTests.swift b/CodeEditTests/Features/CodeFile/CodeFileDocument+UTTypeTests.swift similarity index 100% rename from CodeEditTests/Features/Documents/CodeFileDocument+UTTypeTests.swift rename to CodeEditTests/Features/CodeFile/CodeFileDocument+UTTypeTests.swift diff --git a/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift b/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift new file mode 100644 index 000000000..449bd6edb --- /dev/null +++ b/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift @@ -0,0 +1,103 @@ +// +// CodeFileDocumentTests.swift +// CodeEditModules/CodeFileTests +// +// Created by Marco Carnevali on 18/03/22. +// + +import Foundation +import SwiftUI +import Testing +@testable import CodeEdit + +@Suite(.serialized) +struct CodeFileDocumentTests { + let defaultString = "func test() { }" + + private func withFile(_ operation: (URL) throws -> Void) throws { + try withTempDir { dir in + let fileURL = dir.appending(path: "file.swift") + try operation(fileURL) + } + } + + private func withCodeFile(_ operation: (CodeFileDocument) throws -> Void) throws { + try withFile { fileURL in + try defaultString.write(to: fileURL, atomically: true, encoding: .utf8) + let codeFile = try CodeFileDocument(contentsOf: fileURL, ofType: "public.source-code") + try operation(codeFile) + } + } + + @Test + func testLoadUTF8Encoding() throws { + try withFile { fileURL in + try defaultString.write(to: fileURL, atomically: true, encoding: .utf8) + let codeFile = try CodeFileDocument( + for: fileURL, + withContentsOf: fileURL, + ofType: "public.source-code" + ) + #expect(codeFile.content?.string == defaultString) + #expect(codeFile.sourceEncoding == .utf8) + } + } + + @Test + func testWriteUTF8Encoding() throws { + try withFile { fileURL in + let codeFile = CodeFileDocument() + codeFile.content = NSTextStorage(string: defaultString) + codeFile.sourceEncoding = .utf8 + try codeFile.write(to: fileURL, ofType: "public.source-code") + + let data = try Data(contentsOf: fileURL) + var nsString: NSString? + let fileEncoding = NSString.stringEncoding( + for: data, + encodingOptions: [ + .suggestedEncodingsKey: FileEncoding.allCases.map { $0.nsValue }, + .useOnlySuggestedEncodingsKey: true + ], + convertedString: &nsString, + usedLossyConversion: nil + ) + + #expect(codeFile.content?.string as NSString? == nsString) + #expect(fileEncoding == NSUTF8StringEncoding) + } + } + + @Test + func ignoresExternalUpdatesWithOutstandingChanges() throws { + try withCodeFile { codeFile in + // Mark the file dirty + codeFile.updateChangeCount(.changeDone) + + // Update the modification date + try "different contents".write(to: codeFile.fileURL!, atomically: true, encoding: .utf8) + + // Tell the file the disk representation changed + codeFile.presentedItemDidChange() + + // The file should not have reloaded + #expect(codeFile.content?.string == defaultString) + #expect(codeFile.isDocumentEdited == true) + } + } + + @Test + func loadsExternalUpdatesWithNoOutstandingChanges() throws { + try withCodeFile { codeFile in + // Update the modification date + try "different contents".write(to: codeFile.fileURL!, atomically: true, encoding: .utf8) + + // Tell the file the disk representation changed + codeFile.presentedItemDidChange() + + // The file should have reloaded (it was clean) + #expect(codeFile.content?.string == "different contents") + #expect(codeFile.isDocumentEdited == false) + } + } +} diff --git a/CodeEditTests/Features/CodeFile/CodeFileTests.swift b/CodeEditTests/Features/CodeFile/CodeFileTests.swift deleted file mode 100644 index 20d747b2f..000000000 --- a/CodeEditTests/Features/CodeFile/CodeFileTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// UnitTests.swift -// CodeEditModules/CodeFileTests -// -// Created by Marco Carnevali on 18/03/22. -// - -import Foundation -import SwiftUI -import XCTest -@testable import CodeEdit - -final class CodeFileUnitTests: XCTestCase { - var fileURL: URL! - - override func setUp() async throws { - let directory = try FileManager.default.url( - for: .developerApplicationDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - .appending(path: "CodeEdit", directoryHint: .isDirectory) - .appending(path: "WorkspaceClientTests", directoryHint: .isDirectory) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - fileURL = directory.appending(path: "fakeFile.swift") - } - - func testLoadUTF8Encoding() throws { - let fileContent = "func test(){}" - - try fileContent.data(using: .utf8)?.write(to: fileURL) - let codeFile = try CodeFileDocument( - for: fileURL, - withContentsOf: fileURL, - ofType: "public.source-code" - ) - XCTAssertEqual(codeFile.content?.string, fileContent) - XCTAssertEqual(codeFile.sourceEncoding, .utf8) - } - - func testWriteUTF8Encoding() throws { - let codeFile = CodeFileDocument() - codeFile.content = NSTextStorage(string: "func test(){}") - codeFile.sourceEncoding = .utf8 - try codeFile.write(to: fileURL, ofType: "public.source-code") - - let data = try Data(contentsOf: fileURL) - var nsString: NSString? - let fileEncoding = NSString.stringEncoding( - for: data, - encodingOptions: [ - .suggestedEncodingsKey: FileEncoding.allCases.map { $0.nsValue }, - .useOnlySuggestedEncodingsKey: true - ], - convertedString: &nsString, - usedLossyConversion: nil - ) - - XCTAssertEqual(codeFile.content?.string as NSString?, nsString) - XCTAssertEqual(fileEncoding, NSUTF8StringEncoding) - } -} diff --git a/CodeEditTests/Utils/withTempDir.swift b/CodeEditTests/Utils/withTempDir.swift new file mode 100644 index 000000000..99a49fee4 --- /dev/null +++ b/CodeEditTests/Utils/withTempDir.swift @@ -0,0 +1,48 @@ +// +// withTempDir.swift +// CodeEditTests +// +// Created by Khan Winter on 7/3/25. +// + +import Foundation + +func withTempDir(_ test: (URL) async throws -> Void) async throws { + let tempDirURL = try createAndClearDir() + do { + try await test(tempDirURL) + } catch { + try clearDir(tempDirURL) + throw error + } + try clearDir(tempDirURL) +} + +func withTempDir(_ test: (URL) throws -> Void) throws { + let tempDirURL = try createAndClearDir() + do { + try test(tempDirURL) + } catch { + try clearDir(tempDirURL) + throw error + } + try clearDir(tempDirURL) +} + +private func createAndClearDir() throws -> URL { + let tempDirURL = FileManager.default.temporaryDirectory + .appending(path: "CodeEditTestDirectory", directoryHint: .isDirectory) + + // If it exists, delete it before the test + try clearDir(tempDirURL) + + try FileManager.default.createDirectory(at: tempDirURL, withIntermediateDirectories: true) + + return tempDirURL +} + +private func clearDir(_ url: URL) throws { + if FileManager.default.fileExists(atPath: url.absoluteURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: url) + } +} From c3ad84377eb812ffad7290eafe79dc225691006f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:54:39 -0500 Subject: [PATCH 06/14] Filter the directory change stream --- ...WorkspaceFileManager+DirectoryEvents.swift | 28 +++++++++---- ...EWorkspaceFileManager+FileManagement.swift | 14 +++++-- .../Models/CEWorkspaceFileManager.swift | 7 ++++ .../Models/DirectoryEventStream.swift | 13 ++++++ .../WorkspaceDocument/WorkspaceDocument.swift | 1 + .../Restoration/UndoManagerRegistration.swift | 41 +++++++++++++++++-- .../SourceControlManager+GitClient.swift | 8 ++-- 7 files changed, 94 insertions(+), 18 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift index 25715b51c..c1bad6f0e 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift @@ -9,6 +9,11 @@ import Foundation /// This extension handles the file system events triggered by changes in the root folder. extension CEWorkspaceFileManager { + struct ResolvedFSEvent: Hashable { + let file: CEWorkspaceFile + let eventType: FSEvent + } + /// Called by `fsEventStream` when an event occurs. /// /// This method may be called on a background thread, but all work done by this function will be queued on the main @@ -16,18 +21,23 @@ extension CEWorkspaceFileManager { /// - Parameter events: An array of events that occurred. func fileSystemEventReceived(events: [DirectoryEventStream.Event]) { DispatchQueue.main.async { - var files: Set = [] + var files: Set = [] for event in events { // Event returns file/folder that was changed, but in tree we need to update it's parent - guard let parentUrl = URL(string: event.path, relativeTo: self.folderUrl)?.deletingLastPathComponent(), - let parentFileItem = self.flattenedFileItems[parentUrl.path] else { + guard let eventFileUrl = URL(string: event.path, relativeTo: self.folderUrl) else { + continue + } + let parentUrl = eventFileUrl.deletingLastPathComponent() + guard let parentFileItem = self.flattenedFileItems[parentUrl.path] else { continue } switch event.eventType { case .changeInDirectory, .itemChangedOwner, .itemModified: - // Can be ignored for now, these I think not related to tree changes continue +// if let fileItem = self.flattenedFileItems[eventFileUrl.path] { +// files.insert(ResolvedFSEvent(file: fileItem, eventType: event.eventType)) +// } case .rootChanged: // TODO: #1880 - Handle workspace root changing. continue @@ -38,7 +48,7 @@ extension CEWorkspaceFileManager { // swiftlint:disable:next line_length self.logger.error("Failed to rebuild files for event: \(event.eventType.rawValue), path: \(event.path, privacy: .sensitive)") } - files.insert(parentFileItem) + files.insert(ResolvedFSEvent(file: parentFileItem, eventType: event.eventType)) } } if !files.isEmpty { @@ -182,13 +192,17 @@ extension CEWorkspaceFileManager { } /// Notify observers that an update occurred in the watched files. - func notifyObservers(updatedItems: Set) { + func notifyObservers(updatedItems: Set) { observers.allObjects.reversed().forEach { delegate in guard let delegate = delegate as? CEWorkspaceFileManagerObserver else { observers.remove(delegate) return } - delegate.fileManagerUpdated(updatedItems: updatedItems) + let eventsFilter = delegate.fileManagerEventsFilter() + let events = updatedItems.compactMap({ eventsFilter.contains($0.eventType) ? $0.file : nil }) + if !events.isEmpty { + delegate.fileManagerUpdated(updatedItems: Set(events)) + } } } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index 82989fbff..dac4be3f4 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -38,7 +38,10 @@ extension CEWorkspaceFileManager { ) try rebuildFiles(fromItem: file.isFolder ? file : file.parent ?? file) - notifyObservers(updatedItems: [file.isFolder ? file : file.parent ?? file]) + notifyObservers(updatedItems: [ResolvedFSEvent( + file: file.isFolder ? file : file.parent ?? file, + eventType: .itemCreated + )]) guard let newFolder = getFile(folderUrl.path(), createIfNotFound: true) else { throw FileManagerError.fileNotFound @@ -103,7 +106,10 @@ extension CEWorkspaceFileManager { } try rebuildFiles(fromItem: file.isFolder ? file : file.parent ?? file) - notifyObservers(updatedItems: [file.isFolder ? file : file.parent ?? file]) + notifyObservers(updatedItems: [ResolvedFSEvent( + file: file.isFolder ? file : file.parent ?? file, + eventType: .itemCreated + )]) // Create if not found here because this should be indexed if we're creating it. // It's not often a user makes a file and then doesn't use it. @@ -284,13 +290,13 @@ extension CEWorkspaceFileManager { if let parent = file.parent { try rebuildFiles(fromItem: parent) - notifyObservers(updatedItems: [parent]) + notifyObservers(updatedItems: [ResolvedFSEvent(file: parent, eventType: .changeInDirectory)]) } // If we have the new parent file, let's rebuild that directory too if let newFileParent = getFile(newLocation.deletingLastPathComponent().path) { try rebuildFiles(fromItem: newFileParent) - notifyObservers(updatedItems: [newFileParent]) + notifyObservers(updatedItems: [ResolvedFSEvent(file: newFileParent, eventType: .changeInDirectory)]) } return getFile(newLocation.absoluteURL.path) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 2e0f824d1..241acb066 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -11,9 +11,16 @@ import AppKit import OSLog protocol CEWorkspaceFileManagerObserver: AnyObject { + func fileManagerEventsFilter() -> Set func fileManagerUpdated(updatedItems: Set) } +extension CEWorkspaceFileManagerObserver { + func fileManagerEventsFilter() -> Set { + FSEvent.all() + } +} + /// This class is used to load, modify, and listen to files on a user's machine. /// /// The workspace file manager provides an API for: diff --git a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift index bfab19f41..0f002a44f 100644 --- a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift +++ b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift @@ -16,6 +16,19 @@ enum FSEvent: String { case itemModified case itemRemoved case itemRenamed + + static func all() -> Set { + [ + .changeInDirectory, + .rootChanged, + .itemChangedOwner, + .itemCreated, + .itemCloned, + .itemModified, + .itemRemoved, + .itemRenamed + ] + } } /// Creates a stream of events using the File System Events API. diff --git a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift index e5aa3251a..a80541afc 100644 --- a/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift +++ b/CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument.swift @@ -164,6 +164,7 @@ final class WorkspaceDocument: NSDocument, ObservableObject, NSToolbarDelegate { ) } + workspaceFileManager?.addObserver(undoRegistration) editorManager?.restoreFromState(self) utilityAreaModel?.restoreFromState(self) } diff --git a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift index 5a244cd0a..af0f7c34e 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift +++ b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift @@ -16,7 +16,7 @@ import CodeEditTextView /// - `CodeFileDocument` is released once there are no editors viewing it. /// Undo stacks need to be retained for the duration of a workspace session, enduring editor closes.. final class UndoManagerRegistration: ObservableObject { - private var managerMap: [CEWorkspaceFile.ID: CEUndoManager] = [:] + private var managerMap: [String: CEUndoManager] = [:] init() { } @@ -24,12 +24,47 @@ final class UndoManagerRegistration: ObservableObject { /// - Parameter file: The file to create for. /// - Returns: The undo manager for the given file. func manager(forFile file: CEWorkspaceFile) -> CEUndoManager { - if let manager = managerMap[file.id] { + manager(forFile: file.url) + } + + /// Find or create a new undo manager. + /// - Parameter path: The path of the file to create for. + /// - Returns: The undo manager for the given file. + func manager(forFile path: URL) -> CEUndoManager { + if let manager = managerMap[path.absolutePath] { return manager } else { let newManager = CEUndoManager() - managerMap[file.id] = newManager + managerMap[path.absolutePath] = newManager return newManager } } + + /// Find or create a new undo manager. + /// - Parameter path: The path of the file to create for. + /// - Returns: The undo manager for the given file. + func managerIfExists(forFile path: URL) -> CEUndoManager? { + managerMap[path.absolutePath] + } +} + +extension UndoManagerRegistration: CEWorkspaceFileManagerObserver { + func fileManagerEventsFilter() -> Set { + [.itemModified, .itemRemoved] + } + + /// Managers need to be cleared when the following is true: + /// - The file is not open in any editors + /// - The file is updated externally + /// + /// To handle this? + /// - When we receive a file update, if the file is not open in any editors we clear the undo stack + func fileManagerUpdated(updatedItems: Set) { + for file in updatedItems where file.fileDocument == nil { + if managerMap[file.url.absolutePath] != nil { + print("Clearing undo stack for file \(file.fileName())") + } + managerMap.removeValue(forKey: file.url.absolutePath) + } + } } diff --git a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift b/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift index fe9af2d7c..eccd2df31 100644 --- a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift +++ b/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift @@ -135,7 +135,7 @@ extension SourceControlManager { return } - var updatedStatusFor: Set = [] + var updatedStatusFor: Set = [] // Refresh status of file manager files for changedFile in changedFiles { guard let file = fileManager.getFile(changedFile.ceFileKey) else { @@ -144,13 +144,13 @@ extension SourceControlManager { if file.gitStatus != changedFile.anyStatus() { file.gitStatus = changedFile.anyStatus() } - updatedStatusFor.insert(file) + updatedStatusFor.insert(.init(file: file, eventType: .itemModified)) } for (_, file) in fileManager.flattenedFileItems - where !updatedStatusFor.contains(file) && file.gitStatus != nil { + where !updatedStatusFor.contains(.init(file: file, eventType: .itemModified)) && file.gitStatus != nil { file.gitStatus = nil - updatedStatusFor.insert(file) + updatedStatusFor.insert(.init(file: file, eventType: .itemModified)) } if updatedStatusFor.isEmpty { From be4be7f7a7667ea34213bb1c9a6f7baacbde0e60 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:55:01 -0500 Subject: [PATCH 07/14] Register Content Reloads with UndoManager --- .../CodeFileDocument/CodeFileDocument.swift | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 59389cbf4..d7ffc8558 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -14,6 +14,7 @@ import CodeEditTextView import CodeEditLanguages import Combine import OSLog +import TextStory enum CodeFileError: Error { case failedToDecode @@ -161,18 +162,38 @@ final class CodeFileDocument: NSDocument, ObservableObject { convertedString: &nsString, usedLossyConversion: nil ) - if let validEncoding = FileEncoding(rawEncoding), let nsString { - self.sourceEncoding = validEncoding - if let content { - content.mutableString.setString(nsString as String) - } else { - self.content = NSTextStorage(string: nsString as String) - } - } else { + guard let validEncoding = FileEncoding(rawEncoding), let nsString else { Self.logger.error("Failed to read file from data using encoding: \(rawEncoding)") + return + } + self.sourceEncoding = validEncoding + if let content { + registerContentChangeUndo(fileURL: fileURL, nsString: nsString, content: content) + content.mutableString.setString(nsString as String) + } else { + self.content = NSTextStorage(string: nsString as String) } NotificationCenter.default.post(name: Self.didOpenNotification, object: self) } + + /// If this file is already open and being tracked by an undo manager, we register an undo mutation + /// of the entire contents. This allows the user to undo changes that occurred outside of CodeEdit + /// while the file was displayed in CodeEdit. + /// + /// - Note: This is inefficient memory-wise. We could do a diff of the file and only register the + /// mutations that would recreate the diff. However, that would instead be CPU intensive. + /// Tradeoffs. + private func registerContentChangeUndo(fileURL: URL?, nsString: NSString, content: NSTextStorage) { + guard let fileURL else { return } + // If there's an undo manager, register a mutation replacing the entire contents. + let mutation = TextMutation( + string: nsString as String, + range: NSRange(location: 0, length: content.length), + limit: content.length + ) + let undoManager = self.findWorkspace()?.undoRegistration.managerIfExists(forFile: fileURL) + undoManager?.registerMutation(mutation) + } // MARK: - Autosave From c32ac0bf4139823d4d6e5b4068df2f82cc9d4db3 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:55:30 -0500 Subject: [PATCH 08/14] Centralize File Load, Fix Memory Leak --- .../CEWorkspace/Models/CEWorkspaceFile.swift | 8 +++++ .../Editor/Models/Editor/Editor.swift | 22 ++++++------ .../EditorLayout+StateRestoration.swift | 35 ++++++++++++------- .../EditorTabBarTrailingAccessories.swift | 6 ++-- .../Features/Editor/Views/CodeFileView.swift | 4 +-- 5 files changed, 45 insertions(+), 30 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index b544b6367..ce3a4d7c9 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift @@ -266,6 +266,14 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor return true } + /// Loads the ``fileDocument`` property with a new ``CodeFileDocument`` and registers it with the shared + /// ``CodeEditDocumentController``. + func loadCodeFile() throws { + let codeFile = try CodeFileDocument(contentsOf: resolvedURL, ofType: contentType?.identifier ?? "") + CodeEditDocumentController.shared.addDocument(codeFile) + self.fileDocument = codeFile + } + // MARK: Statics /// The default `FileManager` instance static let fileManager = FileManager.default diff --git a/CodeEdit/Features/Editor/Models/Editor/Editor.swift b/CodeEdit/Features/Editor/Models/Editor/Editor.swift index eebfb1902..0ec276515 100644 --- a/CodeEdit/Features/Editor/Models/Editor/Editor.swift +++ b/CodeEdit/Features/Editor/Models/Editor/Editor.swift @@ -69,12 +69,16 @@ final class Editor: ObservableObject, Identifiable { selectedTab: Tab? = nil, temporaryTab: Tab? = nil, parent: SplitViewData? = nil, - workspace: WorkspaceDocument? = nil, + workspace: WorkspaceDocument? = nil ) { - self.tabs = [] self.parent = parent self.workspace = workspace - files.forEach { openTab(file: $0) } + // If we open the files without a valid workspace, we risk creating a file we lose track of but stays in memory + if workspace != nil { + files.forEach { openTab(file: $0) } + } else { + self.tabs = OrderedSet(files.map { EditorInstance(workspace: workspace, file: $0) }) + } self.selectedTab = selectedTab ?? (files.isEmpty ? nil : Tab(workspace: workspace, file: files.first!)) self.temporaryTab = temporaryTab } @@ -237,18 +241,12 @@ final class Editor: ObservableObject, Identifiable { } private func openFile(item: Tab) throws { - guard item.file.fileDocument == nil else { + // If this isn't attached to a workspace, loading a new NSDocument will cause a loose document we can't close + guard item.file.fileDocument == nil && workspace != nil else { return } - let contentType = item.file.resolvedURL.contentType - let codeFile = try CodeFileDocument( - for: item.file.url, - withContentsOf: item.file.resolvedURL, - ofType: contentType?.identifier ?? "" - ) - item.file.fileDocument = codeFile - CodeEditDocumentController.shared.addDocument(codeFile) + try item.file.loadCodeFile() } /// Check if tab can be closed diff --git a/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift index 88ef12897..b8692f974 100644 --- a/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift +++ b/CodeEdit/Features/Editor/Models/EditorLayout/EditorLayout+StateRestoration.swift @@ -34,7 +34,7 @@ extension EditorManager { return } - fixRestoredEditorLayout(state.groups, workspace: workspace) + try fixRestoredEditorLayout(state.groups, workspace: workspace) self.editorLayout = state.groups self.activeEditor = activeEditor @@ -53,29 +53,29 @@ extension EditorManager { /// - Parameters: /// - group: The tab group to fix. /// - fileManager: The file manager to use to map files. - private func fixRestoredEditorLayout(_ group: EditorLayout, workspace: WorkspaceDocument) { + private func fixRestoredEditorLayout(_ group: EditorLayout, workspace: WorkspaceDocument) throws { switch group { case let .one(data): - fixEditor(data, workspace: workspace) + try fixEditor(data, workspace: workspace) case let .vertical(splitData): - splitData.editorLayouts.forEach { group in - fixRestoredEditorLayout(group, workspace: workspace) + try splitData.editorLayouts.forEach { group in + try fixRestoredEditorLayout(group, workspace: workspace) } case let .horizontal(splitData): - splitData.editorLayouts.forEach { group in - fixRestoredEditorLayout(group, workspace: workspace) + try splitData.editorLayouts.forEach { group in + try fixRestoredEditorLayout(group, workspace: workspace) } } } - private func findEditorLayout(group: EditorLayout, searchFor id: UUID) -> Editor? { + private func findEditorLayout(group: EditorLayout, searchFor id: UUID) throws -> Editor? { switch group { case let .one(data): return data.id == id ? data : nil case let .vertical(splitData): - return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first + return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first case let .horizontal(splitData): - return splitData.editorLayouts.compactMap { findEditorLayout(group: $0, searchFor: id) }.first + return try splitData.editorLayouts.compactMap { try findEditorLayout(group: $0, searchFor: id) }.first } } @@ -87,16 +87,25 @@ extension EditorManager { /// - Parameters: /// - data: The tab group to fix. /// - fileManager: The file manager to use to map files.a - private func fixEditor(_ editor: Editor, workspace: WorkspaceDocument) { + private func fixEditor(_ editor: Editor, workspace: WorkspaceDocument) throws { guard let fileManager = workspace.workspaceFileManager else { return } let resolvedTabs = editor .tabs - .compactMap({ fileManager.getFile($0.file.url.path(), createIfNotFound: true) }) + .compactMap({ fileManager.getFile($0.file.url.path(percentEncoded: false), createIfNotFound: true) }) .map({ EditorInstance(workspace: workspace, file: $0) }) + + for tab in resolvedTabs { + try tab.file.loadCodeFile() + } + editor.workspace = workspace editor.tabs = OrderedSet(resolvedTabs) + if let selectedTab = editor.selectedTab { - if let resolvedFile = fileManager.getFile(selectedTab.file.url.path(), createIfNotFound: true) { + if let resolvedFile = fileManager.getFile( + selectedTab.file.url.path(percentEncoded: false), + createIfNotFound: true + ) { editor.setSelectedTab(resolvedFile) } else { editor.setSelectedTab(nil) diff --git a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift index 64131b569..833385660 100644 --- a/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift +++ b/CodeEdit/Features/Editor/TabBar/Views/EditorTabBarTrailingAccessories.swift @@ -59,9 +59,9 @@ struct EditorTabBarTrailingAccessories: View { Toggle( "Wrap Lines", isOn: Binding( - get: { codeFile.wrapLines ?? wrapLinesToEditorWidth }, - set: { - codeFile.wrapLines = $0 + get: { [weak codeFile] in codeFile?.wrapLines ?? wrapLinesToEditorWidth }, + set: { [weak codeFile] in + codeFile?.wrapLines = $0 } ) ) diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index 31dc997ba..dba54eed7 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -99,8 +99,8 @@ struct CodeFileView: View { codeFile .contentCoordinator .textUpdatePublisher - .sink { _ in - codeFile.updateChangeCount(.changeDone) + .sink { [weak codeFile] _ in + codeFile?.updateChangeCount(.changeDone) } .store(in: &cancellables) } From f14ed34cdcf1d04d320b710b1d876ee1c909cce6 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:55:45 -0500 Subject: [PATCH 09/14] Ensure `CodeFileView` is Reloaded --- CodeEdit/Features/Editor/Views/CodeFileView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/Editor/Views/CodeFileView.swift b/CodeEdit/Features/Editor/Views/CodeFileView.swift index dba54eed7..6320a6127 100644 --- a/CodeEdit/Features/Editor/Views/CodeFileView.swift +++ b/CodeEdit/Features/Editor/Views/CodeFileView.swift @@ -171,7 +171,8 @@ struct CodeFileView: View { undoManager: undoRegistration.manager(forFile: editorInstance.file), coordinators: textViewCoordinators ) - .id(codeFile.fileURL) + // This view needs to refresh when the codefile changes. The file URL is too stable. + .id(ObjectIdentifier(codeFile)) .background { if colorScheme == .dark { EffectView(.underPageBackground) From a5e251051aeac0fa266c53014dd065ddb04ae870 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:56:12 -0500 Subject: [PATCH 10/14] lint:fix --- .../Features/Documents/CodeFileDocument/CodeFileDocument.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index d7ffc8558..bab03a52f 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -175,7 +175,7 @@ final class CodeFileDocument: NSDocument, ObservableObject { } NotificationCenter.default.post(name: Self.didOpenNotification, object: self) } - + /// If this file is already open and being tracked by an undo manager, we register an undo mutation /// of the entire contents. This allows the user to undo changes that occurred outside of CodeEdit /// while the file was displayed in CodeEdit. From 7d5db514319e83e7710bd3b8dda07a4cdfda9618 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:21:09 -0500 Subject: [PATCH 11/14] Revert FS Stream Filtering Changes --- ...WorkspaceFileManager+DirectoryEvents.swift | 27 +++++-------------- ...EWorkspaceFileManager+FileManagement.swift | 14 +++------- .../Models/CEWorkspaceFileManager.swift | 7 ----- .../Models/DirectoryEventStream.swift | 13 --------- .../Restoration/UndoManagerRegistration.swift | 4 --- .../SourceControlManager+GitClient.swift | 8 +++--- 6 files changed, 14 insertions(+), 59 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift index c1bad6f0e..1c604829d 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift @@ -9,11 +9,6 @@ import Foundation /// This extension handles the file system events triggered by changes in the root folder. extension CEWorkspaceFileManager { - struct ResolvedFSEvent: Hashable { - let file: CEWorkspaceFile - let eventType: FSEvent - } - /// Called by `fsEventStream` when an event occurs. /// /// This method may be called on a background thread, but all work done by this function will be queued on the main @@ -21,23 +16,17 @@ extension CEWorkspaceFileManager { /// - Parameter events: An array of events that occurred. func fileSystemEventReceived(events: [DirectoryEventStream.Event]) { DispatchQueue.main.async { - var files: Set = [] + var files: Set = [] for event in events { // Event returns file/folder that was changed, but in tree we need to update it's parent - guard let eventFileUrl = URL(string: event.path, relativeTo: self.folderUrl) else { - continue - } - let parentUrl = eventFileUrl.deletingLastPathComponent() - guard let parentFileItem = self.flattenedFileItems[parentUrl.path] else { + guard let parentUrl = URL(string: event.path, relativeTo: self.folderUrl)?.deletingLastPathComponent(), + let parentFileItem = self.flattenedFileItems[parentUrl.path] else { continue } switch event.eventType { case .changeInDirectory, .itemChangedOwner, .itemModified: continue -// if let fileItem = self.flattenedFileItems[eventFileUrl.path] { -// files.insert(ResolvedFSEvent(file: fileItem, eventType: event.eventType)) -// } case .rootChanged: // TODO: #1880 - Handle workspace root changing. continue @@ -48,7 +37,7 @@ extension CEWorkspaceFileManager { // swiftlint:disable:next line_length self.logger.error("Failed to rebuild files for event: \(event.eventType.rawValue), path: \(event.path, privacy: .sensitive)") } - files.insert(ResolvedFSEvent(file: parentFileItem, eventType: event.eventType)) + files.insert(parentFileItem) } } if !files.isEmpty { @@ -192,17 +181,13 @@ extension CEWorkspaceFileManager { } /// Notify observers that an update occurred in the watched files. - func notifyObservers(updatedItems: Set) { + func notifyObservers(updatedItems: Set) { observers.allObjects.reversed().forEach { delegate in guard let delegate = delegate as? CEWorkspaceFileManagerObserver else { observers.remove(delegate) return } - let eventsFilter = delegate.fileManagerEventsFilter() - let events = updatedItems.compactMap({ eventsFilter.contains($0.eventType) ? $0.file : nil }) - if !events.isEmpty { - delegate.fileManagerUpdated(updatedItems: Set(events)) - } + delegate.fileManagerUpdated(updatedItems: updatedItems) } } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index dac4be3f4..82989fbff 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -38,10 +38,7 @@ extension CEWorkspaceFileManager { ) try rebuildFiles(fromItem: file.isFolder ? file : file.parent ?? file) - notifyObservers(updatedItems: [ResolvedFSEvent( - file: file.isFolder ? file : file.parent ?? file, - eventType: .itemCreated - )]) + notifyObservers(updatedItems: [file.isFolder ? file : file.parent ?? file]) guard let newFolder = getFile(folderUrl.path(), createIfNotFound: true) else { throw FileManagerError.fileNotFound @@ -106,10 +103,7 @@ extension CEWorkspaceFileManager { } try rebuildFiles(fromItem: file.isFolder ? file : file.parent ?? file) - notifyObservers(updatedItems: [ResolvedFSEvent( - file: file.isFolder ? file : file.parent ?? file, - eventType: .itemCreated - )]) + notifyObservers(updatedItems: [file.isFolder ? file : file.parent ?? file]) // Create if not found here because this should be indexed if we're creating it. // It's not often a user makes a file and then doesn't use it. @@ -290,13 +284,13 @@ extension CEWorkspaceFileManager { if let parent = file.parent { try rebuildFiles(fromItem: parent) - notifyObservers(updatedItems: [ResolvedFSEvent(file: parent, eventType: .changeInDirectory)]) + notifyObservers(updatedItems: [parent]) } // If we have the new parent file, let's rebuild that directory too if let newFileParent = getFile(newLocation.deletingLastPathComponent().path) { try rebuildFiles(fromItem: newFileParent) - notifyObservers(updatedItems: [ResolvedFSEvent(file: newFileParent, eventType: .changeInDirectory)]) + notifyObservers(updatedItems: [newFileParent]) } return getFile(newLocation.absoluteURL.path) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 241acb066..2e0f824d1 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -11,16 +11,9 @@ import AppKit import OSLog protocol CEWorkspaceFileManagerObserver: AnyObject { - func fileManagerEventsFilter() -> Set func fileManagerUpdated(updatedItems: Set) } -extension CEWorkspaceFileManagerObserver { - func fileManagerEventsFilter() -> Set { - FSEvent.all() - } -} - /// This class is used to load, modify, and listen to files on a user's machine. /// /// The workspace file manager provides an API for: diff --git a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift index 0f002a44f..bfab19f41 100644 --- a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift +++ b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift @@ -16,19 +16,6 @@ enum FSEvent: String { case itemModified case itemRemoved case itemRenamed - - static func all() -> Set { - [ - .changeInDirectory, - .rootChanged, - .itemChangedOwner, - .itemCreated, - .itemCloned, - .itemModified, - .itemRemoved, - .itemRenamed - ] - } } /// Creates a stream of events using the File System Events API. diff --git a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift index af0f7c34e..bdd990138 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift +++ b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift @@ -49,10 +49,6 @@ final class UndoManagerRegistration: ObservableObject { } extension UndoManagerRegistration: CEWorkspaceFileManagerObserver { - func fileManagerEventsFilter() -> Set { - [.itemModified, .itemRemoved] - } - /// Managers need to be cleared when the following is true: /// - The file is not open in any editors /// - The file is updated externally diff --git a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift b/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift index eccd2df31..fe9af2d7c 100644 --- a/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift +++ b/CodeEdit/Features/SourceControl/SourceControlManager+GitClient.swift @@ -135,7 +135,7 @@ extension SourceControlManager { return } - var updatedStatusFor: Set = [] + var updatedStatusFor: Set = [] // Refresh status of file manager files for changedFile in changedFiles { guard let file = fileManager.getFile(changedFile.ceFileKey) else { @@ -144,13 +144,13 @@ extension SourceControlManager { if file.gitStatus != changedFile.anyStatus() { file.gitStatus = changedFile.anyStatus() } - updatedStatusFor.insert(.init(file: file, eventType: .itemModified)) + updatedStatusFor.insert(file) } for (_, file) in fileManager.flattenedFileItems - where !updatedStatusFor.contains(.init(file: file, eventType: .itemModified)) && file.gitStatus != nil { + where !updatedStatusFor.contains(file) && file.gitStatus != nil { file.gitStatus = nil - updatedStatusFor.insert(.init(file: file, eventType: .itemModified)) + updatedStatusFor.insert(file) } if updatedStatusFor.isEmpty { From 03a53cc6ae5d6ac18f6a801e96031addfe0ca731 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:21:58 -0500 Subject: [PATCH 12/14] Remove Print Statement --- .../Editor/Models/Restoration/UndoManagerRegistration.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift index bdd990138..a665c8991 100644 --- a/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift +++ b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift @@ -57,9 +57,6 @@ extension UndoManagerRegistration: CEWorkspaceFileManagerObserver { /// - When we receive a file update, if the file is not open in any editors we clear the undo stack func fileManagerUpdated(updatedItems: Set) { for file in updatedItems where file.fileDocument == nil { - if managerMap[file.url.absolutePath] != nil { - print("Clearing undo stack for file \(file.fileName())") - } managerMap.removeValue(forKey: file.url.absolutePath) } } From c22960b671131068e31d047fc27db48ad572e576 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:23:47 -0500 Subject: [PATCH 13/14] Revert One Last Change --- .../Models/CEWorkspaceFileManager+DirectoryEvents.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift index 1c604829d..25715b51c 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift @@ -26,6 +26,7 @@ extension CEWorkspaceFileManager { switch event.eventType { case .changeInDirectory, .itemChangedOwner, .itemModified: + // Can be ignored for now, these I think not related to tree changes continue case .rootChanged: // TODO: #1880 - Handle workspace root changing. From 622505b69f506a5644c3afc891b15db948f32ef5 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Jul 2025 11:59:28 -0500 Subject: [PATCH 14/14] Allow Tests using Temp Dir to run in parallel --- .../CodeFile/CodeFileDocumentTests.swift | 2 +- ...ument+SearchState+FindAndReplaceTests.swift | 10 +++++----- .../Editor/EditorStateRestorationTests.swift | 2 +- CodeEditTests/Utils/withTempDir.swift | 18 ++++++++++++++---- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift b/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift index 449bd6edb..b5b8fc040 100644 --- a/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift +++ b/CodeEditTests/Features/CodeFile/CodeFileDocumentTests.swift @@ -10,7 +10,7 @@ import SwiftUI import Testing @testable import CodeEdit -@Suite(.serialized) +@Suite struct CodeFileDocumentTests { let defaultString = "func test() { }" diff --git a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift index 72fd990db..34209faea 100644 --- a/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift +++ b/CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift @@ -8,8 +8,8 @@ import XCTest @testable import CodeEdit -// swiftlint:disable:next type_body_length -final class FindAndReplaceTests: XCTestCase { +@MainActor +final class FindAndReplaceTests: XCTestCase { // swiftlint:disable:this type_body_length private var directory: URL! private var files: [CEWorkspaceFile] = [] private var mockWorkspace: WorkspaceDocument! @@ -34,8 +34,8 @@ final class FindAndReplaceTests: XCTestCase { try? FileManager.default.removeItem(at: directory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - mockWorkspace = try await WorkspaceDocument(for: directory, withContentsOf: directory, ofType: "") - searchState = await mockWorkspace.searchState + mockWorkspace = try WorkspaceDocument(for: directory, withContentsOf: directory, ofType: "") + searchState = mockWorkspace.searchState // Add a few files let folder1 = directory.appending(path: "Folder 2") @@ -64,7 +64,7 @@ final class FindAndReplaceTests: XCTestCase { files[1].parent = folder1File files[2].parent = folder2File - await mockWorkspace.searchState?.addProjectToIndex() + mockWorkspace.searchState?.addProjectToIndex() // NOTE: This is a temporary solution. In the future, a file watcher should track file updates // and trigger an index update. diff --git a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift index b2e7d46be..a97363fc6 100644 --- a/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift +++ b/CodeEditTests/Features/Editor/EditorStateRestorationTests.swift @@ -9,7 +9,7 @@ import Testing import Foundation @testable import CodeEdit -@Suite(.serialized) +@Suite struct EditorStateRestorationTests { @Test func createsDatabase() throws { diff --git a/CodeEditTests/Utils/withTempDir.swift b/CodeEditTests/Utils/withTempDir.swift index 99a49fee4..c6a5af75c 100644 --- a/CodeEditTests/Utils/withTempDir.swift +++ b/CodeEditTests/Utils/withTempDir.swift @@ -6,9 +6,14 @@ // import Foundation +import Testing func withTempDir(_ test: (URL) async throws -> Void) async throws { - let tempDirURL = try createAndClearDir() + guard let currentTest = Test.current else { + #expect(Bool(false)) + return + } + let tempDirURL = try createAndClearDir(file: currentTest.sourceLocation.fileID + currentTest.name) do { try await test(tempDirURL) } catch { @@ -19,7 +24,11 @@ func withTempDir(_ test: (URL) async throws -> Void) async throws { } func withTempDir(_ test: (URL) throws -> Void) throws { - let tempDirURL = try createAndClearDir() + guard let currentTest = Test.current else { + #expect(Bool(false)) + return + } + let tempDirURL = try createAndClearDir(file: currentTest.sourceLocation.fileID + currentTest.name) do { try test(tempDirURL) } catch { @@ -29,9 +38,10 @@ func withTempDir(_ test: (URL) throws -> Void) throws { try clearDir(tempDirURL) } -private func createAndClearDir() throws -> URL { +private func createAndClearDir(file: String) throws -> URL { + let file = file.components(separatedBy: CharacterSet(charactersIn: "/:?%*|\"<>")).joined() let tempDirURL = FileManager.default.temporaryDirectory - .appending(path: "CodeEditTestDirectory", directoryHint: .isDirectory) + .appending(path: "CodeEditTestDirectory" + file, directoryHint: .isDirectory) // If it exists, delete it before the test try clearDir(tempDirURL)