From aa30305cdce7773c87789305721870464dceded4 Mon Sep 17 00:00:00 2001 From: Abe M Date: Fri, 25 Jul 2025 00:57:47 -0700 Subject: [PATCH 1/7] Added phantom files and file renaming focus --- .../CEWorkspace/Models/CEWorkspaceFile.swift | 3 + .../ProjectNavigatorMenuActions.swift | 37 +++++++--- .../ProjectNavigatorTableViewCell.swift | 71 +++++++++++++++++-- ...ViewController+NSOutlineViewDelegate.swift | 6 +- 4 files changed, 98 insertions(+), 19 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index ce3a4d7c94..f7774d8065 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift @@ -164,6 +164,9 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor FileIcon.iconColor(fileType: type) } + /// Indicates whether the file is phantom (not yet created on disk) + var isPhantom: Bool = false + init( id: String, url: URL, diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift index 1aa65af926..6b55f3c782 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift @@ -81,20 +81,35 @@ 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) + let phantomFile = CEWorkspaceFile( + id: UUID().uuidString, + url: item.url.appendingPathComponent("Untitled"), + changeType: nil, + staged: false + ) + phantomFile.isPhantom = true + phantomFile.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[phantomFile.id] = phantomFile + if fileManager.childrenMap[item.id] == nil { + fileManager.childrenMap[item.id] = [] } - } catch { - let alert = NSAlert(error: error) - alert.addButton(withTitle: "Dismiss") - alert.runModal() + fileManager.childrenMap[item.id]?.append(phantomFile.id) + } + + workspace?.listenerModel.highlightedFileItem = phantomFile + sender.outlineView.reloadData() + + DispatchQueue.main.async { + self.renameFile() } } @@ -103,11 +118,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 } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift index 82db7b1649..a9e34b2758 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift @@ -56,15 +56,72 @@ 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.isPhantom { + 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 { + let newFile = try workspaceFileManager.addFile( + fileName: newName, + toFile: parent + ) + + removePhantomFile(fileItem: fileItem, fileManager: workspaceFileManager) + 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() + } + } } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 9256c3e3e1..9d9115e4c1 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.isPhantom && 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.isPhantom { + return + } + if row < 0 { let alert = NSAlert() alert.messageText = NSLocalizedString( From 890117f6f0d4b8752441a7c3e373e01e46223eab Mon Sep 17 00:00:00 2001 From: Abe M Date: Fri, 25 Jul 2025 02:44:51 -0700 Subject: [PATCH 2/7] Added folder renaming --- .../CEWorkspace/Models/CEWorkspaceFile.swift | 2 +- ...EWorkspaceFileManager+FileManagement.swift | 43 +---------- .../ProjectNavigatorMenuActions.swift | 74 +++++++++---------- .../ProjectNavigatorTableViewCell.swift | 26 ++++--- 4 files changed, 55 insertions(+), 90 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index f7774d8065..fa85a09ae5 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 + isPhantom ? resolvedURL.hasDirectoryPath : resolvedURL.isFolder }() /// Returns a boolean that is true if the contents of the directory at this path are diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index 82989fbffc..94f5785f31 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -65,28 +65,14 @@ 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) - - // Don't add a . if the extension is empty, but add it if it's missing. - if !fileExtension.isEmpty && !fileExtension.starts(with: ".") { - fileExtension = "." + fileExtension - } - } - - var fileUrl = file.nearestFolder.appending(path: "\(fileName)\(fileExtension)") + var fileUrl = file.nearestFolder.appending(path: "\(fileName)") // 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() - .appending(path: "\(fileName)\(fileNumber)\(fileExtension)") + .appending(path: "\(fileName)\(fileNumber)") } guard fileUrl.fileName.isValidFilename else { @@ -117,31 +103,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/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift index 6b55f3c782..3c9c3644b1 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift @@ -84,33 +84,7 @@ extension ProjectNavigatorMenu { /// Action that creates a new untitled file @objc func newFile() { - guard let item else { return } - let phantomFile = CEWorkspaceFile( - id: UUID().uuidString, - url: item.url.appendingPathComponent("Untitled"), - changeType: nil, - staged: false - ) - phantomFile.isPhantom = true - phantomFile.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[phantomFile.id] = phantomFile - if fileManager.childrenMap[item.id] == nil { - fileManager.childrenMap[item.id] = [] - } - fileManager.childrenMap[item.id]?.append(phantomFile.id) - } - - workspace?.listenerModel.highlightedFileItem = phantomFile - sender.outlineView.reloadData() - - DispatchQueue.main.async { - self.renameFile() - } + createAndAddPhantomFile(isFolder: false) } /// Opens the rename file dialogue on the cell this was presented from. @@ -154,20 +128,10 @@ extension ProjectNavigatorMenu { } } - // 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. @@ -299,6 +263,40 @@ extension ProjectNavigatorMenu { NSPasteboard.general.setString(paths, forType: .string) } + private func createAndAddPhantomFile(isFolder: Bool) { + guard let item else { return } + let phantomFile = CEWorkspaceFile( + id: UUID().uuidString, + url: item.url + .appending( + path: isFolder ? "New Folder" : "Untitled", + directoryHint: isFolder ? .isDirectory : .notDirectory + ), + changeType: nil, + staged: false + ) + phantomFile.isPhantom = true + phantomFile.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[phantomFile.id] = phantomFile + if fileManager.childrenMap[item.id] == nil { + fileManager.childrenMap[item.id] = [] + } + fileManager.childrenMap[item.id]?.append(phantomFile.id) + } + + workspace?.listenerModel.highlightedFileItem = phantomFile + sender.outlineView.reloadData() + + DispatchQueue.main.async { + 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 a9e34b2758..a26e6cdb26 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift @@ -88,21 +88,27 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { let workspaceFileManager = workspace.workspace?.workspaceFileManager, let parent = fileItem.parent { do { - let newFile = try workspaceFileManager.addFile( - fileName: newName, - toFile: parent - ) - - removePhantomFile(fileItem: fileItem, fileManager: workspaceFileManager) - workspace.workspace?.listenerModel.highlightedFileItem = newFile - workspace.workspace?.editorManager?.openTab(item: newFile) - + 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 + ) + 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) } + + removePhantomFile(fileItem: fileItem, fileManager: workspaceFileManager) } } else { if let workspace = delegate as? ProjectNavigatorViewController, From a09d8d120474bc200df32ff423254b33c61554d4 Mon Sep 17 00:00:00 2001 From: Abe M Date: Fri, 25 Jul 2025 11:47:45 -0700 Subject: [PATCH 3/7] Added cancel operation check, fix bug --- .../ProjectNavigatorMenuActions.swift | 7 ++---- .../ProjectNavigatorTableViewCell.swift | 23 ++++++++++++++++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift index 3c9c3644b1..e66b7698c7 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift @@ -119,7 +119,7 @@ extension ProjectNavigatorMenu { ) { workspace?.listenerModel.highlightedFileItem = newFile workspace?.editorManager?.openTab(item: newFile) - renameFile() + self.renameFile() } } catch { let alert = NSAlert(error: error) @@ -291,10 +291,7 @@ extension ProjectNavigatorMenu { workspace?.listenerModel.highlightedFileItem = phantomFile sender.outlineView.reloadData() - - DispatchQueue.main.async { - self.renameFile() - } + self.renameFile() } private func reloadData() { diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift index a26e6cdb26..e1883b9888 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift @@ -58,7 +58,10 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { guard let fileItem else { return } if fileItem.isPhantom { - handlePhantomFileCompletion(fileItem: fileItem, wasCancelled: false) + DispatchQueue.main.async { [weak fileItem, weak self] in + guard let fileItem, let self = self else { return } + self.handlePhantomFileCompletion(fileItem: fileItem, wasCancelled: false) + } } else { textField?.backgroundColor = fileItem.validateFileName(for: textField?.stringValue ?? "") ? .none : errorRed if fileItem.validateFileName(for: textField?.stringValue ?? "") { @@ -130,4 +133,22 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { 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.isPhantom 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 + } } From 2493c660eee5b59572be9bf90ebe90f77db88ceb Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 26 Jul 2025 03:56:40 -0700 Subject: [PATCH 4/7] Fixed renaming behavior for pasted file --- .../CEWorkspace/Models/CEWorkspaceFile.swift | 6 +-- .../CEWorkspace/Models/PhantomFile.swift | 12 ++++++ .../ProjectNavigatorMenuActions.swift | 39 +++++++------------ .../ProjectNavigatorTableViewCell.swift | 9 +++-- ...ViewController+NSOutlineViewDelegate.swift | 4 +- 5 files changed, 37 insertions(+), 33 deletions(-) create mode 100644 CodeEdit/Features/CEWorkspace/Models/PhantomFile.swift diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift index fa85a09ae5..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 = { - isPhantom ? resolvedURL.hasDirectoryPath : 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,8 +164,8 @@ final class CEWorkspaceFile: Codable, Comparable, Hashable, Identifiable, Editor FileIcon.iconColor(fileType: type) } - /// Indicates whether the file is phantom (not yet created on disk) - var isPhantom: Bool = false + /// Holds information about the phantom file + var phantomFile: PhantomFile? init( id: String, 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/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift index e66b7698c7..69a4b58a6e 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorMenuActions.swift @@ -107,25 +107,14 @@ 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) - self.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) } /// Action that creates a new untitled folder @@ -263,9 +252,9 @@ extension ProjectNavigatorMenu { NSPasteboard.general.setString(paths, forType: .string) } - private func createAndAddPhantomFile(isFolder: Bool) { + private func createAndAddPhantomFile(isFolder: Bool, usePasteboardContent: Bool = false) { guard let item else { return } - let phantomFile = CEWorkspaceFile( + let file = CEWorkspaceFile( id: UUID().uuidString, url: item.url .appending( @@ -275,21 +264,21 @@ extension ProjectNavigatorMenu { changeType: nil, staged: false ) - phantomFile.isPhantom = true - phantomFile.parent = item + 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[phantomFile.id] = phantomFile + fileManager.flattenedFileItems[file.id] = file if fileManager.childrenMap[item.id] == nil { fileManager.childrenMap[item.id] = [] } - fileManager.childrenMap[item.id]?.append(phantomFile.id) + fileManager.childrenMap[item.id]?.append(file.id) } - workspace?.listenerModel.highlightedFileItem = phantomFile + workspace?.listenerModel.highlightedFileItem = file sender.outlineView.reloadData() self.renameFile() } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift index e1883b9888..91a7d7212d 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift @@ -57,7 +57,7 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { override func controlTextDidEndEditing(_ obj: Notification) { guard let fileItem else { return } - if fileItem.isPhantom { + 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) @@ -100,7 +100,10 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { } else { let newFile = try workspaceFileManager.addFile( fileName: newName, - toFile: parent + 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) @@ -140,7 +143,7 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { textView: NSTextView, doCommandBy commandSelector: Selector ) -> Bool { - guard let fileItem, fileItem.isPhantom else { return false } + guard let fileItem, fileItem.phantomFile != nil else { return false } if commandSelector == #selector(NSResponder.cancelOperation(_:)) { DispatchQueue.main.async { [weak fileItem, weak self] in diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+NSOutlineViewDelegate.swift index 9d9115e4c1..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 && !item.isPhantom && 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,7 +131,7 @@ extension ProjectNavigatorViewController: NSOutlineViewDelegate { outlineView.selectRowIndexes(.init(integer: row), byExtendingSelection: false) shouldSendSelectionUpdate = true - if fileItem.isPhantom { + if fileItem.phantomFile != nil { return } From 7cf8bc6e6c66664e7dc69e6b2672827685c3aff6 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sat, 26 Jul 2025 04:04:39 -0700 Subject: [PATCH 5/7] Fix tests --- .../CEWorkspaceFileManager+FileManagement.swift | 17 +++++++++++++++-- .../CEWorkspaceFileManagerTests.swift | 10 ---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift index 94f5785f31..30d7d0c8d1 100644 --- a/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift +++ b/CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFileManager+FileManagement.swift @@ -66,13 +66,26 @@ extension CEWorkspaceFileManager { contents: Data? = nil ) throws -> CEWorkspaceFile { do { - var fileUrl = file.nearestFolder.appending(path: "\(fileName)") + 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 ?? "" + + // Don't add a . if the extension is empty, but add it if it's missing. + if !fileExtension.isEmpty && !fileExtension.starts(with: ".") { + fileExtension = "." + fileExtension + } + } + + var fileUrl = file.nearestFolder.appending(path: "\(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() - .appending(path: "\(fileName)\(fileNumber)") + .appending(path: "\(fileName)\(fileNumber)\(fileExtension)") } guard fileUrl.fileName.isValidFilename else { 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", From 3e4eec76d87fb862a5c8e838e5f67aa04916db81 Mon Sep 17 00:00:00 2001 From: Abe M Date: Wed, 29 Oct 2025 18:28:50 -0700 Subject: [PATCH 6/7] Fix file not being created if FS event occured --- ...WorkspaceFileManager+DirectoryEvents.swift | 6 +++ .../ProjectNavigatorOutlineView.swift | 52 ++++++++++++++++--- .../ProjectNavigatorTableViewCell.swift | 8 +-- ...troller+OutlineTableViewCellDelegate.swift | 1 - .../ProjectNavigatorViewController.swift | 2 - 5 files changed, 57 insertions(+), 12 deletions(-) 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/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index a072d80c27..df7555dc0f 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -85,15 +85,49 @@ 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) + // Find the cell being edited by traversing up from the text view + var currentView = outlineView.window?.firstResponder as? NSView + capturedSelectionRange = (outlineView.window?.firstResponder as? NSTextView)?.selectedRange + + 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) + } + + // 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 + } } } @@ -102,6 +136,12 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { controller?.shouldSendSelectionUpdate = false outlineView.selectRowIndexes(IndexSet(selectedIndexes), byExtendingSelection: false) controller?.shouldSendSelectionUpdate = true + + // Reselect the file that is currently active in the editor so it still appears highlighted + if outlineView.selectedRowIndexes.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 91a7d7212d..69ad1cff4b 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorTableViewCell.swift @@ -58,9 +58,11 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { guard let fileItem else { return } 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) + self.handlePhantomFileCompletion(fileItem: fileItem, wasCancelled: false, enteredName: enteredName) } } else { textField?.backgroundColor = fileItem.validateFileName(for: textField?.stringValue ?? "") ? .none : errorRed @@ -76,7 +78,7 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { delegate?.cellDidFinishEditing() } - private func handlePhantomFileCompletion(fileItem: CEWorkspaceFile, wasCancelled: Bool) { + private func handlePhantomFileCompletion(fileItem: CEWorkspaceFile, wasCancelled: Bool, enteredName: String = "") { if wasCancelled { if let workspace = delegate as? ProjectNavigatorViewController, let workspaceFileManager = workspace.workspace?.workspaceFileManager { @@ -85,7 +87,7 @@ final class ProjectNavigatorTableViewCell: FileSystemTableViewCell { return } - let newName = textField?.stringValue ?? "" + let newName = enteredName.isEmpty ? (textField?.stringValue ?? "") : enteredName if !newName.isEmpty && newName.isValidFilename { if let workspace = delegate as? ProjectNavigatorViewController, let workspaceFileManager = workspace.workspace?.workspaceFileManager, diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift index 91d68b42e2..4669e7a275 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift @@ -41,7 +41,6 @@ extension ProjectNavigatorViewController: OutlineTableViewCellDelegate { } func cellDidFinishEditing() { - guard shouldReloadAfterDoneEditing else { return } outlineView.reloadData() } } 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 } From 0774e412dab7689892897a22bf7a149fec922886 Mon Sep 17 00:00:00 2001 From: Abe M Date: Thu, 30 Oct 2025 12:29:25 -0700 Subject: [PATCH 7/7] Fix edge cases and lint warnings --- .../Features/LSP/Service/LSPService.swift | 3 +- .../ProjectNavigatorOutlineView.swift | 36 +++++++++---------- ...troller+OutlineTableViewCellDelegate.swift | 4 +-- 3 files changed, 20 insertions(+), 23 deletions(-) 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/ProjectNavigatorOutlineView.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift index df7555dc0f..1d99ee3b0d 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorOutlineView.swift @@ -93,14 +93,13 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { if outlineView.window?.firstResponder !== outlineView && outlineView.window?.firstResponder is NSTextView && (outlineView.window?.firstResponder as? NSView)?.isDescendant(of: outlineView) == true { - // Find the cell being edited by traversing up from the text view - var currentView = outlineView.window?.firstResponder as? NSView 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 { + let fileItem = cell.fileItem, fileItem.phantomFile != nil { editingPhantomFile = fileItem capturedText = cell.textField?.stringValue break @@ -114,33 +113,32 @@ struct ProjectNavigatorOutlineView: NSViewControllerRepresentable { 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( + if row >= 0, let cell = outlineView.view( atColumn: 0, row: row, makeIfNecessary: false - ) as? ProjectNavigatorTableViewCell { + ) as? ProjectNavigatorTableViewCell { cell.textField?.stringValue = text outlineView.window?.makeFirstResponder(cell.textField) if let selectionRange = capturedSelectionRange { cell.textField?.currentEditor()?.selectedRange = selectionRange } } - } - - // 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 - - // Reselect the file that is currently active in the editor so it still appears highlighted - if outlineView.selectedRowIndexes.isEmpty, - let activeFileID = workspace?.editorManager?.activeEditor.selectedTab?.file.id { - controller?.updateSelection(itemID: activeFileID) + } 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) + } } } diff --git a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift index 4669e7a275..520124de8c 100644 --- a/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift +++ b/CodeEdit/Features/NavigatorArea/ProjectNavigator/OutlineView/ProjectNavigatorViewController+OutlineTableViewCellDelegate.swift @@ -40,7 +40,5 @@ extension ProjectNavigatorViewController: OutlineTableViewCellDelegate { } } - func cellDidFinishEditing() { - outlineView.reloadData() - } + func cellDidFinishEditing() { } }