diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index ce3a4d7c94..fa20bf37ee 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+DirectoryEvents.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift index 25715b51c2..11bbce1594 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+DirectoryEvents.swift @@ -152,6 +152,12 @@ extension CEWorkspaceFileManager { let directoryContentsUrlsRelativePaths = directoryContentsUrls.map({ $0.relativePath }) for (idx, oldURL) in (childrenMap[fileItem.id] ?? []).map({ URL(filePath: $0) }).enumerated().reversed() where !directoryContentsUrlsRelativePaths.contains(oldURL.relativePath) { + // Don't remove phantom files, they don't exist on disk yet + // They will be cleaned up when the user finishes editing + if let existingFile = flattenedFileItems[oldURL.relativePath], + existingFile.phantomFile != nil { + continue + } flattenedFileItems.removeValue(forKey: oldURL.relativePath) childrenMap[fileItem.id]?.remove(at: idx) } diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index 82989fbffc..30d7d0c8d1 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 0000000000..d6112ea488 --- /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/LSP/Service/LSPService.swift b/CodeEdit/Features/LSP/Service/LSPService.swift index 8be58a3c58..4ddf716bea 100644 --- a/CodeEdit/Features/LSP/Service/LSPService.swift +++ b/CodeEdit/Features/LSP/Service/LSPService.swift @@ -334,7 +334,7 @@ final class LSPService: ObservableObject { extension LSPService { private func notifyToInstallLanguageServer(language lspLanguage: LanguageIdentifier) { // TODO: Re-Enable when this is more fleshed out (don't send duplicate notifications in a session) - return +#if false let lspLanguageTitle = lspLanguage.rawValue.capitalized let notificationTitle = "Install \(lspLanguageTitle) Language Server" // Make sure the user doesn't have the same existing notification @@ -354,6 +354,7 @@ extension LSPService { // This will always read the default value and will not update self?.openWindow(sceneID: .settings) } +#endif } } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift index 1aa65af926..69a4b58a6e 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/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index a072d80c27..1d99ee3b0d 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -85,23 +85,61 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { guard let outlineView = controller?.outlineView else { return } let selectedRows = outlineView.selectedRowIndexes.compactMap({ outlineView.item(atRow: $0) }) - // If some text view inside the outline view is first responder right now, push the update off - // until editing is finished using the `shouldReloadAfterDoneEditing` flag. + // Check if we're currently editing a phantom file and capture its text + var editingPhantomFile: CEWorkspaceFile? + var capturedText: String? + var capturedSelectionRange: NSRange? + if outlineView.window?.firstResponder !== outlineView && outlineView.window?.firstResponder is NSTextView && (outlineView.window?.firstResponder as? NSView)?.isDescendant(of: outlineView) == true { - controller?.shouldReloadAfterDoneEditing = true - } else { - for item in updatedItems { - outlineView.reloadItem(item, reloadChildren: true) + capturedSelectionRange = (outlineView.window?.firstResponder as? NSTextView)?.selectedRange + + // Find the cell being edited by traversing up from the text view + var currentView = outlineView.window?.firstResponder as? NSView + while let view = currentView { + if let cell = view as? ProjectNavigatorTableViewCell, + let fileItem = cell.fileItem, fileItem.phantomFile != nil { + editingPhantomFile = fileItem + capturedText = cell.textField?.stringValue + break + } + currentView = view.superview } } + // Reload all items with children + 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 + + // If we were editing a phantom file, restore the text field and focus + if let phantomFile = editingPhantomFile, let text = capturedText { + let row = outlineView.row(forItem: phantomFile) + if row >= 0, let cell = outlineView.view( + atColumn: 0, + row: row, + makeIfNecessary: false + ) as? ProjectNavigatorTableViewCell { + cell.textField?.stringValue = text + outlineView.window?.makeFirstResponder(cell.textField) + if let selectionRange = capturedSelectionRange { + cell.textField?.currentEditor()?.selectedRange = selectionRange + } + } + } else { + // Reselect the file that is currently active in the editor so it still appears highlighted + if selectedIndexes.isEmpty, + let activeFileID = workspace?.editorManager?.activeEditor.selectedTab?.file.id { + controller?.updateSelection(itemID: activeFileID) + } + } } deinit { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift index 82db7b1649..69ad1cff4b 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift @@ -56,15 +56,104 @@ 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 { + // Capture the text field value before any async work + let enteredName = textField?.stringValue ?? "" + DispatchQueue.main.async { [weak fileItem, weak self] in + guard let fileItem, let self = self else { return } + self.handlePhantomFileCompletion(fileItem: fileItem, wasCancelled: false, enteredName: enteredName) + } } 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, enteredName: String = "") { + if wasCancelled { + if let workspace = delegate as? ProjectNavigatorViewController, + let workspaceFileManager = workspace.workspace?.workspaceFileManager { + removePhantomFile(fileItem: fileItem, fileManager: workspaceFileManager) + } + return + } + + let newName = enteredName.isEmpty ? (textField?.stringValue ?? "") : enteredName + 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 9256c3e3e1..9ee8d38fc2 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/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift index 91d68b42e2..520124de8c 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift @@ -40,8 +40,5 @@ extension ProjectNavigatorViewController: OutlineTableViewCellDelegate { } } - func cellDidFinishEditing() { - guard shouldReloadAfterDoneEditing else { return } - outlineView.reloadData() - } + func cellDidFinishEditing() { } } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift index f681705351..c71d8d2fac 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController.swift @@ -64,8 +64,6 @@ final class ProjectNavigatorViewController: NSViewController { /// to open the file a second time. var shouldSendSelectionUpdate: Bool = true - var shouldReloadAfterDoneEditing: Bool = false - var filterIsEmpty: Bool { workspace?.navigatorFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true } diff --git a/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift b/CodeEditTests/Utils/CEWorkspaceFileManager/CEWorkspaceFileManagerTests.swift index 2fb01159fd..5d2bd0aa74 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",