Skip to content
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 @@ -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)
}
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
}
3 changes: 2 additions & 1 deletion CodeEdit/Features/LSP/Service/LSPService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -354,6 +354,7 @@ extension LSPService {
// This will always read the default value and will not update
self?.openWindow(sceneID: .settings)
}
#endif
}
}

Expand Down
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 @@ -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 {
Expand Down
Loading
Loading