Skip to content
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 @@ -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