diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 87e5d8aff8..942de336d9 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -480,6 +480,8 @@ 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 */; }; + 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 */; }; @@ -488,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 */; }; @@ -1161,6 +1165,8 @@ 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 = ""; }; + 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 = ""; }; @@ -1168,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 = ""; }; @@ -2425,6 +2433,7 @@ 58A2E40629C3975D005CB615 /* CEWorkspaceFileIcon.swift */, 58710158298EB80000951BA4 /* CEWorkspaceFileManager.swift */, 77EF6C0C2C60E23400984B69 /* CEWorkspaceFileManager+DirectoryEvents.swift */, + 6CDAFDDC2D35B2A0002B2D47 /* CEWorkspaceFileManager+Error.swift */, 6CB52DC82AC8DC3E002E75B3 /* CEWorkspaceFileManager+FileManagement.swift */, 6C049A362A49E2DB00D42923 /* DirectoryEventStream.swift */, ); @@ -2502,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 */, ); @@ -2532,6 +2541,7 @@ D7E201AD27E8B3C000CB86D0 /* String+Ranges.swift */, 58D01C8D293167DC00C5B6B4 /* String+RemoveOccurrences.swift */, 58D01C8C293167DC00C5B6B4 /* String+SHA256.swift */, + 6CDAFDDE2D35DADD002B2D47 /* String+ValidFileName.swift */, ); path = String; sourceTree = ""; @@ -3031,6 +3041,7 @@ 6C96191C2C3F27E3009733CE /* ProjectNavigator */ = { isa = PBXGroup; children = ( + 6CFC0C3B2D381D2000F09CD0 /* ProjectNavigatorFileManagementUITests.swift */, 6C96191B2C3F27E3009733CE /* ProjectNavigatorUITests.swift */, ); path = ProjectNavigator; @@ -3056,6 +3067,7 @@ 6C96191F2C3F27E3009733CE /* CodeEditUITests */ = { isa = PBXGroup; children = ( + 6CFC0C3D2D382B3900F09CD0 /* UI TESTING.md */, 6CFBA54A2C4E168A00E3A914 /* App.swift */, 6C510CB62D2E462D006EBE85 /* Extensions */, 6C96191E2C3F27E3009733CE /* Features */, @@ -3977,6 +3989,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6CFC0C3E2D382B3F00F09CD0 /* UI TESTING.md in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4073,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 */, @@ -4136,6 +4150,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 */, @@ -4632,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+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..c56adc160b --- /dev/null +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+Error.swift @@ -0,0 +1,48 @@ +// +// CEWorkspaceFileManager+Error.swift +// CodeEdit +// +// Created by Khan Winter on 1/13/25. +// + +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 { + case .fileNotFound: + return "File not found" + case .fileNotIndexed: + 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 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 f26dfa5cc1..29182c6453 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -13,8 +13,9 @@ 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) { + 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,8 +36,17 @@ 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 { - fatalError(error.localizedDescription) + logger.error("Failed to create folder: \(error, privacy: .auto)") + throw error } } @@ -48,68 +58,102 @@ 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 ``CEWorkspaceFile`` representing the new file in the file manager's cache. + 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 + 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) + + // Don't add a . if the extension is empty, but add it if it's missing. + if !fileExtension.isEmpty && !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 - } + guard fileUrl.fileName.isValidFilename else { + throw FileManagerError.invalidFileName + } - 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)") + // Create the file + guard fileManager.createFile( + atPath: fileUrl.path, + contents: nil, + attributes: [FileAttributeKey.creationDate: Date()] + ) else { + throw CocoaError.error(.fileWriteUnknown, url: fileUrl) + } + + 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. + guard let newFile = getFile(fileUrl.path, createIfNotFound: true) else { + throw FileManagerError.fileNotIndexed + } + return newFile + } catch { + logger.error("Failed to add file: \(error, privacy: .auto)") + throw error } + } - // Create the file - guard fileManager.createFile( - atPath: fileUrl.path, - contents: nil, - attributes: [FileAttributeKey.creationDate: Date()] - ) else { - throw CocoaError.error(.fileWriteUnknown, url: fileUrl) + /// 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] + + 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 { + 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)") + throw error } } @@ -118,7 +162,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 +176,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 +185,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 +196,30 @@ 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) + } + } + } + + /// 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 { + throw FileManagerError.fileNotFound } + 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 +234,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 +245,57 @@ 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, 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? { 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) - } 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]) + } + + // 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)") + throw error } } @@ -227,12 +303,15 @@ extension CEWorkspaceFileManager { /// - Parameters: /// - file: The file to copy. /// - newLocation: The location to copy to. - public func copy(file: CEWorkspaceFile, to newLocation: URL) { - guard file.url != newLocation && !fileManager.fileExists(atPath: newLocation.absoluteString) else { return } + public func copy(file: CEWorkspaceFile, to newLocation: URL) throws { do { + guard file.url != newLocation && !fileManager.fileExists(atPath: newLocation.absoluteString) else { + throw FileManagerError.originFileNotFound + } 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..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 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..9721c8ccf2 100644 --- a/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/OutlineView/FileSystemTableViewCell.swift @@ -159,20 +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() } } } - -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 8f469e3290..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 @@ -102,10 +103,15 @@ 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 { + 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") + 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,65 @@ 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 + 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 { + 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/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+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+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 6f8a6f30af..9256c3e3e1 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 } } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift index 15475dfb86..b8d09e631a 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?.listenerModel.highlightedFileItem = newFile + workspace?.editorManager?.openTab(item: newFile) + } 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..0784dde07e 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/ProjectNavigatorToolbarBottom.swift @@ -98,26 +98,47 @@ 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 + ) { + workspace.listenerModel.highlightedFileItem = newFile + workspace.editorManager?.openTab(item: newFile) + } } catch { let alert = NSAlert(error: error) alert.addButton(withTitle: "Dismiss") alert.runModal() } } + Button("Add Folder") { let filePathURL = activeTabURL() guard let rootFile = workspace.workspaceFileManager?.getFile(filePathURL.path) else { return } - workspace.workspaceFileManager?.addFolder(folderName: "untitled", toFile: rootFile) + do { + 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") + alert.runModal() + } } } 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..5ab09cb0fb --- /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, must be non-empty, and must be less + /// than 256 UTF16 characters. + var isValidFilename: Bool { + !isEmpty && 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..fd80ace286 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,55 @@ 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. + // See #1966 + 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..3806217445 100644 --- a/CodeEditTests/Utils/UnitTests_Extensions.swift +++ b/CodeEditTests/Utils/UnitTests_Extensions.swift @@ -133,4 +133,50 @@ 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..af41e654dd --- /dev/null +++ b/CodeEditUITests/UI TESTING.md @@ -0,0 +1,39 @@ +# 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: 0) +``` + +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) +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.