Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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