Skip to content
Merged
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
76 changes: 74 additions & 2 deletions Examples/UndoForMacOS/UndoForMacOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ struct DemoFeature {
enum Action: UndoManageableAction {
case undoManager(UndoManagingAction)
case addItem
case addItemInBackground
case addUntrackedItem
case incrementCount(Int)
case incrementAll
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -113,6 +124,7 @@ struct DemoFeature {
struct DemoView: View {
@Bindable var store: StoreOf<DemoFeature>
@Environment(\.undoManager) var undoManager
@State private var observableUndo = ObservableUndoManager()

var body: some View {
VStack(spacing: 16) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
}

Expand Down
12 changes: 5 additions & 7 deletions Sources/SQLiteUndo/UndoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 17 additions & 4 deletions Sources/SQLiteUndo/UndoStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions Tests/SQLiteUndoTests/UndoEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading