diff --git a/Examples/UndoForMacOS/UndoForMacOSApp.swift b/Examples/UndoForMacOS/UndoForMacOSApp.swift index 6e4bb2c..75a0644 100644 --- a/Examples/UndoForMacOS/UndoForMacOSApp.swift +++ b/Examples/UndoForMacOS/UndoForMacOSApp.swift @@ -37,6 +37,7 @@ struct DemoFeature { enum Action: UndoManageableAction { case undoManager(UndoManagingAction) case addItem + case addItemInBackground case addUntrackedItem case incrementCount(Int) case incrementAll @@ -64,6 +65,16 @@ struct DemoFeature { } return .none + case .addItemInBackground: + return .run { _ in + try await undoable("Add Item (Background)") { + try await database.write { db in + let nextID = (try DemoItem.all.fetchAll(db).map(\.id).max() ?? 0) + 1 + try DemoItem.insert { DemoItem(id: nextID, name: "Item \(nextID)") }.execute(db) + } + } + } + case .addUntrackedItem: withErrorReporting { try undoable("Add Untracked Item") { @@ -113,6 +124,7 @@ struct DemoFeature { struct DemoView: View { @Bindable var store: StoreOf @Environment(\.undoManager) var undoManager + @State private var observableUndo = ObservableUndoManager() var body: some View { VStack(spacing: 16) { @@ -124,8 +136,8 @@ struct DemoView: View { .foregroundStyle(.secondary) HStack { - Button("Undo") { undoManager?.undo() }.disabled(!(undoManager?.canUndo ?? false)) - Button("Redo") { undoManager?.redo() }.disabled(!(undoManager?.canRedo ?? false)) + Button("Undo") { observableUndo.undo() }.disabled(!observableUndo.canUndo) + Button("Redo") { observableUndo.redo() }.disabled(!observableUndo.canRedo) } List { @@ -154,6 +166,10 @@ struct DemoView: View { store.send(.addItem) } .buttonStyle(.borderedProminent) + Button("Add Item (Background)") { + store.send(.addItemInBackground) + } + .buttonStyle(.bordered) Button("Increment All") { store.send(.incrementAll) } @@ -169,6 +185,62 @@ struct DemoView: View { .padding() .frame(width: 400) .setUndoManager(store: store) + .onChange(of: undoManager, initial: true) { _, newValue in observableUndo.set(newValue) } + } +} + +/// Makes UndoManager's canUndo/canRedo state observable by SwiftUI. +/// +/// UndoManager doesn't participate in SwiftUI's observation system, so +/// changes to canUndo/canRedo don't trigger view updates. This wrapper +/// listens to NSUndoManager notifications and exposes observable properties. +@Observable +final class ObservableUndoManager { + private(set) var canUndo = false + private(set) var canRedo = false + + private var undoManager: UndoManager? + private var observations: [Any] = [] + + func set(_ undoManager: UndoManager?) { + self.undoManager = undoManager + observations.removeAll() + guard let undoManager else { + canUndo = false + canRedo = false + return + } + update() + let nc = NotificationCenter.default + let handler: (Notification) -> Void = { [weak self] _ in self?.update() } + observations = [ + nc.addObserver( + forName: .NSUndoManagerDidCloseUndoGroup, + object: undoManager, + queue: .main, + using: handler + ), + nc.addObserver( + forName: .NSUndoManagerDidUndoChange, + object: undoManager, + queue: .main, + using: handler + ), + nc.addObserver( + forName: .NSUndoManagerDidRedoChange, + object: undoManager, + queue: .main, + using: handler + ), + ] + } + + func undo() { undoManager?.undo() } + func redo() { undoManager?.redo() } + + private func update() { + canUndo = undoManager?.canUndo ?? false + canRedo = undoManager?.canRedo ?? false } } diff --git a/Sources/SQLiteUndo/UndoEngine.swift b/Sources/SQLiteUndo/UndoEngine.swift index 1d025c2..6dfea8a 100644 --- a/Sources/SQLiteUndo/UndoEngine.swift +++ b/Sources/SQLiteUndo/UndoEngine.swift @@ -183,13 +183,11 @@ extension UndoEngine: DependencyKey { guard let barrier = try coordinator.endBarrier(id) else { return } - MainActor.assumeIsolated { - undoStack.registerBarrier( - barrier, - { try coordinator.performUndo(barrier: barrier) }, - { try coordinator.performRedo(barrier: barrier) } - ) - } + undoStack.registerBarrier( + barrier, + { try coordinator.performUndo(barrier: barrier) }, + { try coordinator.performRedo(barrier: barrier) } + ) }, cancelBarrier: { id in try coordinator.cancelBarrier(id) diff --git a/Sources/SQLiteUndo/UndoStack.swift b/Sources/SQLiteUndo/UndoStack.swift index 13b05ba..91def7d 100644 --- a/Sources/SQLiteUndo/UndoStack.swift +++ b/Sources/SQLiteUndo/UndoStack.swift @@ -27,7 +27,7 @@ public struct UndoStack: Sendable { /// /// Called by UndoEngine when a barrier completes with changes. public var registerBarrier: - @MainActor @Sendable ( + @Sendable ( _ barrier: UndoBarrier, _ onUndo: @escaping @Sendable () throws -> Void, _ onRedo: @escaping @Sendable () throws -> Void @@ -144,7 +144,8 @@ extension UndoStack: DependencyKey { } if let self { logger.info( - "\(self.currentState.logDescription(after: "undo \"\(barrier.name)\""))") + "\(self.currentState.logDescription(after: "undo \"\(barrier.name)\""))" + ) } target.registerRedo(barrier: barrier, onUndo: onUndo, onRedo: onRedo) } catch { @@ -185,7 +186,8 @@ extension UndoStack: DependencyKey { } if let self { logger.info( - "\(self.currentState.logDescription(after: "redo \"\(barrier.name)\""))") + "\(self.currentState.logDescription(after: "redo \"\(barrier.name)\""))" + ) } target.registerUndo(barrier: barrier, onUndo: onUndo, onRedo: onRedo) } catch { @@ -204,7 +206,18 @@ extension UndoStack: DependencyKey { $0.undo.append(barrier.name) $0.redo = [] } - target.registerUndo(barrier: barrier, onUndo: onUndo, onRedo: onRedo) + // NSUndoManager requires main thread + if Thread.isMainThread { + MainActor.assumeIsolated { + target.registerUndo(barrier: barrier, onUndo: onUndo, onRedo: onRedo) + } + } else { + DispatchQueue.main.sync { + MainActor.assumeIsolated { + target.registerUndo(barrier: barrier, onUndo: onUndo, onRedo: onRedo) + } + } + } }, currentState: { UndoStackState( diff --git a/Tests/SQLiteUndoTests/UndoEngineTests.swift b/Tests/SQLiteUndoTests/UndoEngineTests.swift index 5bab8ba..c650639 100644 --- a/Tests/SQLiteUndoTests/UndoEngineTests.swift +++ b/Tests/SQLiteUndoTests/UndoEngineTests.swift @@ -426,6 +426,38 @@ enum UndoEngineTests { } } + @Test + func endBarrierFromBackgroundThread() throws { + let testUndoManager = UndoManager() + try withDependencies { + let database = try! makeTestDatabase() + $0.defaultDatabase = database + $0.defaultUndoStack = .live(testUndoManager) + $0.defaultUndoEngine = try! UndoEngine(for: database, tables: TestRecord.self) + } operation: { + @Dependency(\.defaultDatabase) var database + @Dependency(\.defaultUndoEngine) var undoEngine + + let barrierId = try undoEngine.beginBarrier("Background Insert") + try database.write { db in + try TestRecord.insert { TestRecord(id: 1, name: "Test") }.execute(db) + } + + // End the barrier from a background thread + DispatchQueue.global().sync { + try! undoEngine.endBarrier(barrierId) + } + + #expect(testUndoManager.canUndo == true) + #expect(testUndoManager.undoActionName == "Background Insert") + + testUndoManager.undo() + + let count = try database.read { db in try TestRecord.all.fetchCount(db) } + #expect(count == 0) + } + } + @Test func undoRedoStackStateTransitions() throws { let testUndoManager = UndoManager()