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/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 61b32428f..bab03a52f 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,15 +162,39 @@ final class CodeFileDocument: NSDocument, ObservableObject { convertedString: &nsString, usedLossyConversion: nil ) - if let validEncoding = FileEncoding(rawEncoding), let nsString { - self.sourceEncoding = validEncoding - 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 /// Triggered when change occurred @@ -217,6 +242,43 @@ 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() { + 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) + } + } + return + } + } + + 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 + } + // MARK: - Close override func close() { 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/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/Models/Restoration/UndoManagerRegistration.swift b/CodeEdit/Features/Editor/Models/Restoration/UndoManagerRegistration.swift index 5a244cd0a..a665c8991 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,40 @@ 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 { + /// 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 { + managerMap.removeValue(forKey: file.url.absolutePath) + } + } } 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..6320a6127 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) } @@ -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) 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..b5b8fc040 --- /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 +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/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)