From eef4a8c4096718c5e5813fb707e510ece5a4b018 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:46:33 -0600 Subject: [PATCH 01/10] Improve File Management --- CodeEdit.xcodeproj/project.pbxproj | 4 + ...WorkspaceFileManager+DirectoryEvents.swift | 23 +- .../Models/CEWorkspaceFileManager+Error.swift | 36 +++ ...EWorkspaceFileManager+FileManagement.swift | 205 +++++++++++------- .../Models/CEWorkspaceFileManager.swift | 1 + .../Models/DirectoryEventStream.swift | 2 + .../FileInspector/FileInspectorView.swift | 46 ++-- .../OutlineView/FileSystemTableViewCell.swift | 20 +- .../ProjectNavigatorMenuActions.swift | 82 ++++--- ...ewController+NSOutlineViewDataSource.swift | 10 +- ...troller+OutlineTableViewCellDelegate.swift | 42 ++-- .../ProjectNavigatorToolbarBottom.swift | 16 +- 12 files changed, 313 insertions(+), 174 deletions(-) create mode 100644 CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 87e5d8aff8..d04e01e9e5 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -480,6 +480,7 @@ 6CD26C8A2C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */; }; 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; 6CDA84AD284C1BA000C1CC3A /* EditorTabBarContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */; }; + 6CDAFDDD2D35B2A0002B2D47 /* CEWorkspaceFileManager+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDAFDDC2D35B2A0002B2D47 /* CEWorkspaceFileManager+Error.swift */; }; 6CE21E812C643D8F0031B056 /* CETerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE21E802C643D8F0031B056 /* CETerminalView.swift */; }; 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6CE21E862C650D2C0031B056 /* SwiftTerm */; }; 6CE622692A2A174A0013085C /* InspectorTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE622682A2A174A0013085C /* InspectorTab.swift */; }; @@ -1161,6 +1162,7 @@ 6CD26C802C8F8A4400ADBA38 /* LanguageIdentifier+CodeLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LanguageIdentifier+CodeLanguage.swift"; sourceTree = ""; }; 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LanguageServer+DocumentTests.swift"; sourceTree = ""; }; 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabBarContextMenu.swift; sourceTree = ""; }; + 6CDAFDDC2D35B2A0002B2D47 /* CEWorkspaceFileManager+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CEWorkspaceFileManager+Error.swift"; sourceTree = ""; }; 6CE21E802C643D8F0031B056 /* CETerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CETerminalView.swift; sourceTree = ""; }; 6CE622682A2A174A0013085C /* InspectorTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorTab.swift; sourceTree = ""; }; 6CE6226A2A2A1C730013085C /* UtilityAreaTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilityAreaTab.swift; sourceTree = ""; }; @@ -2425,6 +2427,7 @@ 58A2E40629C3975D005CB615 /* CEWorkspaceFileIcon.swift */, 58710158298EB80000951BA4 /* CEWorkspaceFileManager.swift */, 77EF6C0C2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift */, + 6CDAFDDC2D35B2A0002B2D47 /* CEWorkspaceFileManager+Error.swift */, 6CB52DC82AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift */, 6C049A362A49E2DB00D42923 /* DirectoryEventStream.swift */, ); @@ -4136,6 +4139,7 @@ D7012EE827E757850001E1EF /* FindNavigatorView.swift in Sources */, 58A5DF8029325B5A00D1BD5D /* GitClient.swift in Sources */, D7E201AE27E8B3C000CB86D0 /* String+Ranges.swift in Sources */, + 6CDAFDDD2D35B2A0002B2D47 /* CEWorkspaceFileManager+Error.swift in Sources */, 6CE6226E2A2A1CDE0013085C /* NavigatorTab.swift in Sources */, 041FC6AD2AE437CE00C1F65A /* SourceControlNewBranchView.swift in Sources */, 77A01E432BBC3A2800F0EA38 /* CETask.swift in Sources */, diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift index 3a81f34880..25715b51c2 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift @@ -19,11 +19,10 @@ extension CEWorkspaceFileManager { var files: Set = [] for event in events { // Event returns file/folder that was changed, but in tree we need to update it's parent - let parentUrl = "/" + event.path.split(separator: "/").dropLast().joined(separator: "/") - // Find all folders pointing to the parent's file url. - let fileItems = self.flattenedFileItems.filter({ - $0.value.resolvedURL.path == parentUrl - }).map { $0.value } + 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: @@ -33,15 +32,13 @@ extension CEWorkspaceFileManager { // TODO: #1880 - Handle workspace root changing. continue case .itemCreated, .itemCloned, .itemRemoved, .itemRenamed: - for fileItem in fileItems { - do { - try self.rebuildFiles(fromItem: fileItem) - } catch { - // swiftlint:disable:next line_length - self.logger.error("Failed to rebuild files for event: \(event.eventType.rawValue), path: \(event.path, privacy: .sensitive)") - } - files.insert(fileItem) + do { + try self.rebuildFiles(fromItem: parentFileItem) + } catch { + // 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) } } if !files.isEmpty { diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift new file mode 100644 index 0000000000..f51d4045ac --- /dev/null +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift @@ -0,0 +1,36 @@ +// +// CEWorkspaceFileManager+Error.swift +// CodeEdit +// +// Created by Khan Winter on 1/13/25. +// + +import Foundation + +extension CEWorkspaceFileManager { + enum FileManagerError: LocalizedError { + case fileNotFound + case fileNotIndexed + case originFileNotFound + + var errorDescription: String? { + switch self { + case .fileNotFound: + return "File not found" + case .fileNotIndexed: + return "File not found in CodeEdit" + case .originFileNotFound: + return "Failed to find origin file" + } + } + + var helpAnchor: String? { + switch self { + case .fileNotIndexed: + return "Reopen the workspace to reindex the file system." + case .fileNotFound, .originFileNotFound: + return "The file may have moved during the operation, try again." + } + } + } +} diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index f26dfa5cc1..d3bbbebc92 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -14,7 +14,7 @@ extension CEWorkspaceFileManager { /// - folderName: The name of the new folder /// - file: The file to add the new folder to. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* - func addFolder(folderName: String, toFile file: CEWorkspaceFile) { + func addFolder(folderName: String, toFile file: CEWorkspaceFile) throws { // Check if folder, if it is create folder under self, else create on same level. var folderUrl = ( file.isFolder ? file.url.appendingPathComponent(folderName) @@ -36,7 +36,8 @@ extension CEWorkspaceFileManager { attributes: [:] ) } catch { - fatalError(error.localizedDescription) + logger.error("Failed to create folder: \(error, privacy: .auto)") + throw error } } @@ -48,68 +49,89 @@ extension CEWorkspaceFileManager { /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* /// - Throws: Throws a `CocoaError.fileWriteUnknown` with the file url if creating the file fails, and calls /// ``rebuildFiles(fromItem:deep:)`` which throws other `FileManager` errors. - func addFile(fileName: String, toFile file: CEWorkspaceFile, useExtension: String? = nil) throws { + /// - Returns: The newly created file. + func addFile( + fileName: String, + toFile file: CEWorkspaceFile, + useExtension: String? = nil + ) throws -> CEWorkspaceFile { // check the folder for other files, and see what the most common file extension is - var fileExtension: String - if let useExtension { - fileExtension = useExtension - } else { - var fileExtensions: [String: Int] = ["": 0] - - for child in ( - file.isFolder ? file.flattenedSiblings(withHeight: 2, ignoringFolders: true, using: self) - : file.parent?.flattenedSiblings(withHeight: 2, ignoringFolders: true, using: self) - ) ?? [] - where !child.isFolder { - // if the file extension was present before, add it now - let childFileName = child.fileName(typeHidden: false) - if let index = childFileName.lastIndex(of: ".") { - let childFileExtension = ".\(childFileName.suffix(from: index).dropFirst())" - fileExtensions[childFileExtension] = (fileExtensions[childFileExtension] ?? 0) + 1 - } else { - fileExtensions[""] = (fileExtensions[""] ?? 0) + 1 - } + do { + var fileExtension: String = useExtension ?? findCommonFileExtension(for: file) + + if !fileExtension.starts(with: ".") { + fileExtension = "." + fileExtension } - fileExtension = fileExtensions.sorted(by: { $0.value > $1.value }).first?.key ?? "txt" - } + var fileUrl = file.nearestFolder.appendingPathComponent("\(fileName)\(fileExtension)") + // If a file/folder with the same name exists, add a number to the end. + var fileNumber = 0 + while fileManager.fileExists(atPath: fileUrl.path) { + fileNumber += 1 + fileUrl = fileUrl.deletingLastPathComponent() + .appendingPathComponent("\(fileName)\(fileNumber)\(fileExtension)") + } - if !fileExtension.starts(with: ".") { - fileExtension = "." + fileExtension - } + // Create the file + guard fileManager.createFile( + atPath: fileUrl.path, + contents: nil, + attributes: [FileAttributeKey.creationDate: Date()] + ) else { + throw CocoaError.error(.fileWriteUnknown, url: fileUrl) + } - var fileUrl = file.nearestFolder.appendingPathComponent("\(fileName)\(fileExtension)") - // If a file/folder with the same name exists, add a number to the end. - var fileNumber = 0 - while fileManager.fileExists(atPath: fileUrl.path) { - fileNumber += 1 - fileUrl = fileUrl.deletingLastPathComponent() - .appendingPathComponent("\(fileName)\(fileNumber)\(fileExtension)") + try rebuildFiles(fromItem: file) + notifyObservers(updatedItems: [file]) + + guard let newFile = getFile(fileUrl.path) else { + throw FileManagerError.fileNotIndexed + } + return newFile + } catch { + logger.error("Failed to add file: \(error, privacy: .auto)") + throw error } + } + + /// Finds a common file extension in the same directory as a file. Defaults to `txt` if no better alternatives + /// are found. + /// - Parameter file: The file to use to determine a common extension. + /// - Returns: The suggested file extension. + private func findCommonFileExtension(for file: CEWorkspaceFile) -> String { + var fileExtensions: [String: Int] = ["": 0] - // Create the file - guard fileManager.createFile( - atPath: fileUrl.path, - contents: nil, - attributes: [FileAttributeKey.creationDate: Date()] - ) else { - throw CocoaError.error(.fileWriteUnknown, url: fileUrl) + for child in ( + file.isFolder ? file.flattenedSiblings(withHeight: 2, ignoringFolders: true, using: self) + : file.parent?.flattenedSiblings(withHeight: 2, ignoringFolders: true, using: self) + ) ?? [] + where !child.isFolder { + // if the file extension was present before, add it now + let childFileName = child.fileName(typeHidden: false) + if let index = childFileName.lastIndex(of: ".") { + let childFileExtension = ".\(childFileName.suffix(from: index).dropFirst())" + fileExtensions[childFileExtension] = (fileExtensions[childFileExtension] ?? 0) + 1 + } else { + fileExtensions[""] = (fileExtensions[""] ?? 0) + 1 + } } - try rebuildFiles(fromItem: file) + return fileExtensions.max(by: { $0.value < $1.value })?.key ?? "txt" } /// This function deletes the item or folder from the current project by moving to Trash /// - Parameters: /// - file: The file or folder to delete /// - Authors: Paul Ebose - public func trash(file: CEWorkspaceFile) { - if fileManager.fileExists(atPath: file.url.path) { - do { - try fileManager.trashItem(at: file.url, resultingItemURL: nil) - } catch { - print(error.localizedDescription) - } + public func trash(file: CEWorkspaceFile) throws { + guard fileManager.fileExists(atPath: file.url.path) else { + throw FileManagerError.fileNotFound + } + do { + try fileManager.trashItem(at: file.url, resultingItemURL: nil) + } catch { + logger.error("Failed to trash file: \(error, privacy: .auto)") + throw error } } @@ -118,7 +140,7 @@ extension CEWorkspaceFileManager { /// - file: The file to delete /// - confirmDelete: True to present an alert to confirm the delete. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja., Paul Ebose *Moved from 7c27b1e* - public func delete(file: CEWorkspaceFile, confirmDelete: Bool = true) { + public func delete(file: CEWorkspaceFile, confirmDelete: Bool = true) throws { // This function also has to account for how the // - file system can change outside of the editor let fileName = file.name @@ -132,11 +154,7 @@ extension CEWorkspaceFileManager { deleteConfirmation.addButton(withTitle: "Cancel") if !confirmDelete || deleteConfirmation.runModal() == .alertFirstButtonReturn { // "Delete" button if fileManager.fileExists(atPath: file.url.path) { - do { - try fileManager.removeItem(at: file.url) - } catch { - fatalError(error.localizedDescription) - } + try deleteFile(at: file.url) } } } @@ -145,7 +163,7 @@ extension CEWorkspaceFileManager { /// - Parameters: /// - files: The files to delete /// - confirmDelete: True to present an alert to confirm the delete. - public func batchDelete(files: Set, confirmDelete: Bool = true) { + public func batchDelete(files: Set, confirmDelete: Bool = true) throws { let deleteConfirmation = NSAlert() deleteConfirmation.messageText = "Are you sure you want to delete the \(files.count) selected items?" // swiftlint:disable:next line_length @@ -156,19 +174,27 @@ extension CEWorkspaceFileManager { deleteConfirmation.addButton(withTitle: "Cancel") if !confirmDelete || deleteConfirmation.runModal() == .alertFirstButtonReturn { for file in files where fileManager.fileExists(atPath: file.url.path) { - do { - try fileManager.removeItem(at: file.url) - } catch { - print(error.localizedDescription) - } + try deleteFile(at: file.url) } } } + private func deleteFile(at url: URL) throws { + guard fileManager.fileExists(atPath: url.path) else { + throw FileManagerError.fileNotFound + } + do { + try fileManager.removeItem(at: url) + } catch { + logger.error("Failed to delete file: \(error, privacy: .auto)") + throw error + } + } + /// This function duplicates the item or folder /// - Parameter file: The file to duplicate /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* - public func duplicate(file: CEWorkspaceFile) { + public func duplicate(file: CEWorkspaceFile) throws { // If a file/folder with the same name exists, add "copy" to the end var fileUrl = file.url while fileManager.fileExists(atPath: fileUrl.path) { @@ -183,7 +209,8 @@ extension CEWorkspaceFileManager { do { try fileManager.copyItem(at: file.url, to: fileUrl) } catch { - fatalError(error.localizedDescription) + logger.error("Failed to duplicate file: \(error, privacy: .auto)") + throw error } } } @@ -193,33 +220,48 @@ extension CEWorkspaceFileManager { /// - file: The file to move. /// - newLocation: The destination to move the file to. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* - public func move(file: CEWorkspaceFile, to newLocation: URL) { - guard !fileManager.fileExists(atPath: newLocation.path) else { return } - createMissingParentDirectory(for: newLocation.deletingLastPathComponent()) + /// - Returns: The new file object. + @discardableResult + public func move(file: CEWorkspaceFile, to newLocation: URL) throws -> CEWorkspaceFile { + guard !fileManager.fileExists(atPath: newLocation.path) else { + throw FileManagerError.originFileNotFound + } do { + try createMissingParentDirectory(for: newLocation.deletingLastPathComponent()) + try fileManager.moveItem(at: file.url, to: newLocation) - } catch { fatalError(error.localizedDescription) } - // This function recursively creates missing directories if the file is moved to a directory that does not exist - func createMissingParentDirectory(for url: URL, createSelf: Bool = true) { - // if the folder's parent folder doesn't exist, create it. - if !fileManager.fileExists(atPath: url.deletingLastPathComponent().path) { - createMissingParentDirectory(for: url.deletingLastPathComponent()) - } - // if the folder doesn't exist and the function was ordered to create it, create it. - if createSelf && !fileManager.fileExists(atPath: url.path) { - // Create the folder - do { + // This function recursively creates missing directories if the file is moved to a directory that does + // not exist + func createMissingParentDirectory(for url: URL, createSelf: Bool = true) throws { + // if the folder's parent folder doesn't exist, create it. + if !fileManager.fileExists(atPath: url.deletingLastPathComponent().path) { + try createMissingParentDirectory(for: url.deletingLastPathComponent()) + } + // if the folder doesn't exist and the function was ordered to create it, create it. + if createSelf && !fileManager.fileExists(atPath: url.path) { + // Create the folder try fileManager.createDirectory( at: url, withIntermediateDirectories: true, attributes: [:] ) - } catch { - fatalError(error.localizedDescription) } } + + if let parent = file.parent { + try rebuildFiles(fromItem: parent) + notifyObservers(updatedItems: [parent]) + } + + guard let newFile = getFile(newLocation.absoluteURL.path) else { + throw FileManagerError.fileNotIndexed + } + return newFile + } catch { + logger.error("Failed to move file: \(error, privacy: .auto)") + throw error } } @@ -227,12 +269,13 @@ extension CEWorkspaceFileManager { /// - Parameters: /// - file: The file to copy. /// - newLocation: The location to copy to. - public func copy(file: CEWorkspaceFile, to newLocation: URL) { + public func copy(file: CEWorkspaceFile, to newLocation: URL) throws { guard file.url != newLocation && !fileManager.fileExists(atPath: newLocation.absoluteString) else { return } do { try fileManager.copyItem(at: file.url, to: newLocation) } catch { - fatalError(error.localizedDescription) + logger.error("Failed to copy file: \(error, privacy: .auto)") + throw error } } } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 4a3a97b835..4a53f06679 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -80,6 +80,7 @@ final class CEWorkspaceFileManager { Task { try await self.sourceControlManager?.validate() + await sourceControlManager?.refreshAllChangedFiles() } } diff --git a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift index ff127d01f6..cc125cad46 100644 --- a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift +++ b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift @@ -92,6 +92,8 @@ class DirectoryEventStream { // it is useful when file renamed, because it's firing to separate events with old and new path, // but they can be linked by file id | kFSEventStreamCreateFlagUseExtendedData + // Useful for us, always sends after the debounce duration. + | kFSEventStreamCreateFlagNoDefer ) ) { self.streamRef = ref diff --git a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift index 3707df1e8b..d11f75fbd8 100644 --- a/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift +++ b/CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift @@ -92,15 +92,20 @@ struct FileInspectorView: View { let destinationURL = file.url .deletingLastPathComponent() .appendingPathComponent(fileName) - if !file.isFolder { - editorManager.editorLayout.closeAllTabs(of: file) - } DispatchQueue.main.async { [weak workspace] in - workspace?.workspaceFileManager?.move(file: file, to: destinationURL) - let newItem = CEWorkspaceFile(url: destinationURL) - newItem.parent = file.parent - if !newItem.isFolder { - editorManager.openTab(item: newItem) + do { + if let newItem = try workspace?.workspaceFileManager?.move( + file: file, + to: destinationURL + ), + !newItem.isFolder { + editorManager.editorLayout.closeAllTabs(of: file) + editorManager.openTab(item: newItem) + } + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() } } } else { @@ -134,25 +139,20 @@ struct FileInspectorView: View { guard let newURL = chooseNewFileLocation() else { return } - if !file.isFolder { - editorManager.editorLayout.closeAllTabs(of: file) - } // This is ugly but if the tab is opened at the same time as closing the others, it doesn't open // And if the files are re-built at the same time as the tab is opened, it causes a memory error DispatchQueue.main.async { [weak workspace] in - workspace?.workspaceFileManager?.move(file: file, to: newURL) - // If the parent directory doesn't exist in the workspace, don't open it in a tab. - if let newParent = workspace?.workspaceFileManager?.getFile( - newURL.deletingLastPathComponent().path - ) { - let newItem = CEWorkspaceFile(url: newURL) - newItem.parent = newParent - if !file.isFolder { - editorManager.openTab(item: newItem) - } - DispatchQueue.main.async { [weak workspace] in - _ = try? workspace?.workspaceFileManager?.rebuildFiles(fromItem: newParent) + do { + guard let newItem = try workspace?.workspaceFileManager?.move(file: file, to: newURL), + !newItem.isFolder else { + return } + editorManager.editorLayout.closeAllTabs(of: file) + editorManager.openTab(item: newItem) + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() } } } diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift index 9b07702a4d..6e900ef033 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift @@ -159,12 +159,20 @@ extension FileSystemTableViewCell: NSTextFieldDelegate { func controlTextDidEndEditing(_ obj: Notification) { guard let fileItem else { return } - textField?.backgroundColor = fileItem.validateFileName(for: textField?.stringValue ?? "") ? .none : errorRed - if fileItem.validateFileName(for: textField?.stringValue ?? "") { - let newURL = fileItem.url.deletingLastPathComponent().appendingPathComponent(textField?.stringValue ?? "") - workspace?.workspaceFileManager?.move(file: fileItem, to: newURL) - } else { - textField?.stringValue = fileItem.labelFileName() + do { + textField?.backgroundColor = fileItem.validateFileName(for: textField?.stringValue ?? "") ? .none : errorRed + if fileItem.validateFileName(for: textField?.stringValue ?? "") { + let newURL = fileItem.url + .deletingLastPathComponent() + .appendingPathComponent(textField?.stringValue ?? "") + try workspace?.workspaceFileManager?.move(file: fileItem, to: newURL) + } else { + textField?.stringValue = fileItem.labelFileName() + } + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() } } } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift index 8f469e3290..9e28300734 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift @@ -102,10 +102,16 @@ extension ProjectNavigatorMenu { @objc func newFolder() { guard let item else { return } - workspace?.workspaceFileManager?.addFolder(folderName: "untitled", toFile: item) - reloadData() - sender.outlineView.expandItem(item) - sender.outlineView.expandItem(item.isFolder ? item : item.parent) + do { + try workspace?.workspaceFileManager?.addFolder(folderName: "untitled", toFile: item) + reloadData() + sender.outlineView.expandItem(item) + sender.outlineView.expandItem(item.isFolder ? item : item.parent) + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() + } } /// Creates a new folder with the items selected. @@ -124,8 +130,14 @@ extension ProjectNavigatorMenu { newFolderURL = parent.url.appendingPathComponent("New Folder With Items \(folderNumber)") } - for selectedItem in selectedItems where selectedItem.url != newFolderURL { - workspaceFileManager.move(file: selectedItem, to: newFolderURL.appending(path: selectedItem.name)) + do { + for selectedItem in selectedItems where selectedItem.url != newFolderURL { + try workspaceFileManager.move(file: selectedItem, to: newFolderURL.appending(path: selectedItem.name)) + } + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() } reloadData() @@ -149,43 +161,61 @@ extension ProjectNavigatorMenu { /// Action that moves the item to trash. @objc func trash() { - selectedItems().forEach { item in - workspace?.workspaceFileManager?.trash(file: item) - withAnimation { - sender.editor?.closeTab(file: item) + do { + try selectedItems().forEach { item in + try workspace?.workspaceFileManager?.trash(file: item) + withAnimation { + sender.editor?.closeTab(file: item) + } } + reloadData() + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() } - reloadData() } /// Action that deletes the item immediately. @objc func delete() { - let selectedItems = selectedItems() - if selectedItems.count == 1 { - selectedItems.forEach { item in - workspace?.workspaceFileManager?.delete(file: item) + do { + let selectedItems = selectedItems() + if selectedItems.count == 1 { + try selectedItems.forEach { item in + try workspace?.workspaceFileManager?.delete(file: item) + } + } else { + try workspace?.workspaceFileManager?.batchDelete(files: selectedItems) } - } else { - workspace?.workspaceFileManager?.batchDelete(files: selectedItems) - } - withAnimation { - selectedItems.forEach { item in - sender.editor?.closeTab(file: item) + withAnimation { + selectedItems.forEach { item in + sender.editor?.closeTab(file: item) + } } - } - reloadData() + reloadData() + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() + } } /// Action that duplicates the item @objc func duplicate() { - selectedItems().forEach { item in - workspace?.workspaceFileManager?.duplicate(file: item) + do { + try selectedItems().forEach { item in + try workspace?.workspaceFileManager?.duplicate(file: item) + } + reloadData() + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() } - reloadData() } private func reloadData() { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift index cdf4cd80e7..546a0b6215 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDataSource.swift @@ -15,9 +15,13 @@ extension ProjectNavigatorViewController: NSOutlineViewDataSource { } if let children = workspace?.workspaceFileManager?.childrenOfFile(item) { - let filteredChildren = children.filter { fileSearchMatches(workspace?.navigatorFilter ?? "", for: $0) } - filteredContentChildren[item] = filteredChildren - return filteredChildren + if let filter = workspace?.navigatorFilter, !filter.isEmpty { + let filteredChildren = children.filter { fileSearchMatches(filter, for: $0) } + filteredContentChildren[item] = filteredChildren + return filteredChildren + } + + return children } return [] diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift index 15475dfb86..4e61be7fd3 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift @@ -6,35 +6,37 @@ // import Foundation +import AppKit // MARK: - OutlineTableViewCellDelegate extension ProjectNavigatorViewController: OutlineTableViewCellDelegate { func moveFile(file: CEWorkspaceFile, to destination: URL) { - if !file.isFolder { - workspace?.editorManager?.editorLayout.closeAllTabs(of: file) - } - workspace?.workspaceFileManager?.move(file: file, to: destination) - if let parent = file.parent { - do { - try workspace?.workspaceFileManager?.rebuildFiles(fromItem: parent) - - // Grab the file connected to the rest of the cached file tree. - guard let newFile = workspace?.workspaceFileManager?.getFile( - destination.absoluteURL.path(percentEncoded: false) - ), - !newFile.isFolder else { - return - } - - workspace?.editorManager?.openTab(item: newFile) - } catch { - Self.logger.error("Failed to rebuild file item after moving: \(error)") + do { + guard let newFile = try workspace?.workspaceFileManager?.move(file: file, to: destination), + !newFile.isFolder else { + return } + outlineView.reloadItem(file.parent, reloadChildren: true) + if !file.isFolder { + workspace?.editorManager?.editorLayout.closeAllTabs(of: file) + } + workspace?.editorManager?.openTab(item: newFile) + select(by: file.tabID, forcesReveal: true) + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() } } func copyFile(file: CEWorkspaceFile, to destination: URL) { - workspace?.workspaceFileManager?.copy(file: file, to: destination) + do { + try workspace?.workspaceFileManager?.copy(file: file, to: destination) + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() + } } } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift index 8f61a449b5..ec4177d897 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift @@ -98,7 +98,13 @@ struct ProjectNavigatorToolbarBottom: View { let filePathURL = activeTabURL() guard let rootFile = workspace.workspaceFileManager?.getFile(filePathURL.path) else { return } do { - try workspace.workspaceFileManager?.addFile(fileName: "untitled", toFile: rootFile) + if let newFile = try workspace.workspaceFileManager?.addFile( + fileName: "untitled", + toFile: rootFile + ) { + editorManager.openTab(item: newFile) + NSApp.sendAction(#selector(ProjectNavigatorViewController.revealFile(_:)), to: nil, from: nil) + } } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") @@ -108,7 +114,13 @@ struct ProjectNavigatorToolbarBottom: View { Button("Add Folder") { let filePathURL = activeTabURL() guard let rootFile = workspace.workspaceFileManager?.getFile(filePathURL.path) else { return } - workspace.workspaceFileManager?.addFolder(folderName: "untitled", toFile: rootFile) + do { + try workspace.workspaceFileManager?.addFolder(folderName: "untitled", toFile: rootFile) + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() + } } } label: {} .background { From 553bb8553ae2894d87d2ee33a1e611260aad7d92 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:53:11 -0600 Subject: [PATCH 02/10] Add Some UI Tests, Unit Tests --- CodeEdit.xcodeproj/project.pbxproj | 18 ++- .../CEWorkspace/Models/CEWorkspaceFile.swift | 12 +- .../Models/CEWorkspaceFileManager+Error.swift | 14 +- ...EWorkspaceFileManager+FileManagement.swift | 70 ++++++---- .../OutlineView/FileSystemTableViewCell.swift | 8 -- .../ProjectNavigatorMenuActions.swift | 20 +-- .../ProjectNavigatorOutlineView.swift | 7 + ...ViewController+NSOutlineViewDelegate.swift | 12 +- .../ProjectNavigatorToolbarBottom.swift | 15 ++- .../String/String+ValidFileName.swift | 21 +++ .../CEWorkspaceFileManagerTests.swift | 122 ++++++++++++------ .../Utils/UnitTests_Extensions.swift | 45 +++++++ CodeEditUITests/App.swift | 9 +- .../Tasks/TasksMenuUITests.swift | 2 +- ...rojectNavigatorFileManagementUITests.swift | 100 ++++++++++++++ CodeEditUITests/Query.swift | 11 +- CodeEditUITests/UI TESTING.md | 37 ++++++ 17 files changed, 417 insertions(+), 106 deletions(-) create mode 100644 CodeEdit/Utils/Extensions/String/String+ValidFileName.swift create mode 100644 CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift create mode 100644 CodeEditUITests/UI TESTING.md diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index d04e01e9e5..942de336d9 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -481,6 +481,7 @@ 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; 6CDA84AD284C1BA000C1CC3A /* EditorTabBarContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */; }; 6CDAFDDD2D35B2A0002B2D47 /* CEWorkspaceFileManager+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDAFDDC2D35B2A0002B2D47 /* CEWorkspaceFileManager+Error.swift */; }; + 6CDAFDDF2D35DADD002B2D47 /* String+ValidFileName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDAFDDE2D35DADD002B2D47 /* String+ValidFileName.swift */; }; 6CE21E812C643D8F0031B056 /* CETerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE21E802C643D8F0031B056 /* CETerminalView.swift */; }; 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6CE21E862C650D2C0031B056 /* SwiftTerm */; }; 6CE622692A2A174A0013085C /* InspectorTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CE622682A2A174A0013085C /* InspectorTab.swift */; }; @@ -489,6 +490,8 @@ 6CED16E42A3E660D000EC962 /* String+Lines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CED16E32A3E660D000EC962 /* String+Lines.swift */; }; 6CFBA54B2C4E168A00E3A914 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CFBA54A2C4E168A00E3A914 /* App.swift */; }; 6CFBA54D2C4E16C900E3A914 /* WindowCloseCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CFBA54C2C4E16C900E3A914 /* WindowCloseCommandTests.swift */; }; + 6CFC0C3C2D381D2000F09CD0 /* ProjectNavigatorFileManagementUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CFC0C3B2D381D2000F09CD0 /* ProjectNavigatorFileManagementUITests.swift */; }; + 6CFC0C3E2D382B3F00F09CD0 /* UI TESTING.md in Resources */ = {isa = PBXBuildFile; fileRef = 6CFC0C3D2D382B3900F09CD0 /* UI TESTING.md */; }; 6CFF967429BEBCC300182D6F /* FindCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CFF967329BEBCC300182D6F /* FindCommands.swift */; }; 6CFF967629BEBCD900182D6F /* FileCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CFF967529BEBCD900182D6F /* FileCommands.swift */; }; 6CFF967829BEBCF600182D6F /* MainCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CFF967729BEBCF600182D6F /* MainCommands.swift */; }; @@ -1163,6 +1166,7 @@ 6CD26C892C8F91ED00ADBA38 /* LanguageServer+DocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LanguageServer+DocumentTests.swift"; sourceTree = ""; }; 6CDA84AC284C1BA000C1CC3A /* EditorTabBarContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorTabBarContextMenu.swift; sourceTree = ""; }; 6CDAFDDC2D35B2A0002B2D47 /* CEWorkspaceFileManager+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CEWorkspaceFileManager+Error.swift"; sourceTree = ""; }; + 6CDAFDDE2D35DADD002B2D47 /* String+ValidFileName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+ValidFileName.swift"; sourceTree = ""; }; 6CE21E802C643D8F0031B056 /* CETerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CETerminalView.swift; sourceTree = ""; }; 6CE622682A2A174A0013085C /* InspectorTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorTab.swift; sourceTree = ""; }; 6CE6226A2A2A1C730013085C /* UtilityAreaTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilityAreaTab.swift; sourceTree = ""; }; @@ -1170,6 +1174,8 @@ 6CED16E32A3E660D000EC962 /* String+Lines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Lines.swift"; sourceTree = ""; }; 6CFBA54A2C4E168A00E3A914 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 6CFBA54C2C4E16C900E3A914 /* WindowCloseCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCloseCommandTests.swift; sourceTree = ""; }; + 6CFC0C3B2D381D2000F09CD0 /* ProjectNavigatorFileManagementUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectNavigatorFileManagementUITests.swift; sourceTree = ""; }; + 6CFC0C3D2D382B3900F09CD0 /* UI TESTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "UI TESTING.md"; sourceTree = ""; }; 6CFF967329BEBCC300182D6F /* FindCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindCommands.swift; sourceTree = ""; }; 6CFF967529BEBCD900182D6F /* FileCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCommands.swift; sourceTree = ""; }; 6CFF967729BEBCF600182D6F /* MainCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCommands.swift; sourceTree = ""; }; @@ -2505,18 +2511,18 @@ children = ( 588847672992AAB800996D95 /* Array */, 5831E3C72933E7F700D5A6D2 /* Bundle */, - 5831E3C62933E7E600D5A6D2 /* Color */, 669A504F2C380BFD00304CD8 /* Collection */, + 5831E3C62933E7E600D5A6D2 /* Color */, 5831E3C82933E80500D5A6D2 /* Date */, 6CB94D002C9F1CF900E8651C /* LanguageIdentifier */, 6C82D6C429C0129E00495C54 /* NSApplication */, 5831E3D02934036D00D5A6D2 /* NSTableView */, 77A01E922BCA9C0400F0EA38 /* NSWindow */, - 6CB94CFF2C9F1CB600E8651C /* TextView */, - 77EF6C042C57DE4B00984B69 /* URL */, 58D01C8B293167DC00C5B6B4 /* String */, 5831E3CB2933E89A00D5A6D2 /* SwiftTerm */, 6CBD1BC42978DE3E006639D5 /* Text */, + 6CB94CFF2C9F1CB600E8651C /* TextView */, + 77EF6C042C57DE4B00984B69 /* URL */, 6CD26C752C8EA80000ADBA38 /* URL */, 5831E3CA2933E86F00D5A6D2 /* View */, ); @@ -2535,6 +2541,7 @@ D7E201AD27E8B3C000CB86D0 /* String+Ranges.swift */, 58D01C8D293167DC00C5B6B4 /* String+RemoveOccurrences.swift */, 58D01C8C293167DC00C5B6B4 /* String+SHA256.swift */, + 6CDAFDDE2D35DADD002B2D47 /* String+ValidFileName.swift */, ); path = String; sourceTree = ""; @@ -3034,6 +3041,7 @@ 6C96191C2C3F27E3009733CE /* ProjectNavigator */ = { isa = PBXGroup; children = ( + 6CFC0C3B2D381D2000F09CD0 /* ProjectNavigatorFileManagementUITests.swift */, 6C96191B2C3F27E3009733CE /* ProjectNavigatorUITests.swift */, ); path = ProjectNavigator; @@ -3059,6 +3067,7 @@ 6C96191F2C3F27E3009733CE /* CodeEditUITests */ = { isa = PBXGroup; children = ( + 6CFC0C3D2D382B3900F09CD0 /* UI TESTING.md */, 6CFBA54A2C4E168A00E3A914 /* App.swift */, 6C510CB62D2E462D006EBE85 /* Extensions */, 6C96191E2C3F27E3009733CE /* Features */, @@ -3980,6 +3989,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6CFC0C3E2D382B3F00F09CD0 /* UI TESTING.md in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4076,6 +4086,7 @@ EC0870F72A455F6400EB8692 /* ProjectNavigatorViewController+NSMenuDelegate.swift in Sources */, B60718202B0C6CE7009CDAB4 /* GitStashEntry.swift in Sources */, 6CAAF69429BCD78600A1F48A /* (null) in Sources */, + 6CDAFDDF2D35DADD002B2D47 /* String+ValidFileName.swift in Sources */, 3026F50F2AC006C80061227E /* InspectorAreaViewModel.swift in Sources */, 6C82D6C629C012AD00495C54 /* NSApp+openWindow.swift in Sources */, 6C14CEB028777D3C001468FE /* FindNavigatorListViewController.swift in Sources */, @@ -4636,6 +4647,7 @@ 6CFBA54D2C4E16C900E3A914 /* WindowCloseCommandTests.swift in Sources */, 6C9619222C3F27F1009733CE /* Query.swift in Sources */, 6C07383B2D284ECA0025CBE3 /* TasksMenuUITests.swift in Sources */, + 6CFC0C3C2D381D2000F09CD0 /* ProjectNavigatorFileManagementUITests.swift in Sources */, 6C9619202C3F27E3009733CE /* ProjectNavigatorUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index f9ece260de..32e3367eed 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift @@ -253,13 +253,15 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor } func validateFileName(for newName: String) -> Bool { - guard newName != labelFileName() else { return true } - - guard !newName.isEmpty && newName.isValidFilename && + // Name must be: new, nonempty, valid characters, and not exist in the filesystem. + guard newName != labelFileName() && + !newName.isEmpty && + newName.isValidFilename && !FileManager.default.fileExists( atPath: self.url.deletingLastPathComponent().appendingPathComponent(newName).path - ) - else { return false } + ) else { + return false + } return true } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift index f51d4045ac..c56adc160b 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift @@ -8,10 +8,14 @@ import Foundation extension CEWorkspaceFileManager { + /// Localized errors related to actions in the file manager. + /// These errors are suitable for presentation using `NSAlert(error:)`. enum FileManagerError: LocalizedError { case fileNotFound case fileNotIndexed case originFileNotFound + case destinationFileExists + case invalidFileName var errorDescription: String? { switch self { @@ -21,15 +25,23 @@ extension CEWorkspaceFileManager { return "File not found in CodeEdit" case .originFileNotFound: return "Failed to find origin file" + case .destinationFileExists: + return "Destination already exists" + case .invalidFileName: + return "Invalid file name" } } - var helpAnchor: String? { + var recoverySuggestion: String? { switch self { case .fileNotIndexed: return "Reopen the workspace to reindex the file system." case .fileNotFound, .originFileNotFound: return "The file may have moved during the operation, try again." + case .destinationFileExists: + return "Use a different file name or remove the conflicting file." + case .invalidFileName: + return "File names must not contain the : character and be less than 256 characters." } } } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index d3bbbebc92..e90c591747 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -14,7 +14,7 @@ extension CEWorkspaceFileManager { /// - folderName: The name of the new folder /// - file: The file to add the new folder to. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* - func addFolder(folderName: String, toFile file: CEWorkspaceFile) throws { + func addFolder(folderName: String, toFile file: CEWorkspaceFile) throws -> CEWorkspaceFile { // Check if folder, if it is create folder under self, else create on same level. var folderUrl = ( file.isFolder ? file.url.appendingPathComponent(folderName) @@ -35,6 +35,14 @@ extension CEWorkspaceFileManager { withIntermediateDirectories: true, attributes: [:] ) + + try rebuildFiles(fromItem: file.isFolder ? file : file.parent ?? file) + notifyObservers(updatedItems: [file.isFolder ? file : file.parent ?? file]) + + guard let newFolder = getFile(folderUrl.path(), createIfNotFound: true) else { + throw FileManagerError.fileNotFound + } + return newFolder } catch { logger.error("Failed to create folder: \(error, privacy: .auto)") throw error @@ -57,10 +65,17 @@ extension CEWorkspaceFileManager { ) throws -> CEWorkspaceFile { // check the folder for other files, and see what the most common file extension is do { - var fileExtension: String = useExtension ?? findCommonFileExtension(for: file) + var fileExtension: String + if fileName.contains(".") { + // If we already have a file extension in the name, don't add another one + fileExtension = "" + } else { + fileExtension = useExtension ?? findCommonFileExtension(for: file) - if !fileExtension.starts(with: ".") { - fileExtension = "." + fileExtension + // Don't add a . if the extension is empty, but add it if it's missing. + if !fileExtension.isEmpty && !fileExtension.starts(with: ".") { + fileExtension = "." + fileExtension + } } var fileUrl = file.nearestFolder.appendingPathComponent("\(fileName)\(fileExtension)") @@ -72,6 +87,10 @@ extension CEWorkspaceFileManager { .appendingPathComponent("\(fileName)\(fileNumber)\(fileExtension)") } + guard fileUrl.fileName.isValidFilename else { + throw FileManagerError.invalidFileName + } + // Create the file guard fileManager.createFile( atPath: fileUrl.path, @@ -84,7 +103,9 @@ extension CEWorkspaceFileManager { try rebuildFiles(fromItem: file) notifyObservers(updatedItems: [file]) - guard let newFile = getFile(fileUrl.path) else { + // 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. + guard let newFile = getFile(fileUrl.path, createIfNotFound: true) else { throw FileManagerError.fileNotIndexed } return newFile @@ -124,10 +145,10 @@ extension CEWorkspaceFileManager { /// - file: The file or folder to delete /// - Authors: Paul Ebose public func trash(file: CEWorkspaceFile) throws { - guard fileManager.fileExists(atPath: file.url.path) else { - throw FileManagerError.fileNotFound - } do { + guard fileManager.fileExists(atPath: file.url.path) else { + throw FileManagerError.fileNotFound + } try fileManager.trashItem(at: file.url, resultingItemURL: nil) } catch { logger.error("Failed to trash file: \(error, privacy: .auto)") @@ -180,10 +201,10 @@ extension CEWorkspaceFileManager { } private func deleteFile(at url: URL) throws { - guard fileManager.fileExists(atPath: url.path) else { - throw FileManagerError.fileNotFound - } do { + guard fileManager.fileExists(atPath: url.path) else { + throw FileManagerError.fileNotFound + } try fileManager.removeItem(at: url) } catch { logger.error("Failed to delete file: \(error, privacy: .auto)") @@ -220,14 +241,20 @@ extension CEWorkspaceFileManager { /// - file: The file to move. /// - newLocation: The destination to move the file to. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* - /// - Returns: The new file object. + /// - Returns: The new file object, if it has been indexed. The file manager does not index folders that have not + /// been revealed to save memory. This may move a file deeper into the tree than is indexed. In that + /// case, it is correct to return nothing. This is intentionally different than `addFile`. @discardableResult - public func move(file: CEWorkspaceFile, to newLocation: URL) throws -> CEWorkspaceFile { - guard !fileManager.fileExists(atPath: newLocation.path) else { - throw FileManagerError.originFileNotFound - } - + public func move(file: CEWorkspaceFile, to newLocation: URL) throws -> CEWorkspaceFile? { do { + guard fileManager.fileExists(atPath: file.url.path()) else { + throw FileManagerError.originFileNotFound + } + + guard !fileManager.fileExists(atPath: newLocation.path) else { + throw FileManagerError.destinationFileExists + } + try createMissingParentDirectory(for: newLocation.deletingLastPathComponent()) try fileManager.moveItem(at: file.url, to: newLocation) @@ -255,10 +282,7 @@ extension CEWorkspaceFileManager { notifyObservers(updatedItems: [parent]) } - guard let newFile = getFile(newLocation.absoluteURL.path) else { - throw FileManagerError.fileNotIndexed - } - return newFile + return getFile(newLocation.absoluteURL.path) } catch { logger.error("Failed to move file: \(error, privacy: .auto)") throw error @@ -270,8 +294,10 @@ extension CEWorkspaceFileManager { /// - file: The file to copy. /// - newLocation: The location to copy to. public func copy(file: CEWorkspaceFile, to newLocation: URL) throws { - guard file.url != newLocation && !fileManager.fileExists(atPath: newLocation.absoluteString) else { return } do { + guard file.url != newLocation && !fileManager.fileExists(atPath: newLocation.absoluteString) else { + throw FileManagerError.originFileNotFound + } try fileManager.copyItem(at: file.url, to: newLocation) } catch { logger.error("Failed to copy file: \(error, privacy: .auto)") diff --git a/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift b/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift index 6e900ef033..9721c8ccf2 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift @@ -176,11 +176,3 @@ extension FileSystemTableViewCell: NSTextFieldDelegate { } } } - -extension String { - var isValidFilename: Bool { - let regex = "[^:]" - let testString = NSPredicate(format: "SELF MATCHES %@", regex) - return !testString.evaluate(with: self) - } -} diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift index 9e28300734..8cdd593499 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift @@ -87,14 +87,15 @@ extension ProjectNavigatorMenu { func newFile() { guard let item else { return } do { - try workspace?.workspaceFileManager?.addFile(fileName: "untitled", toFile: item) + if let newFile = try workspace?.workspaceFileManager?.addFile(fileName: "untitled", toFile: item) { + workspace?.listenerModel.highlightedFileItem = newFile + workspace?.editorManager?.openTab(item: newFile) + } } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") alert.runModal() } - reloadData() - sender.outlineView.expandItem(item.isFolder ? item : item.parent) } // TODO: allow custom folder names @@ -103,10 +104,9 @@ extension ProjectNavigatorMenu { func newFolder() { guard let item else { return } do { - try workspace?.workspaceFileManager?.addFolder(folderName: "untitled", toFile: item) - reloadData() - sender.outlineView.expandItem(item) - sender.outlineView.expandItem(item.isFolder ? item : item.parent) + if let newFolder = try workspace?.workspaceFileManager?.addFolder(folderName: "untitled", toFile: item) { + workspace?.listenerModel.highlightedFileItem = newFolder + } } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") @@ -163,10 +163,14 @@ extension ProjectNavigatorMenu { func trash() { do { try selectedItems().forEach { item in - try workspace?.workspaceFileManager?.trash(file: item) withAnimation { sender.editor?.closeTab(file: item) } + guard FileManager.default.fileExists(atPath: item.url.path) else { + // Was likely already trashed (eg selecting files in a folder and deleting the folder and files) + return + } + try workspace?.workspaceFileManager?.trash(file: item) } reloadData() } catch { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index 5b97d09b24..21cf99dd03 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -75,10 +75,17 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { func fileManagerUpdated(updatedItems: Set) { guard let outlineView = controller?.outlineView else { return } + let selectedRows = outlineView.selectedRowIndexes.compactMap({ outlineView.item(atRow: $0) }) for item in updatedItems { outlineView.reloadItem(item, reloadChildren: true) } + + // Restore selected items where the files still exist. + let selectedIndexes = selectedRows.compactMap({ outlineView.row(forItem: $0) }).filter({ $0 >= 0 }) + controller?.shouldSendSelectionUpdate = false + outlineView.selectRowIndexes(IndexSet(selectedIndexes), byExtendingSelection: false) + controller?.shouldSendSelectionUpdate = true } deinit { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 6f8a6f30af..c24c692833 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift @@ -45,13 +45,11 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { guard let item = outlineView.item(atRow: selectedIndex) as? CEWorkspaceFile else { return } if !item.isFolder && shouldSendSelectionUpdate { - DispatchQueue.main.async { [weak self] in - self?.shouldSendSelectionUpdate = false - if self?.workspace?.editorManager?.activeEditor.selectedTab?.file != item { - self?.workspace?.editorManager?.activeEditor.openTab(file: item, asTemporary: true) - } - self?.shouldSendSelectionUpdate = true + shouldSendSelectionUpdate = false + if workspace?.editorManager?.activeEditor.selectedTab?.file != item { + workspace?.editorManager?.activeEditor.openTab(file: item, asTemporary: true) } + shouldSendSelectionUpdate = true } } @@ -117,6 +115,7 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { outlineView.deselectRow(outlineView.selectedRow) } shouldSendSelectionUpdate = false + print("Selecting", id.id) outlineView.selectRowIndexes(.init(integer: row), byExtendingSelection: false) shouldSendSelectionUpdate = true } @@ -130,6 +129,7 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { } let row = outlineView.row(forItem: fileItem) shouldSendSelectionUpdate = false + print("Revealing", fileItem.url.relativePath) outlineView.selectRowIndexes(.init(integer: row), byExtendingSelection: false) shouldSendSelectionUpdate = true diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift index ec4177d897..0784dde07e 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift @@ -102,8 +102,8 @@ struct ProjectNavigatorToolbarBottom: View { fileName: "untitled", toFile: rootFile ) { - editorManager.openTab(item: newFile) - NSApp.sendAction(#selector(ProjectNavigatorViewController.revealFile(_:)), to: nil, from: nil) + workspace.listenerModel.highlightedFileItem = newFile + workspace.editorManager?.openTab(item: newFile) } } catch { let alert = NSAlert(error: error) @@ -111,11 +111,17 @@ struct ProjectNavigatorToolbarBottom: View { alert.runModal() } } + Button("Add Folder") { let filePathURL = activeTabURL() guard let rootFile = workspace.workspaceFileManager?.getFile(filePathURL.path) else { return } do { - try workspace.workspaceFileManager?.addFolder(folderName: "untitled", toFile: rootFile) + if let newFolder = try workspace.workspaceFileManager?.addFolder( + folderName: "untitled", + toFile: rootFile + ) { + workspace.listenerModel.highlightedFileItem = newFolder + } } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") @@ -125,11 +131,14 @@ struct ProjectNavigatorToolbarBottom: View { } label: {} .background { Image(systemName: "plus") + .accessibilityHidden(true) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .frame(maxWidth: 18, alignment: .center) .opacity(activeState == .inactive ? 0.45 : 1) + .accessibilityLabel("Add Folder or File") + .accessibilityIdentifier("addButton") } /// We clear the text and remove the first responder which removes the cursor diff --git a/CodeEdit/Utils/Extensions/String/String+ValidFileName.swift b/CodeEdit/Utils/Extensions/String/String+ValidFileName.swift new file mode 100644 index 0000000000..fed334c653 --- /dev/null +++ b/CodeEdit/Utils/Extensions/String/String+ValidFileName.swift @@ -0,0 +1,21 @@ +// +// String+ValidFileName.swift +// CodeEdit +// +// Created by Khan Winter on 1/13/25. +// + +import Foundation + +extension CharacterSet { + /// On macOS, valid file names must not contain the `NULL` or `:` characters. + static var invalidFileNameCharacters: CharacterSet = CharacterSet(charactersIn: "\0:") +} + +extension String { + /// On macOS, valid file names must not contain the `NULL` or `:` characters and must be less than + /// 256 UTF16 characters. + var isValidFilename: Bool { + CharacterSet(charactersIn: self).isDisjoint(with: .invalidFileNameCharacters) && utf16.count < 256 + } +} diff --git a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift index d10111fafa..34e936e45d 100644 --- a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift +++ b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift @@ -63,38 +63,36 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { } func testDirectoryChanges() throws { - // This test is flaky on CI. Right now, the mac runner can take hours to send the file system events that - // this relies on. Commenting out for now to make automated testing feasible. -// let client = CEWorkspaceFileManager( -// folderUrl: directory, -// ignoredFilesAndFolders: [], -// sourceControlManager: nil -// ) -// -// let newFile = generateRandomFiles(amount: 1)[0] -// let expectation = XCTestExpectation(description: "wait for files") -// -// let observer = DummyObserver { -// let url = client.folderUrl.appending(path: newFile).path -// if client.flattenedFileItems[url] != nil { -// expectation.fulfill() -// } -// } -// client.addObserver(observer) -// -// var files = client.flattenedFileItems.map { $0.value.name } -// files.append(newFile) -// try files.forEach { -// let fakeData = Data("fake string".utf8) -// let fileUrl = directory -// .appendingPathComponent($0) -// try fakeData.write(to: fileUrl) -// } -// -// wait(for: [expectation], timeout: 2.0) -// XCTAssertEqual(files.count, client.flattenedFileItems.count - 1) -// try FileManager.default.removeItem(at: directory) -// client.removeObserver(observer) + let client = CEWorkspaceFileManager( + folderUrl: directory, + ignoredFilesAndFolders: [], + sourceControlManager: nil + ) + + let newFile = generateRandomFiles(amount: 1)[0] + let expectation = XCTestExpectation(description: "wait for files") + + let observer = DummyObserver { + let url = client.folderUrl.appending(path: newFile).path + if client.flattenedFileItems[url] != nil { + expectation.fulfill() + } + } + client.addObserver(observer) + + var files = client.flattenedFileItems.map { $0.value.name } + files.append(newFile) + try files.forEach { + let fakeData = Data("fake string".utf8) + let fileUrl = directory + .appendingPathComponent($0) + try fakeData.write(to: fileUrl) + } + + wait(for: [expectation], timeout: 2.0) + XCTAssertEqual(files.count, client.flattenedFileItems.count - 1) + try FileManager.default.removeItem(at: directory) + client.removeObserver(observer) } func generateRandomFiles(amount: Int) -> [String] { @@ -117,30 +115,30 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { try FileManager.default.createDirectory(at: testDirectoryURL, withIntermediateDirectories: true) try "".write(to: testFileURL, atomically: true, encoding: .utf8) - let fileManger = CEWorkspaceFileManager( + let fileManager = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [], sourceControlManager: nil ) - XCTAssert(fileManger.getFile(testFileURL.path()) == nil) - XCTAssert(fileManger.childrenOfFile(CEWorkspaceFile(url: testFileURL)) == nil) - XCTAssert(fileManger.getFile(testFileURL.path(), createIfNotFound: true) != nil) - XCTAssert(fileManger.childrenOfFile(CEWorkspaceFile(url: testDirectoryURL)) != nil) + XCTAssert(fileManager.getFile(testFileURL.path()) == nil) + XCTAssert(fileManager.childrenOfFile(CEWorkspaceFile(url: testFileURL)) == nil) + XCTAssert(fileManager.getFile(testFileURL.path(), createIfNotFound: true) != nil) + XCTAssert(fileManager.childrenOfFile(CEWorkspaceFile(url: testDirectoryURL)) != nil) } func testDeleteFile() throws { let testFileURL = directory.appending(path: "file.txt") try "".write(to: testFileURL, atomically: true, encoding: .utf8) - let fileManger = CEWorkspaceFileManager( + let fileManager = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [], sourceControlManager: nil ) - XCTAssert(fileManger.getFile(testFileURL.path()) != nil) + XCTAssert(fileManager.getFile(testFileURL.path()) != nil) XCTAssert(FileManager.default.fileExists(atPath: testFileURL.path()) == true) - fileManger.delete(file: CEWorkspaceFile(url: testFileURL), confirmDelete: false) + try fileManager.delete(file: CEWorkspaceFile(url: testFileURL), confirmDelete: false) XCTAssert(FileManager.default.fileExists(atPath: testFileURL.path()) == false) } @@ -149,16 +147,54 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { let testDuplicatedFileURL = directory.appendingPathComponent("file copy.txt") try "😄".write(to: testFileURL, atomically: true, encoding: .utf8) - let fileManger = CEWorkspaceFileManager( + let fileManager = CEWorkspaceFileManager( folderUrl: directory, ignoredFilesAndFolders: [], sourceControlManager: nil ) - XCTAssert(fileManger.getFile(testFileURL.path()) != nil) + XCTAssert(fileManager.getFile(testFileURL.path()) != nil) XCTAssert(FileManager.default.fileExists(atPath: testFileURL.path()) == true) - fileManger.duplicate(file: CEWorkspaceFile(url: testFileURL)) + try fileManager.duplicate(file: CEWorkspaceFile(url: testFileURL)) XCTAssert(FileManager.default.fileExists(atPath: testFileURL.path()) == true) XCTAssert(FileManager.default.fileExists(atPath: testDuplicatedFileURL.path(percentEncoded: false)) == true) XCTAssert(try String(contentsOf: testDuplicatedFileURL) == "😄") } + + func testAddFile() throws { + let fileManager = CEWorkspaceFileManager( + folderUrl: directory, + ignoredFilesAndFolders: [], + sourceControlManager: nil + ) + + // This will throw if unsuccessful. + var file = try fileManager.addFile(fileName: "Test File.txt", toFile: fileManager.workspaceItem) + + // Should not add a new file extension, it already has one. This adds a '.' at the end if incorrect. + XCTAssertEqual(file.name, "Test File.txt") + + // Test the automatic file extension stuff + file = try fileManager.addFile( + fileName: "Test File Extension", + toFile: fileManager.workspaceItem, + useExtension: nil + ) + + // Should detect '.txt' with the previous file in the same directory. + XCTAssertEqual(file.name, "Test File Extension.txt") + + // Test explicit file extension with both . and no period at the beginning of the given extension. + file = try fileManager.addFile( + fileName: "Explicit File Extension", + toFile: fileManager.workspaceItem, + useExtension: "xlsx" + ) + XCTAssertEqual(file.name, "Explicit File Extension.xlsx") + file = try fileManager.addFile( + fileName: "PDF", + toFile: fileManager.workspaceItem, + useExtension: ".pdf" + ) + XCTAssertEqual(file.name, "PDF.pdf") + } } diff --git a/CodeEditTests/Utils/UnitTests_Extensions.swift b/CodeEditTests/Utils/UnitTests_Extensions.swift index 94b7d66521..67304b8721 100644 --- a/CodeEditTests/Utils/UnitTests_Extensions.swift +++ b/CodeEditTests/Utils/UnitTests_Extensions.swift @@ -133,4 +133,49 @@ final class CodeEditUtilsExtensionsUnitTests: XCTestCase { XCTAssertEqual(result, withoutSpaces) } + // MARK: - STRING + VALID FILE NAME + + func testValidFileName() { + let validCases = [ + "hello world", + "newSwiftFile.swift", + "documento_español.txt", + "dokument_deutsch.pdf", + "rapport_français.docx", + "レポート_日本語.xlsx", + "отчет_русский.pptx", + "보고서_한국어.txt", + "文件_中文.pdf", + "dokument_svenska.txt", + "relatório_português.docx", + "relazione_italiano.pdf", + "file_with_emoji_😊.txt", + "emoji_report_📄.pdf", + "archivo_con_emoji_🌟.docx", + "文件和表情符号_🚀.txt", + "rapport_avec_emoji_🎨.pptx", + // 255 characters (exactly the maximum) + String((0..<255).map({ _ in "abcd".randomElement() ?? Character("") })) + ] + + for validCase in validCases { + XCTAssertTrue(validCase.isValidFilename, "Detected invalid case \"\(validCase)\", should be valid.") + } + } + + func testInvalidFileName() { + // The only limitations for macOS file extensions is no ':' and no NULL characters and 255 UTF16 char limit. + let invalidCases = [ + ":", + "\0", + "Hell\0 World!", + "export:2024-04-12.txt", + // 256 characters (1 too long) + String((0..<256).map({ _ in "abcd".randomElement() ?? Character("") })) + ] + + for invalidCase in invalidCases { + XCTAssertFalse(invalidCase.isValidFilename, "Detected valid case \"\(invalidCase)\", should be invalid.") + } + } } diff --git a/CodeEditUITests/App.swift b/CodeEditUITests/App.swift index 653544da20..855380fc84 100644 --- a/CodeEditUITests/App.swift +++ b/CodeEditUITests/App.swift @@ -15,12 +15,13 @@ enum App { return application } - // Launches CodeEdit in a new directory - static func launchWithTempDir() throws -> XCUIApplication { + // Launches CodeEdit in a new directory and returns the directory path. + static func launchWithTempDir() throws -> (XCUIApplication, String) { + let tempDirURL = try tempProjectPath() let application = XCUIApplication() - application.launchArguments = ["-ApplePersistenceIgnoreState", "YES", "--open", try tempProjectPath()] + application.launchArguments = ["-ApplePersistenceIgnoreState", "YES", "--open", tempDirURL] application.launch() - return application + return (application, tempDirURL) } static func launch() -> XCUIApplication { diff --git a/CodeEditUITests/Features/ActivityViewer/Tasks/TasksMenuUITests.swift b/CodeEditUITests/Features/ActivityViewer/Tasks/TasksMenuUITests.swift index a05e567dff..dbb5655dcf 100644 --- a/CodeEditUITests/Features/ActivityViewer/Tasks/TasksMenuUITests.swift +++ b/CodeEditUITests/Features/ActivityViewer/Tasks/TasksMenuUITests.swift @@ -23,7 +23,7 @@ final class ActivityViewerTasksMenuTests: XCTestCase { @MainActor override func setUp() async throws { - app = try App.launchWithTempDir() + (app, _) = try App.launchWithTempDir() window = Query.getWindow(app) XCTAssertTrue(window.exists, "Window not found") } diff --git a/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift b/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift new file mode 100644 index 0000000000..72cb59f782 --- /dev/null +++ b/CodeEditUITests/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorFileManagementUITests.swift @@ -0,0 +1,100 @@ +// +// ProjectNavigatorFileManagementUITests.swift +// CodeEditUITests +// +// Created by Khan Winter on 1/15/25. +// + +import XCTest + +final class ProjectNavigatorFileManagementUITests: XCTestCase { + + var app: XCUIApplication! + var window: XCUIElement! + var navigator: XCUIElement! + var path: String! + + override func setUp() async throws { + // MainActor required for async compatibility which is required to make this method throwing + try await MainActor.run { + (app, path) = try App.launchWithTempDir() + + window = Query.getWindow(app) + XCTAssertTrue(window.exists, "Window not found") + window.toolbars.firstMatch.click() + + navigator = Query.Window.getProjectNavigator(window) + XCTAssertTrue(navigator.exists, "Navigator not found") + XCTAssertEqual(Query.Navigator.getRows(navigator).count, 1, "Found more than just the root file.") + } + } + + func testNewFilesAppear() throws { + // Create a few files, one in the base path and one inside a new folder. They should all appear in the navigator + + guard FileManager.default.createFile(atPath: path.appending("/newFile.txt"), contents: nil) else { + XCTFail("Failed to create test file") + return + } + + try FileManager.default.createDirectory( + atPath: path.appending("/New Folder"), + withIntermediateDirectories: true + ) + + guard FileManager.default.createFile( + atPath: path.appending("/New Folder/My New JS File.jsx"), + contents: nil + ) else { + XCTFail("Failed to create second test file") + return + } + + guard Query.Navigator.getProjectNavigatorRow( + fileTitle: "newFile.txt", + navigator + ).waitForExistence(timeout: 2.0) else { + XCTFail("newFile.txt did not appear") + return + } + guard Query.Navigator.getProjectNavigatorRow( + fileTitle: "New Folder", + navigator + ).waitForExistence(timeout: 2.0) else { + XCTFail("New Folder did not appear") + return + } + + let folderRow = Query.Navigator.getProjectNavigatorRow(fileTitle: "New Folder", navigator) + folderRow.disclosureTriangles.element.click() + + guard Query.Navigator.getProjectNavigatorRow( + fileTitle: "My New JS File.jsx", + navigator + ).waitForExistence(timeout: 2.0) else { + XCTFail("New file inside the folder did not appear when folder was opened.") + return + } + } + + func testCreateNewFiles() throws { + // Add a few files with the navigator button + for idx in 0..<5 { + let addButton = window.popUpButtons["addButton"] + addButton.click() + let addMenu = addButton.menus.firstMatch + addMenu.menuItems["Add File"].click() + + let selectedRows = Query.Navigator.getSelectedRows(navigator) + guard selectedRows.firstMatch.waitForExistence(timeout: 0.5) else { + XCTFail("No new selected rows appeared") + return + } + + let title = idx > 0 ? "untitled\(idx)" : "untitled" + + let newFileRow = selectedRows.firstMatch + XCTAssertEqual(newFileRow.descendants(matching: .textField).firstMatch.value as? String, title) + } + } +} diff --git a/CodeEditUITests/Query.swift b/CodeEditUITests/Query.swift index 3e41809998..e958c89650 100644 --- a/CodeEditUITests/Query.swift +++ b/CodeEditUITests/Query.swift @@ -42,9 +42,16 @@ enum Query { } enum Navigator { + static func getRows(_ navigator: XCUIElement) -> XCUIElementQuery { + navigator.descendants(matching: .outlineRow) + } + + static func getSelectedRows(_ navigator: XCUIElement) -> XCUIElementQuery { + getRows(navigator).matching(NSPredicate(format: "selected = true")) + } + static func getProjectNavigatorRow(fileTitle: String, index: Int = 0, _ navigator: XCUIElement) -> XCUIElement { - return navigator - .descendants(matching: .outlineRow) + return getRows(navigator) .containing(.textField, identifier: "ProjectNavigatorTableViewCell-\(fileTitle)") .element(boundBy: index) } diff --git a/CodeEditUITests/UI TESTING.md b/CodeEditUITests/UI TESTING.md new file mode 100644 index 0000000000..8178ab3af8 --- /dev/null +++ b/CodeEditUITests/UI TESTING.md @@ -0,0 +1,37 @@ +# UI Testing in CodeEdit + +CodeEdit uses XCUITests for automating tests that require user interaction. Ideally, we have UI tests for every UI +component in CodeEdit, but right now (as of Jan, 2025) we have fewer tests than we'd like. + +## Test Application Setup + +To test workspaces with real files, launch the application with the `App` enum. To create a temporary test directory, +use the `App.launchWithTempDir()` method. This will create a random directory in the temporary directory and return +the created path. In tests you can add files to that directory and it will be cleaned up when the tests finish. + +There is a `App.launchWithCodeEditWorkspace` method, but please try not to use it. It exists for compatibility with a +few existing tests and would be a pain to replace. It's more likely to be flaky, and can't test things like file +modification, creation, or anything besides clicking around the workspace or you risk modifying the very project +that's being tested! + +## Query Extensions + +For common, long, queries, add static methods to the `Query` enum. This enum should be used to help clarify tests +without having to read long XCUI queries. For instance +```swift +let window = application.windows.element(matching: .window, identifier: "workspace") +let navigator = window.descendants(matching: .any).matching(identifier: "ProjectNavigator").element +let newFileCell = navigator.descendants(matching: .outlineRow) + .containing(.textField, identifier: "ProjectNavigatorTableViewCell-FileName") + .element(boundBy: index) +``` + +Should be shortened to the following, which is much easier to read and intuit what the test is doing. +```swift +let window = Query.getWindow(app) +let navigator = Query.Window.getProjectNavigator(window) +let newFileCell = Query.Navigator.getProjectNavigatorRow(fileTitle: "FileName", navigator) +``` + +This isn't necessary for all tests, but useful for querying common regions like the project navigator, window, or +utility area. From 566a38e3b90988ad3c83d8a8fe9662ea83bcce5e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Jan 2025 12:54:54 -0600 Subject: [PATCH 03/10] Update UI Testing docs --- CodeEditUITests/UI TESTING.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CodeEditUITests/UI TESTING.md b/CodeEditUITests/UI TESTING.md index 8178ab3af8..af41e654dd 100644 --- a/CodeEditUITests/UI TESTING.md +++ b/CodeEditUITests/UI TESTING.md @@ -21,12 +21,14 @@ without having to read long XCUI queries. For instance ```swift let window = application.windows.element(matching: .window, identifier: "workspace") let navigator = window.descendants(matching: .any).matching(identifier: "ProjectNavigator").element -let newFileCell = navigator.descendants(matching: .outlineRow) +let newFileCell = navigator + .descendants(matching: .outlineRow) .containing(.textField, identifier: "ProjectNavigatorTableViewCell-FileName") - .element(boundBy: index) + .element(boundBy: 0) ``` -Should be shortened to the following, which is much easier to read and intuit what the test is doing. +Should be shortened to the following, which should be easier to read and intuit what the test is doing. + ```swift let window = Query.getWindow(app) let navigator = Query.Window.getProjectNavigator(window) From da8f92c8bc314f1f5e564cf2e314a4df98864ee4 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Jan 2025 12:59:16 -0600 Subject: [PATCH 04/10] Docs --- .../Models/CEWorkspaceFileManager+FileManagement.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index e90c591747..72034ff460 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -13,6 +13,7 @@ extension CEWorkspaceFileManager { /// - Parameters: /// - folderName: The name of the new folder /// - file: The file to add the new folder to. + /// - Returns: The ``CEWorkspaceFile`` representing the folder in the file manager's cache. /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* func addFolder(folderName: String, toFile file: CEWorkspaceFile) throws -> CEWorkspaceFile { // Check if folder, if it is create folder under self, else create on same level. From d4f0c1ba9439c28405ff0bdfdfd9a4fb2977e5ae Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:05:35 -0600 Subject: [PATCH 05/10] Docs --- .../Models/CEWorkspaceFileManager+FileManagement.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index 72034ff460..111a19c526 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -58,7 +58,7 @@ extension CEWorkspaceFileManager { /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* /// - Throws: Throws a `CocoaError.fileWriteUnknown` with the file url if creating the file fails, and calls /// ``rebuildFiles(fromItem:deep:)`` which throws other `FileManager` errors. - /// - Returns: The newly created file. + /// - Returns: The ``CEWorkspaceFile`` representing the new file in the file manager's cache. func addFile( fileName: String, toFile file: CEWorkspaceFile, From d87ae91ade07577fd90cf68614b9db58ac2e573e Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:10:29 -0600 Subject: [PATCH 06/10] Docs, Remove Unnecessary Change --- .../Models/CEWorkspaceFileManager+FileManagement.swift | 7 +++++-- .../CEWorkspace/Models/CEWorkspaceFileManager.swift | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index 111a19c526..50920a202f 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -58,7 +58,7 @@ extension CEWorkspaceFileManager { /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* /// - Throws: Throws a `CocoaError.fileWriteUnknown` with the file url if creating the file fails, and calls /// ``rebuildFiles(fromItem:deep:)`` which throws other `FileManager` errors. - /// - Returns: The ``CEWorkspaceFile`` representing the new file in the file manager's cache. + /// - Returns: The ``CEWorkspaceFile`` representing the new file in the file manager's cache. func addFile( fileName: String, toFile file: CEWorkspaceFile, @@ -200,7 +200,10 @@ extension CEWorkspaceFileManager { } } } - + + /// Delete a file from the file system. + /// - Note: Use ``trash(file:)`` if the file should be moved to the trash. This is irreversible. + /// - Parameter url: The file URL to delete. private func deleteFile(at url: URL) throws { do { guard fileManager.fileExists(atPath: url.path) else { diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 4a53f06679..4a3a97b835 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -80,7 +80,6 @@ final class CEWorkspaceFileManager { Task { try await self.sourceControlManager?.validate() - await sourceControlManager?.refreshAllChangedFiles() } } From da60c02a42249daf1d149261b8b183759cfd6a06 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:13:53 -0600 Subject: [PATCH 07/10] Add Empty Case To `isValidFilename` --- CodeEdit/Utils/Extensions/String/String+ValidFileName.swift | 6 +++--- CodeEditTests/Utils/UnitTests_Extensions.swift | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CodeEdit/Utils/Extensions/String/String+ValidFileName.swift b/CodeEdit/Utils/Extensions/String/String+ValidFileName.swift index fed334c653..5ab09cb0fb 100644 --- a/CodeEdit/Utils/Extensions/String/String+ValidFileName.swift +++ b/CodeEdit/Utils/Extensions/String/String+ValidFileName.swift @@ -13,9 +13,9 @@ extension CharacterSet { } extension String { - /// On macOS, valid file names must not contain the `NULL` or `:` characters and must be less than - /// 256 UTF16 characters. + /// On macOS, valid file names must not contain the `NULL` or `:` characters, must be non-empty, and must be less + /// than 256 UTF16 characters. var isValidFilename: Bool { - CharacterSet(charactersIn: self).isDisjoint(with: .invalidFileNameCharacters) && utf16.count < 256 + !isEmpty && CharacterSet(charactersIn: self).isDisjoint(with: .invalidFileNameCharacters) && utf16.count < 256 } } diff --git a/CodeEditTests/Utils/UnitTests_Extensions.swift b/CodeEditTests/Utils/UnitTests_Extensions.swift index 67304b8721..3806217445 100644 --- a/CodeEditTests/Utils/UnitTests_Extensions.swift +++ b/CodeEditTests/Utils/UnitTests_Extensions.swift @@ -166,6 +166,7 @@ final class CodeEditUtilsExtensionsUnitTests: XCTestCase { func testInvalidFileName() { // The only limitations for macOS file extensions is no ':' and no NULL characters and 255 UTF16 char limit. let invalidCases = [ + "", ":", "\0", "Hell\0 World!", From 84a62f49c2c3fc63f398a4eef6a03795056e94ba Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:17:43 -0600 Subject: [PATCH 08/10] Docs --- .../ProjectNavigatorViewController+NSOutlineViewDelegate.swift | 2 -- ...ctNavigatorViewController+OutlineTableViewCellDelegate.swift | 2 +- .../CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift | 1 + 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index c24c692833..9256c3e3e1 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift @@ -115,7 +115,6 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { outlineView.deselectRow(outlineView.selectedRow) } shouldSendSelectionUpdate = false - print("Selecting", id.id) outlineView.selectRowIndexes(.init(integer: row), byExtendingSelection: false) shouldSendSelectionUpdate = true } @@ -129,7 +128,6 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { } let row = outlineView.row(forItem: fileItem) shouldSendSelectionUpdate = false - print("Revealing", fileItem.url.relativePath) outlineView.selectRowIndexes(.init(integer: row), byExtendingSelection: false) shouldSendSelectionUpdate = true diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift index 4e61be7fd3..b8d09e631a 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift @@ -21,8 +21,8 @@ extension ProjectNavigatorViewController: OutlineTableViewCellDelegate { if !file.isFolder { workspace?.editorManager?.editorLayout.closeAllTabs(of: file) } + workspace?.listenerModel.highlightedFileItem = newFile workspace?.editorManager?.openTab(item: newFile) - select(by: file.tabID, forcesReveal: true) } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") diff --git a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift index 34e936e45d..fd80ace286 100644 --- a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift +++ b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift @@ -171,6 +171,7 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { var file = try fileManager.addFile(fileName: "Test File.txt", toFile: fileManager.workspaceItem) // Should not add a new file extension, it already has one. This adds a '.' at the end if incorrect. + // See #1966 XCTAssertEqual(file.name, "Test File.txt") // Test the automatic file extension stuff From fe8086c463e04052b981790482ed03ac274e9499 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:18:24 -0600 Subject: [PATCH 09/10] Lint --- .../Models/CEWorkspaceFileManager+FileManagement.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index 50920a202f..2fca165427 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -200,7 +200,7 @@ extension CEWorkspaceFileManager { } } } - + /// Delete a file from the file system. /// - Note: Use ``trash(file:)`` if the file should be moved to the trash. This is irreversible. /// - Parameter url: The file URL to delete. From 0fbeac0fb9508e6ed6a8fa6286293fc2755ac82f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 15 Jan 2025 23:13:59 -0600 Subject: [PATCH 10/10] Catch Odd Case in `getFile` --- .../CEWorkspaceFileManager+FileManagement.swift | 10 ++++++++-- .../CEWorkspace/Models/CEWorkspaceFileManager.swift | 11 ++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index 2fca165427..29182c6453 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -101,8 +101,8 @@ extension CEWorkspaceFileManager { throw CocoaError.error(.fileWriteUnknown, url: fileUrl) } - try rebuildFiles(fromItem: file) - notifyObservers(updatedItems: [file]) + try rebuildFiles(fromItem: file.isFolder ? file : file.parent ?? file) + 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. @@ -286,6 +286,12 @@ extension CEWorkspaceFileManager { 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: [newFileParent]) + } + return getFile(newLocation.absoluteURL.path) } catch { logger.error("Failed to move file: \(error, privacy: .auto)") diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift index 4a3a97b835..f81e406225 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager.swift @@ -120,7 +120,16 @@ final class CEWorkspaceFileManager { } } - return flattenedFileItems[url.relativePath] + if let file = flattenedFileItems[url.relativePath] { + return file + } else if let parent = getFile(currentURL.deletingLastPathComponent().path) { + // This catches the case where each parent dir has been loaded, their children cached, and this is a new + // file, so we still need to create it and add it to the cache. + let newFileItem = createChild(url, forParent: parent) + flattenedFileItems[newFileItem.id] = newFileItem + childrenMap[parent.id]?.append(newFileItem.id) + return newFileItem + } } return nil