From 86323d1a4ed2f412eccacfcc1929b43f637daf9a Mon Sep 17 00:00:00 2001 From: Filipp Kuznetsov Date: Tue, 25 Feb 2025 11:30:21 +0500 Subject: [PATCH 1/9] Add tab bar indication whether file was deleted or restored externally --- .../TabBar/Tabs/Tab/EditorTabView.swift | 13 +++++++++- .../Tab/Models/EditorTabFileObserver.swift | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift index 7cf4187d3..215045126 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 @@ -57,7 +60,7 @@ struct EditorTabView: View { /// The item associated with the current tab. /// /// You can get tab-related information from here, like `label`, `icon`, etc. - private var item: CEWorkspaceFile + private let item: CEWorkspaceFile var index: Int @@ -112,6 +115,7 @@ struct EditorTabView: View { self.draggingTabId = draggingTabId self.onDragTabId = onDragTabId self._closeButtonGestureActive = closeButtonGestureActive + self._fileObserver = StateObject(wrappedValue: EditorTabFileObserver(item: item)) } @ViewBuilder var content: some View { @@ -137,6 +141,7 @@ struct EditorTabView: View { : .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) @@ -230,5 +235,11 @@ struct EditorTabView: View { .id(item.id) .tabBarContextMenu(item: item, 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..b4d647d6a --- /dev/null +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift @@ -0,0 +1,25 @@ +// +// EditorTabFileObserver.swift +// CodeEdit +// +// Created by Filipp Kuznetsov on 25.02.2025. +// + +import Foundation +import SwiftUI + +final class EditorTabFileObserver: ObservableObject, CEWorkspaceFileManagerObserver { + @Published private(set) var isDeleted = false + + private let item: CEWorkspaceFile + + init(item: CEWorkspaceFile) { + self.item = item + } + + func fileManagerUpdated(updatedItems: Set) { + if updatedItems.contains(item) { + isDeleted = item.doesExist == false + } + } +} From 2d843a6803beb34530022b6b7ac2b586442b0f12 Mon Sep 17 00:00:00 2001 From: Filipp Kuznetsov Date: Tue, 25 Feb 2025 12:24:03 +0500 Subject: [PATCH 2/9] Add externally deleted file saving --- .../CodeFileDocument/CodeFileDocument.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index 4e1638b9d..f5aea4a50 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -191,6 +191,22 @@ 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 { + 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 From 194cea2636b94ae7bf7830ae97e32c4417b8c6c6 Mon Sep 17 00:00:00 2001 From: Filipp Kuznetsov Date: Tue, 25 Feb 2025 12:46:01 +0500 Subject: [PATCH 3/9] Add docstring and comments --- .../Features/Documents/CodeFileDocument/CodeFileDocument.swift | 1 + .../Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift index f5aea4a50..8fd36004b 100644 --- a/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift +++ b/CodeEdit/Features/Documents/CodeFileDocument/CodeFileDocument.swift @@ -198,6 +198,7 @@ final class CodeFileDocument: NSDocument, ObservableObject { } 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) diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift index b4d647d6a..45f36cb22 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI +/// Observer ViewModel for tracking file deletion final class EditorTabFileObserver: ObservableObject, CEWorkspaceFileManagerObserver { @Published private(set) var isDeleted = false From 9f3668864dd515157ec5dff8a6562e8d3088ee3b Mon Sep 17 00:00:00 2001 From: Filipp Kuznetsov Date: Tue, 25 Feb 2025 14:23:44 +0500 Subject: [PATCH 4/9] Add handling for simultaneous FSEvent on a single file --- .../Models/DirectoryEventStream.swift | 63 ++++++++++++------- 1 file changed, 39 insertions(+), 24 deletions(-) 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 } } From ab96c1be3b996cd44e53736697988e9f74c9b80c Mon Sep 17 00:00:00 2001 From: Filipp Kuznetsov Date: Tue, 25 Feb 2025 14:24:28 +0500 Subject: [PATCH 5/9] Fix check for a file being deleted --- .../TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift index 45f36cb22..3380dc521 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift @@ -19,7 +19,11 @@ final class EditorTabFileObserver: ObservableObject, CEWorkspaceFileManagerObser } func fileManagerUpdated(updatedItems: Set) { - if updatedItems.contains(item) { + guard let parent = item.parent else { + return + } + + if updatedItems.contains(parent) { isDeleted = item.doesExist == false } } From 37e971e350a962345a5122bc5538a33b1e12229f Mon Sep 17 00:00:00 2001 From: Filipp Kuznetsov Date: Fri, 28 Feb 2025 11:23:55 +0500 Subject: [PATCH 6/9] Improve property naming and readability --- .../TabBar/Tabs/Tab/EditorTabView.swift | 38 +++++++++---------- .../Tab/Models/EditorTabFileObserver.swift | 14 +++---- .../Editor/TabBar/Tabs/Views/EditorTabs.swift | 2 +- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift index 215045126..b0f3a1baa 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/EditorTabView.swift @@ -57,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 let 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). @@ -89,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) } @@ -100,22 +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(item: item)) + self._fileObserver = StateObject(wrappedValue: EditorTabFileObserver(file: file)) } @ViewBuilder var content: some View { @@ -126,15 +126,15 @@ 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() @@ -146,7 +146,7 @@ struct EditorTabView: View { .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 { @@ -157,7 +157,7 @@ struct EditorTabView: View { isDragging: draggingTabId != nil || onDragTabId != nil, closeAction: closeAction, closeButtonGestureActive: $closeButtonGestureActive, - item: item, + item: tabFile, isHoveringClose: $isHoveringClose ) } @@ -232,8 +232,8 @@ 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) diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift index 3380dc521..c2adf11ea 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift @@ -12,19 +12,15 @@ import SwiftUI final class EditorTabFileObserver: ObservableObject, CEWorkspaceFileManagerObserver { @Published private(set) var isDeleted = false - private let item: CEWorkspaceFile + private let tabFile: CEWorkspaceFile - init(item: CEWorkspaceFile) { - self.item = item + init(file: CEWorkspaceFile) { + self.tabFile = file } func fileManagerUpdated(updatedItems: Set) { - guard let parent = item.parent else { - return - } - - if updatedItems.contains(parent) { - isDeleted = item.doesExist == false + 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, From b9aba2c5bc9d51050c645b7aabac2a782589a7a2 Mon Sep 17 00:00:00 2001 From: Filipp Kuznetsov Date: Fri, 28 Feb 2025 11:24:31 +0500 Subject: [PATCH 7/9] Add MainActor to the view model --- .../Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift index c2adf11ea..d733cd7ac 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift @@ -9,7 +9,8 @@ import Foundation import SwiftUI /// Observer ViewModel for tracking file deletion -final class EditorTabFileObserver: ObservableObject, CEWorkspaceFileManagerObserver { +@MainActor +final class EditorTabFileObserver: ObservableObject, @preconcurrency CEWorkspaceFileManagerObserver { @Published private(set) var isDeleted = false private let tabFile: CEWorkspaceFile From aaee4b471e4fb5907b53d5d2c1b18b01ee0fd224 Mon Sep 17 00:00:00 2001 From: Filipp Kuznetsov Date: Wed, 12 Mar 2025 10:11:49 +0500 Subject: [PATCH 8/9] Fix concurrency issues without using Swift 6 mode --- .../Tabs/Tab/Models/EditorTabFileObserver.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift index d733cd7ac..066772e37 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift @@ -10,7 +10,9 @@ import SwiftUI /// Observer ViewModel for tracking file deletion @MainActor -final class EditorTabFileObserver: ObservableObject, @preconcurrency CEWorkspaceFileManagerObserver { +final class EditorTabFileObserver: ObservableObject, + CEWorkspaceFileManagerObserver +{ @Published private(set) var isDeleted = false private let tabFile: CEWorkspaceFile @@ -19,9 +21,11 @@ final class EditorTabFileObserver: ObservableObject, @preconcurrency CEWorkspace self.tabFile = file } - func fileManagerUpdated(updatedItems: Set) { - if let parent = tabFile.parent, updatedItems.contains(parent) { - isDeleted = tabFile.doesExist == false + nonisolated func fileManagerUpdated(updatedItems: Set) { + Task { @MainActor in + if let parent = tabFile.parent, updatedItems.contains(parent) { + isDeleted = tabFile.doesExist == false + } } } } From b7561639e7d7db48859bbd55f1106c5cc4a7389e Mon Sep 17 00:00:00 2001 From: Filipp Kuznetsov Date: Wed, 12 Mar 2025 13:21:14 +0500 Subject: [PATCH 9/9] Fix lint error --- .../Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift index 066772e37..c9e78d788 100644 --- a/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift +++ b/CodeEdit/Features/Editor/TabBar/Tabs/Tab/Models/EditorTabFileObserver.swift @@ -11,8 +11,7 @@ import SwiftUI /// Observer ViewModel for tracking file deletion @MainActor final class EditorTabFileObserver: ObservableObject, - CEWorkspaceFileManagerObserver -{ + CEWorkspaceFileManagerObserver { @Published private(set) var isDeleted = false private let tabFile: CEWorkspaceFile