Skip to content

Auto focus text field when creating new files and folders #2096

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CodeEdit/Features/CEWorkspace/Models/CEWorkspaceFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: ".") {
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CodeEdit/Features/CEWorkspace/Models/PhantomFile.swift
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,33 +81,22 @@ 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.
@objc
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
}
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading