diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 0d697ab9f..9e3f824bc 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ 30E6D0012A6E505200A58B20 /* NavigatorSidebarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E6D0002A6E505200A58B20 /* NavigatorSidebarViewModel.swift */; }; 3E0196732A3921AC002648D8 /* codeedit_shell_integration_rc.zsh in Resources */ = {isa = PBXBuildFile; fileRef = 3E0196722A3921AC002648D8 /* codeedit_shell_integration_rc.zsh */; }; 3E01967A2A392B45002648D8 /* codeedit_shell_integration.bash in Resources */ = {isa = PBXBuildFile; fileRef = 3E0196792A392B45002648D8 /* codeedit_shell_integration.bash */; }; + 4A6F0DB52CBA462B00499627 /* ProjectNavigatorMenuActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A6F0DB42CBA462B00499627 /* ProjectNavigatorMenuActions.swift */; }; 4E7F066629602E7B00BB3C12 /* CodeEditSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7F066529602E7B00BB3C12 /* CodeEditSplitViewController.swift */; }; 4EE96ECB2960565E00FFBEA8 /* DocumentsUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE96ECA2960565E00FFBEA8 /* DocumentsUnitTests.swift */; }; 4EE96ECE296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE96ECD296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift */; }; @@ -773,6 +774,7 @@ 30E6D0002A6E505200A58B20 /* NavigatorSidebarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigatorSidebarViewModel.swift; sourceTree = ""; }; 3E0196722A3921AC002648D8 /* codeedit_shell_integration_rc.zsh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = codeedit_shell_integration_rc.zsh; sourceTree = ""; }; 3E0196792A392B45002648D8 /* codeedit_shell_integration.bash */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = codeedit_shell_integration.bash; sourceTree = ""; }; + 4A6F0DB42CBA462B00499627 /* ProjectNavigatorMenuActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectNavigatorMenuActions.swift; sourceTree = ""; }; 4E7F066529602E7B00BB3C12 /* CodeEditSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditSplitViewController.swift; sourceTree = ""; }; 4EE96ECA2960565E00FFBEA8 /* DocumentsUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentsUnitTests.swift; sourceTree = ""; }; 4EE96ECD296059E000FFBEA8 /* NSHapticFeedbackPerformerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSHapticFeedbackPerformerMock.swift; sourceTree = ""; }; @@ -1461,6 +1463,7 @@ 6CC17B502C43311900834E2C /* ProjectNavigatorViewController+NSOutlineViewDataSource.swift */, 285FEC6F27FE4B9800E57D53 /* ProjectNavigatorTableViewCell.swift */, 285FEC7127FE4EEF00E57D53 /* ProjectNavigatorMenu.swift */, + 4A6F0DB42CBA462B00499627 /* ProjectNavigatorMenuActions.swift */, D7DC4B75298FFBE900D6C83D /* ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift */, EC0870F62A455F6400EB8692 /* ProjectNavigatorViewController+NSMenuDelegate.swift */, ); @@ -4286,6 +4289,7 @@ 587B9DA629300ABD00AC7927 /* ToolbarBranchPicker.swift in Sources */, 6C6BD6F629CD145F00235D17 /* ExtensionInfo.swift in Sources */, 04BA7C202AE2D92B00584E1C /* GitClient+Status.swift in Sources */, + 4A6F0DB52CBA462B00499627 /* ProjectNavigatorMenuActions.swift in Sources */, 58F2EB05292FB2B0004A9BDE /* Settings.swift in Sources */, 6CBD1BC62978DE53006639D5 /* Font+Caption3.swift in Sources */, 30E6D0012A6E505200A58B20 /* NavigatorSidebarViewModel.swift in Sources */, diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index e3469687f..f26dfa5cc 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -141,6 +141,30 @@ extension CEWorkspaceFileManager { } } + /// This function deletes multiple files or folders from the current project by erasing immediately. + /// - Parameters: + /// - files: The files to delete + /// - confirmDelete: True to present an alert to confirm the delete. + public func batchDelete(files: Set, confirmDelete: Bool = true) { + let deleteConfirmation = NSAlert() + deleteConfirmation.messageText = "Are you sure you want to delete the \(files.count) selected items?" + // swiftlint:disable:next line_length + deleteConfirmation.informativeText = "\(files.count) items will be deleted immediately. You cannot undo this action." + deleteConfirmation.alertStyle = .critical + deleteConfirmation.addButton(withTitle: "Delete") + deleteConfirmation.buttons.last?.hasDestructiveAction = true + 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) + } + } + } + } + /// This function duplicates the item or folder /// - Parameter file: The file to duplicate /// - Authors: Mattijs Eikelenboom, KaiTheRedNinja. *Moved from 7c27b1e* diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift index 01206e590..eb7c7764c 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenu.swift @@ -17,10 +17,12 @@ final class ProjectNavigatorMenu: NSMenu { /// The workspace, for opening the item var workspace: WorkspaceDocument? - var outlineView: NSOutlineView + /// The `ProjectNavigatorViewController` is being called from. + /// By sending it, we can access it's variables and functions. + var sender: ProjectNavigatorViewController - init(sender: NSOutlineView) { - outlineView = sender + init(_ sender: ProjectNavigatorViewController) { + self.sender = sender super.init(title: "Options") } @@ -42,9 +44,9 @@ final class ProjectNavigatorMenu: NSMenu { return mItem } - /// Setup the menu and disables certain items when `isFile` is false - /// - Parameter isFile: A flag indicating that the item is a file instead of a directory - private func setupMenu() { + /// Configures the menu based on the current selection in the outline view. + /// - Menu items get added depending on the amount of selected items. + private func setupMenu() { // swiftlint:disable:this function_body_length guard let item else { return } let showInFinder = menuItem("Show in Finder", action: #selector(showInFinder)) @@ -92,19 +94,30 @@ final class ProjectNavigatorMenu: NSMenu { showFileInspector, NSMenuItem.separator(), newFile, - newFolder, - NSMenuItem.separator(), - rename, - trash, - delete, - duplicate, - NSMenuItem.separator(), - sortByName, - sortByType, - NSMenuItem.separator(), - sourceControl, + newFolder ] + if canCreateFolderFromSelection() { + items.append(menuItem("New Folder from Selection", action: #selector(newFolderFromSelection))) + } + items.append(NSMenuItem.separator()) + if selectedItems().count == 1 { + items.append(rename) + } + + items.append( + contentsOf: [ + trash, + delete, + duplicate, + NSMenuItem.separator(), + sortByName, + sortByType, + NSMenuItem.separator(), + sourceControl, + ] + ) + setSubmenu(openAsMenu(item: item), for: openAs) setSubmenu(sourceControlMenu(item: item), for: sourceControl) } @@ -183,87 +196,6 @@ final class ProjectNavigatorMenu: NSMenu { removeAllItems() setupMenu() } - - /// Action that opens **Finder** at the items location. - @objc - private func showInFinder() { - item?.showInFinder() - } - - /// Action that opens the item, identical to clicking it. - @objc - private func openInTab() { - if let item { - workspace?.editorManager?.openTab(item: item) - } - } - - /// Action that opens in an external editor - @objc - private func openWithExternalEditor() { - item?.openWithExternalEditor() - } - - // TODO: allow custom file names - /// Action that creates a new untitled file - @objc - private func newFile() { - guard let item else { return } - do { - try workspace?.workspaceFileManager?.addFile(fileName: "untitled", toFile: item) - } catch { - let alert = NSAlert(error: error) - alert.addButton(withTitle: "Dismiss") - alert.runModal() - } - outlineView.expandItem(item.isFolder ? item : item.parent) - } - - // TODO: allow custom folder names - /// Action that creates a new untitled folder - @objc - private func newFolder() { - guard let item else { return } - workspace?.workspaceFileManager?.addFolder(folderName: "untitled", toFile: item) - outlineView.expandItem(item) - outlineView.expandItem(item.isFolder ? item : item.parent) - } - - /// Opens the rename file dialogue on the cell this was presented from. - @objc - private func renameFile() { - let row = outlineView.row(forItem: item) - guard row > 0, - let cell = outlineView.view( - atColumn: 0, - row: row, - makeIfNecessary: false - ) as? ProjectNavigatorTableViewCell else { - return - } - outlineView.window?.makeFirstResponder(cell.textField) - } - - /// Action that moves the item to trash. - @objc - private func trash() { - guard let item else { return } - workspace?.workspaceFileManager?.trash(file: item) - } - - /// Action that deletes the item immediately. - @objc - private func delete() { - guard let item else { return } - workspace?.workspaceFileManager?.delete(file: item) - } - - /// Action that duplicates the item - @objc - private func duplicate() { - guard let item else { return } - workspace?.workspaceFileManager?.duplicate(file: item) - } } extension NSMenuItem { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift new file mode 100644 index 000000000..9ab9d3ed2 --- /dev/null +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift @@ -0,0 +1,184 @@ +// +// ProjectNavigatorMenuActions.swift +// CodeEdit +// +// Created by Leonardo LarraƱaga on 10/11/24. +// + +import AppKit + +extension ProjectNavigatorMenu { + /// - Returns: the currently selected `CEWorkspaceFile` items in the outline view. + func selectedItems() -> Set { + /// Selected items... + let selectedItems = Set(sender.outlineView.selectedRowIndexes.compactMap { + sender.outlineView.item(atRow: $0) as? CEWorkspaceFile + }) + + /// Item that the user brought up the menu with... + if let menuItem = sender.outlineView.item(atRow: sender.outlineView.clickedRow) as? CEWorkspaceFile { + /// If the item is not in the set, just like in Xcode, only modify that item. + if !selectedItems.contains(menuItem) { + return Set([menuItem]) + } + } + + return selectedItems + } + + /// Verify if a folder can be made from selection by getting the amount of parents found in the selected items. + /// If the amount of parents is equal to one, a folder can be made. + func canCreateFolderFromSelection() -> Bool { + var uniqueParents: Set = [] + for file in selectedItems() { + if let parent = file.parent { + uniqueParents.insert(parent) + } + } + + return uniqueParents.count == 1 + } + + /// Action that opens **Finder** at the items location. + @objc + func showInFinder() { + NSWorkspace.shared.activateFileViewerSelecting(selectedItems().map { $0.url }) + } + + /// Action that opens the item, identical to clicking it. + @objc + func openInTab() { + /// Sort the selected items first by their parent and then by name. + let sortedItems = selectedItems().sorted { (item1, item2) -> Bool in + /// Get the parents of both items. + let parent1 = sender.outlineView.parent(forItem: item1) as? CEWorkspaceFile + let parent2 = sender.outlineView.parent(forItem: item2) as? CEWorkspaceFile + + /// Compare by parent. + if parent1 != parent2 { + /// If the parents are different, use their row position in the outline view. + return sender.outlineView.row(forItem: parent1) < sender.outlineView.row(forItem: parent2) + } else { + /// If both items have the same parent, sort them by name. + return item1.name < item2.name + } + } + + /// Open the items in order. + sortedItems.forEach { item in + workspace?.editorManager?.openTab(item: item) + } + } + + /// Action that opens in an external editor + @objc + func openWithExternalEditor() { + /// Using `Process` to open all of the selected files at the same time. + let process = Process() + process.launchPath = "/usr/bin/open" + process.arguments = selectedItems().map { $0.url.absoluteString } + try? process.run() + } + + // TODO: allow custom file names + /// Action that creates a new untitled file + @objc + func newFile() { + guard let item else { return } + do { + try workspace?.workspaceFileManager?.addFile(fileName: "untitled", toFile: item) + } 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 + /// Action that creates a new untitled folder + @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) + } + + /// Creates a new folder with the items selected. + @objc + func newFolderFromSelection() { + guard let workspace, let workspaceFileManager = workspace.workspaceFileManager else { return } + + let selectedItems = selectedItems() + guard let parent = selectedItems.first?.parent else { return } + + /// Get 'New Folder' name. + var newFolderURL = parent.url.appendingPathComponent("New Folder With Items", conformingTo: .folder) + var folderNumber = 0 + while workspaceFileManager.fileManager.fileExists(atPath: newFolderURL.path) { + folderNumber += 1 + 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)) + } + + reloadData() + } + + /// Opens the rename file dialogue on the cell this was presented from. + @objc + func renameFile() { + let row = sender.outlineView.row(forItem: item) + guard row > 0, + let cell = sender.outlineView.view( + atColumn: 0, + row: row, + makeIfNecessary: false + ) as? ProjectNavigatorTableViewCell else { + return + } + sender.outlineView.window?.makeFirstResponder(cell.textField) + } + + /// Action that moves the item to trash. + @objc + func trash() { + selectedItems().forEach { item in + workspace?.workspaceFileManager?.trash(file: item) + } + 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) + } + } else { + workspace?.workspaceFileManager?.batchDelete(files: selectedItems) + } + reloadData() + } + + /// Action that duplicates the item + @objc + func duplicate() { + selectedItems().forEach { item in + workspace?.workspaceFileManager?.duplicate(file: item) + } + reloadData() + } + + private func reloadData() { + sender.outlineView.reloadData() + sender.filteredContentChildren.removeAll() + } +} diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 87c93a2c4..6f8a6f30a 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift @@ -36,6 +36,10 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { func outlineViewSelectionDidChange(_ notification: Notification) { guard let outlineView = notification.object as? NSOutlineView else { return } + /// If multiple rows are selected, do not open any file. + guard outlineView.selectedRowIndexes.count == 1 else { return } + + /// If only one row is selected, proceed as before let selectedIndex = outlineView.selectedRow guard let item = outlineView.item(atRow: selectedIndex) as? CEWorkspaceFile else { return } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift index 973bbdde4..8851fd1ec 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift @@ -75,9 +75,10 @@ final class ProjectNavigatorViewController: NSViewController { self.outlineView.autosaveExpandedItems = true self.outlineView.autosaveName = workspace?.workspaceFileManager?.folderUrl.path ?? "" self.outlineView.headerView = nil - self.outlineView.menu = ProjectNavigatorMenu(sender: self.outlineView) + self.outlineView.menu = ProjectNavigatorMenu(self) self.outlineView.menu?.delegate = self self.outlineView.doubleAction = #selector(onItemDoubleClicked) + self.outlineView.allowsMultipleSelection = true self.outlineView.setAccessibilityIdentifier("ProjectNavigator") self.outlineView.setAccessibilityLabel("Project Navigator") @@ -157,6 +158,9 @@ final class ProjectNavigatorViewController: NSViewController { /// Expand or collapse the folder on double click @objc private func onItemDoubleClicked() { + /// If there are multiples items selected, don't do anything, just like in Xcode. + guard outlineView.selectedRowIndexes.count == 1 else { return } + guard let item = outlineView.item(atRow: outlineView.clickedRow) as? CEWorkspaceFile else { return } if item.isFolder {