diff --git a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift index cc125cad4..bfab19f41 100644 --- a/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift +++ b/CodeEdit/Features/CEWorkspace/Models/DirectoryEventStream.swift @@ -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 { + var events: Set = [] + 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 } } diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 4e1638b9d..8fd36004b 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -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 diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift index 7cf4187d3..b0f3a1baa 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift @@ -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 ) { - 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) + } } } diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift new file mode 100644 index 000000000..c9e78d788 --- /dev/null +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift @@ -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) { + Task { @MainActor in + if let parent = tabFile.parent, updatedItems.contains(parent) { + isDeleted = tabFile.doesExist == false + } + } + } +} diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Views/EditorTabs.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Views/EditorTabs.swift index a10dc7839..1bcc3639c 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Views/EditorTabs.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Views/EditorTabs.swift @@ -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,