Skip to content

Add indication for externally deleted files #1999

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

Merged
merged 9 commits into from
Mar 13, 2025
63 changes: 39 additions & 24 deletions CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift
Original file line number Diff line number Diff line change
@@ -138,43 +138,58 @@ class DirectoryEventStream {

for (index, dictionary) in eventDictionaries.enumerated() {
// Get get file id use dictionary[kFSEventStreamEventExtendedFileIDKey] as? UInt64
guard let path = dictionary[kFSEventStreamEventExtendedDataPathKey] as? String,
let event = getEventFromFlags(eventFlags[index])
guard let path = dictionary[kFSEventStreamEventExtendedDataPathKey] as? String
else {
continue
}

events.append(.init(path: path, eventType: event))
let fsEvents = getEventsFromFlags(eventFlags[index])

for event in fsEvents {
events.append(.init(path: path, eventType: event))
}
}

callback(events)
}

/// Parses an ``FSEvent`` from the raw flag value.
/// Parses ``FSEvent`` from the raw flag value.
///
/// There can be multiple events in the raw flag value,
/// bacause of how OS processes almost simlutaneous actions – thus this functions returns a `Set` of `FSEvent`.
///
/// Often returns ``FSEvent/changeInDirectory`` as `FSEventStream` returns
/// Often returns ``[FSEvent/changeInDirectory]`` as `FSEventStream` returns
/// `kFSEventStreamEventFlagNone (0x00000000)` frequently without more information.
/// - Parameter raw: The int value received from the FSEventStream
/// - Returns: An ``FSEvent`` if a valid one was found, or `nil` otherwise.
func getEventFromFlags(_ raw: FSEventStreamEventFlags) -> FSEvent? {
/// - Returns: A `Set` of ``FSEvent``'s if at least one valid was found, or `[]` otherwise.
private func getEventsFromFlags(_ raw: FSEventStreamEventFlags) -> Set<FSEvent> {
var events: Set<FSEvent> = []

if raw == 0 {
return .changeInDirectory
} else if raw & UInt32(kFSEventStreamEventFlagRootChanged) > 0 {
return .rootChanged
} else if raw & UInt32(kFSEventStreamEventFlagItemChangeOwner) > 0 {
return .itemChangedOwner
} else if raw & UInt32(kFSEventStreamEventFlagItemCreated) > 0 {
return .itemCreated
} else if raw & UInt32(kFSEventStreamEventFlagItemCloned) > 0 {
return .itemCloned
} else if raw & UInt32(kFSEventStreamEventFlagItemModified) > 0 {
return .itemModified
} else if raw & UInt32(kFSEventStreamEventFlagItemRemoved) > 0 {
return .itemRemoved
} else if raw & UInt32(kFSEventStreamEventFlagItemRenamed) > 0 {
return .itemRenamed
} else {
return nil
events.insert(.changeInDirectory)
}
if raw & UInt32(kFSEventStreamEventFlagRootChanged) > 0 {
events.insert(.rootChanged)
}
if raw & UInt32(kFSEventStreamEventFlagItemChangeOwner) > 0 {
events.insert(.itemChangedOwner)
}
if raw & UInt32(kFSEventStreamEventFlagItemCreated) > 0 {
events.insert(.itemCreated)
}
if raw & UInt32(kFSEventStreamEventFlagItemCloned) > 0 {
events.insert(.itemCloned)
}
if raw & UInt32(kFSEventStreamEventFlagItemModified) > 0 {
events.insert(.itemModified)
}
if raw & UInt32(kFSEventStreamEventFlagItemRemoved) > 0 {
events.insert(.itemRemoved)
}
if raw & UInt32(kFSEventStreamEventFlagItemRenamed) > 0 {
events.insert(.itemRenamed)
}

return events
}
}
Original file line number Diff line number Diff line change
@@ -191,6 +191,23 @@ final class CodeFileDocument: NSDocument, ObservableObject {
NotificationCenter.default.post(name: Self.didCloseNotification, object: fileURL)
}

override func save(_ sender: Any?) {
guard let fileURL else {
super.save(sender)
return
}

do {
// Get parent directory for cases when entire folders were deleted – and recreate them as needed
let directory = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)

try data(ofType: fileType ?? "").write(to: fileURL, options: .atomic)
} catch {
presentError(error)
}
}

