diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index ce3a4d7c9..fa20bf37e 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift @@ -116,7 +116,7 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor /// Returns a boolean that is true if the resource represented by this object is a directory. lazy var isFolder: Bool = { - resolvedURL.isFolder + phantomFile != nil ? resolvedURL.hasDirectoryPath : resolvedURL.isFolder }() /// Returns a boolean that is true if the contents of the directory at this path are @@ -164,6 +164,9 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor FileIcon.iconColor(fileType: type) } + /// Holds information about the phantom file + var phantomFile: PhantomFile? + init( id: String, url: URL, diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index 82989fbff..30d7d0c8d 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -65,14 +65,13 @@ extension CEWorkspaceFileManager { useExtension: String? = nil, contents: Data? = nil ) throws -> CEWorkspaceFile { - // check the folder for other files, and see what the most common file extension is 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) + fileExtension = useExtension ?? "" // Don't add a . if the extension is empty, but add it if it's missing. if !fileExtension.isEmpty && !fileExtension.starts(with: ".") { @@ -117,31 +116,6 @@ extension CEWorkspaceFileManager { } } - /// 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 - } - } - - 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 diff --git a/CodeEdit/Features/CEWorkspace/Models/PhantomFile.swift b/CodeEdit/Features/CEWorkspace/Models/PhantomFile.swift new file mode 100644 index 000000000..d6112ea48 --- /dev/null +++ b/CodeEdit/Features/CEWorkspace/Models/PhantomFile.swift @@ -0,0 +1,12 @@ +// +// PhantomFile.swift +// CodeEdit +// +// Created by Abe Malla on 7/25/25. +// + +/// Represents a file that doesn't exist on disk +enum PhantomFile { + case empty + case pasteboardContent +} diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift index 1aa65af92..69a4b58a6 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift @@ -81,21 +81,10 @@ extension ProjectNavigatorMenu { try? process.run() } - // TODO: allow custom file names /// Action that creates a new untitled file @objc func newFile() { - guard let item else { return } - do { - 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() - } + createAndAddPhantomFile(isFolder: false) } /// Opens the rename file dialogue on the cell this was presented from. @@ -103,11 +92,11 @@ extension ProjectNavigatorMenu { func renameFile() { guard let newFile = workspace?.listenerModel.highlightedFileItem else { return } let row = sender.outlineView.row(forItem: newFile) - guard row > 0, + guard row >= 0, let cell = sender.outlineView.view( atColumn: 0, row: row, - makeIfNecessary: false + makeIfNecessary: true ) as? ProjectNavigatorTableViewCell else { return } @@ -118,41 +107,20 @@ extension ProjectNavigatorMenu { /// Action that creates a new file with clipboard content @objc func newFileFromClipboard() { - guard let item else { return } - do { - let clipBoardContent = NSPasteboard.general.string(forType: .string)?.data(using: .utf8) - if let clipBoardContent, !clipBoardContent.isEmpty, let newFile = try workspace? - .workspaceFileManager? - .addFile( - fileName: "untitled", - toFile: item, - contents: clipBoardContent - ) { - workspace?.listenerModel.highlightedFileItem = newFile - workspace?.editorManager?.openTab(item: newFile) - renameFile() - } - } catch { - let alert = NSAlert(error: error) - alert.addButton(withTitle: "Dismiss") - alert.runModal() + guard item != nil else { return } + let clipBoardContent = NSPasteboard.general.string(forType: .string)?.data(using: .utf8) + + guard let clipBoardContent, !clipBoardContent.isEmpty else { + return } + + createAndAddPhantomFile(isFolder: false, usePasteboardContent: true) } - // TODO: allow custom folder names /// Action that creates a new untitled folder @objc func newFolder() { - guard let item else { return } - 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() - } + createAndAddPhantomFile(isFolder: true) } /// Creates a new folder with the items selected. @@ -284,6 +252,37 @@ extension ProjectNavigatorMenu { NSPasteboard.general.setString(paths, forType: .string) } + private func createAndAddPhantomFile(isFolder: Bool, usePasteboardContent: Bool = false) { + guard let item else { return } + let file = CEWorkspaceFile( + id: UUID().uuidString, + url: item.url + .appending( + path: isFolder ? "New Folder" : "Untitled", + directoryHint: isFolder ? .isDirectory : .notDirectory + ), + changeType: nil, + staged: false + ) + file.phantomFile = usePasteboardContent ? .pasteboardContent : .empty + file.parent = item + + // Add phantom file to parent's children temporarily for display + if let workspace = workspace, + let fileManager = workspace.workspaceFileManager { + _ = fileManager.childrenOfFile(item) + fileManager.flattenedFileItems[file.id] = file + if fileManager.childrenMap[item.id] == nil { + fileManager.childrenMap[item.id] = [] + } + fileManager.childrenMap[item.id]?.append(file.id) + } + + workspace?.listenerModel.highlightedFileItem = file + sender.outlineView.reloadData() + self.renameFile() + } + private func reloadData() { sender.outlineView.reloadData() sender.filteredContentChildren.removeAll() diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift index 82db7b164..91a7d7212 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift @@ -56,15 +56,102 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { override 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 destinationURL = fileItem.url - .deletingLastPathComponent() - .appending(path: textField?.stringValue ?? "") - delegate?.moveFile(file: fileItem, to: destinationURL) + + if fileItem.phantomFile != nil { + DispatchQueue.main.async { [weak fileItem, weak self] in + guard let fileItem, let self = self else { return } + self.handlePhantomFileCompletion(fileItem: fileItem, wasCancelled: false) + } } else { - textField?.stringValue = fileItem.labelFileName() + textField?.backgroundColor = fileItem.validateFileName(for: textField?.stringValue ?? "") ? .none : errorRed + if fileItem.validateFileName(for: textField?.stringValue ?? "") { + let destinationURL = fileItem.url + .deletingLastPathComponent() + .appending(path: textField?.stringValue ?? "") + delegate?.moveFile(file: fileItem, to: destinationURL) + } else { + textField?.stringValue = fileItem.labelFileName() + } } delegate?.cellDidFinishEditing() } + + private func handlePhantomFileCompletion(fileItem: CEWorkspaceFile, wasCancelled: Bool) { + if wasCancelled { + if let workspace = delegate as? ProjectNavigatorViewController, + let workspaceFileManager = workspace.workspace?.workspaceFileManager { + removePhantomFile(fileItem: fileItem, fileManager: workspaceFileManager) + } + return + } + + let newName = textField?.stringValue ?? "" + if !newName.isEmpty && newName.isValidFilename { + if let workspace = delegate as? ProjectNavigatorViewController, + let workspaceFileManager = workspace.workspace?.workspaceFileManager, + let parent = fileItem.parent { + do { + if fileItem.isFolder { + let newFolder = try workspaceFileManager.addFolder( + folderName: newName, + toFile: parent + ) + workspace.workspace?.listenerModel.highlightedFileItem = newFolder + } else { + let newFile = try workspaceFileManager.addFile( + fileName: newName, + toFile: parent, + contents: fileItem.phantomFile == PhantomFile.pasteboardContent + ? NSPasteboard.general.string(forType: .string)?.data(using: .utf8) + : nil + ) + workspace.workspace?.listenerModel.highlightedFileItem = newFile + workspace.workspace?.editorManager?.openTab(item: newFile) + } + } catch { + let alert = NSAlert(error: error) + alert.addButton(withTitle: "Dismiss") + alert.runModal() + } + + removePhantomFile(fileItem: fileItem, fileManager: workspaceFileManager) + } + } else { + if let workspace = delegate as? ProjectNavigatorViewController, + let workspaceFileManager = workspace.workspace?.workspaceFileManager { + removePhantomFile(fileItem: fileItem, fileManager: workspaceFileManager) + } + } + } + + private func removePhantomFile(fileItem: CEWorkspaceFile, fileManager: CEWorkspaceFileManager) { + fileManager.flattenedFileItems.removeValue(forKey: fileItem.id) + + if let parent = fileItem.parent, + let childrenIds = fileManager.childrenMap[parent.id] { + fileManager.childrenMap[parent.id] = childrenIds.filter { $0 != fileItem.id } + } + + if let workspace = delegate as? ProjectNavigatorViewController { + workspace.outlineView.reloadData() + } + } + + /// Capture a cancel operation (escape key) to remove a phantom file that we are currently renaming + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + guard let fileItem, fileItem.phantomFile != nil else { return false } + + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + DispatchQueue.main.async { [weak fileItem, weak self] in + guard let fileItem, let self = self else { return } + self.handlePhantomFileCompletion(fileItem: fileItem, wasCancelled: true) + } + } + + return false + } } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 9256c3e3e..9ee8d38fc 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift @@ -44,7 +44,7 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { guard let item = outlineView.item(atRow: selectedIndex) as? CEWorkspaceFile else { return } - if !item.isFolder && shouldSendSelectionUpdate { + if !item.isFolder && item.phantomFile == nil && shouldSendSelectionUpdate { shouldSendSelectionUpdate = false if workspace?.editorManager?.activeEditor.selectedTab?.file != item { workspace?.editorManager?.activeEditor.openTab(file: item, asTemporary: true) @@ -131,6 +131,10 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { outlineView.selectRowIndexes(.init(integer: row), byExtendingSelection: false) shouldSendSelectionUpdate = true + if fileItem.phantomFile != nil { + return + } + if row < 0 { let alert = NSAlert() alert.messageText = NSLocalizedString( diff --git a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift index 2fb01159f..5d2bd0aa7 100644 --- a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift +++ b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift @@ -174,16 +174,6 @@ final class CEWorkspaceFileManagerUnitTests: XCTestCase { // 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",