func getLanguage() -> CodeLanguage {
guard let url = fileURL else {
return .default
47 changes: 29 additions & 18 deletions CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift
Original file line number Diff line number Diff line change
@@ -21,8 +21,11 @@ struct EditorTabView: View {
@Environment(\.isFullscreen)
private var isFullscreen

@EnvironmentObject var workspace: WorkspaceDocument
@EnvironmentObject private var editorManager: EditorManager

@StateObject private var fileObserver: EditorTabFileObserver

@AppSettings(\.general.fileIconStyle)
var fileIconStyle

@@ -54,25 +57,25 @@ struct EditorTabView: View {

@EnvironmentObject private var editor: Editor

/// The item associated with the current tab.
/// The file item associated with the current tab.
///
/// You can get tab-related information from here, like `label`, `icon`, etc.
private var item: CEWorkspaceFile
private let tabFile: CEWorkspaceFile

var index: Int

private var isTemporary: Bool {
editor.temporaryTab?.file == item
editor.temporaryTab?.file == tabFile
}

/// Is the current tab the active tab.
private var isActive: Bool {
item == editor.selectedTab?.file
tabFile == editor.selectedTab?.file
}

/// Is the current tab being dragged.
private var isDragging: Bool {
draggingTabId == item.id
draggingTabId == tabFile.id
}

/// Is the current tab being held (by click and hold, not drag).
@@ -86,9 +89,9 @@ struct EditorTabView: View {
private func switchAction() {
// Only set the `selectedId` when they are not equal to avoid performance issue for now.
editorManager.activeEditor = editor
if editor.selectedTab?.file != item {
let tabItem = EditorInstance(file: item)
editor.setSelectedTab(item)
if editor.selectedTab?.file != tabFile {
let tabItem = EditorInstance(file: tabFile)
editor.setSelectedTab(tabFile)
editor.clearFuture()
editor.addToHistory(tabItem)
}
@@ -97,21 +100,22 @@ struct EditorTabView: View {
/// Close the current tab.
func closeAction() {
isAppeared = false
editor.closeTab(file: item)
editor.closeTab(file: tabFile)
}

init(
item: CEWorkspaceFile,
file: CEWorkspaceFile,
index: Int,
draggingTabId: CEWorkspaceFile.ID?,
onDragTabId: CEWorkspaceFile.ID?,
closeButtonGestureActive: Binding<Bool>
) {
self.item = item
self.tabFile = file
self.index = index
self.draggingTabId = draggingTabId
self.onDragTabId = onDragTabId
self._closeButtonGestureActive = closeButtonGestureActive
self._fileObserver = StateObject(wrappedValue: EditorTabFileObserver(file: file))
}

@ViewBuilder var content: some View {
@@ -122,26 +126,27 @@ struct EditorTabView: View {
)
// Tab content (icon and text).
HStack(alignment: .center, spacing: 3) {
Image(nsImage: item.nsIcon)
Image(nsImage: tabFile.nsIcon)
.frame(width: 16, height: 16)
.foregroundColor(
fileIconStyle == .color
&& activeState != .inactive && isActiveEditor
? item.iconColor
? tabFile.iconColor
: .secondary
)
Text(item.name)
Text(tabFile.name)
.font(
isTemporary
? .system(size: 11.0).italic()
: .system(size: 11.0)
)
.lineLimit(1)
.strikethrough(fileObserver.isDeleted, color: .primary)
}
.frame(maxHeight: .infinity) // To max-out the parent (tab bar) area.
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isStaticText)
.accessibilityLabel(item.name)
.accessibilityLabel(tabFile.name)
.padding(.horizontal, 20)
.overlay {
ZStack {
@@ -152,7 +157,7 @@ struct EditorTabView: View {
isDragging: draggingTabId != nil || onDragTabId != nil,
closeAction: closeAction,
closeButtonGestureActive: $closeButtonGestureActive,
item: item,
item: tabFile,
isHoveringClose: $isHoveringClose
)
}
@@ -227,8 +232,14 @@ struct EditorTabView: View {
}
)
.zIndex(isActive ? 2 : (isDragging ? 3 : (isPressing ? 1 : 0)))
.id(item.id)
.tabBarContextMenu(item: item, isTemporary: isTemporary)
.id(tabFile.id)
.tabBarContextMenu(item: tabFile, isTemporary: isTemporary)
.accessibilityElement(children: .contain)
.onAppear {
workspace.workspaceFileManager?.addObserver(fileObserver)
}
.onDisappear {
workspace.workspaceFileManager?.removeObserver(fileObserver)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// EditorTabFileObserver.swift
// CodeEdit
//
// Created by Filipp Kuznetsov on 25.02.2025.
//

import Foundation
import SwiftUI

/// Observer ViewModel for tracking file deletion
@MainActor
final class EditorTabFileObserver: ObservableObject,
CEWorkspaceFileManagerObserver {
@Published private(set) var isDeleted = false

private let tabFile: CEWorkspaceFile

init(file: CEWorkspaceFile) {
self.tabFile = file
}

nonisolated func fileManagerUpdated(updatedItems: Set<CEWorkspaceFile>) {
Task { @MainActor in
if let parent = tabFile.parent, updatedItems.contains(parent) {
isDeleted = tabFile.doesExist == false
}
}
}
}
Original file line number Diff line number Diff line change
@@ -261,7 +261,7 @@ struct EditorTabs: View {
ForEach(Array(openedTabs.enumerated()), id: \.element) { index, id in
if let item = editor.tabs.first(where: { $0.file.id == id }) {
EditorTabView(
item: item.file,
file: item.file,
index: index,
draggingTabId: draggingTabId,
onDragTabId: onDragTabId,