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
53 changes: 42 additions & 11 deletions Examples/UndoForMacOS/UndoForMacOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@ import SwiftUI

@main
struct UndoForMacOSApp: App {
static let store = Store(
initialState: DemoFeature.State()
) {
DemoFeature()
}
init() {
prepareDependencies {
let database = try! makeDemoDatabase()
Expand All @@ -22,17 +17,44 @@ struct UndoForMacOSApp: App {
}
var body: some Scene {
WindowGroup {
DemoView(store: Self.store)
DemoWindow()
}
}
}

/// One window's worth of state.
///
/// The database and engine are app-wide, but each window gets its own `UndoStack`,
/// which binds to that window's UndoManager. Barriers opened by this window's store
/// register with that manager, and only this window sees the resulting undo events.
/// Open a second window with ⌘N to see the two undo stacks operate independently.
struct DemoWindow: View {
@State private var store = withDependencies {
$0.installDefaultUndoStack()
} operation: {
Store(initialState: DemoFeature.State()) {
DemoFeature()
}
}
var body: some View {
DemoView(store: store)
}
}

@Reducer
struct DemoFeature {
@ObservableState
struct State {
@FetchAll(DemoItem.all) var items: [DemoItem]
let windowID: UUID
var eventLog: [UndoEvent] = []
@FetchAll(DemoItem.none) var items: [DemoItem]
init(windowID: UUID = UUID()) {
self._items = FetchAll(
wrappedValue: [],
DemoItem.all.where { $0.windowID.eq(windowID) }
)
self.windowID = windowID
}
}

enum Action: UndoManageableAction {
Expand All @@ -56,6 +78,7 @@ struct DemoFeature {
case .undoManager(.event(let event)):
if let ids = event.ids(for: DemoItem.self) {
print(
"window: \(state.windowID) received undo:",
event.kind,
event.name.debugDescription,
ids.map { $0.formatted() }
Expand All @@ -71,18 +94,22 @@ struct DemoFeature {
try undoable("Add Item") {
try 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)
try DemoItem.insert {
DemoItem(id: nextID, windowID: state.windowID, name: "Item \(nextID)")
}.execute(db)
}
}
}
return .none

case .addItemInBackground:
return .run { _ in
return .run { [windowID = state.windowID] _ 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)
try DemoItem.insert {
DemoItem(id: nextID, windowID: windowID, name: "Item \(nextID)")
}.execute(db)
}
}
}
Expand All @@ -92,7 +119,9 @@ struct DemoFeature {
try withUndoDisabled {
try 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)
try DemoItem.insert {
DemoItem(id: nextID, windowID: state.windowID, name: "Item \(nextID)")
}.execute(db)
}
}
}
Expand Down Expand Up @@ -322,6 +351,7 @@ final class ObservableUndoManager {
@Table
struct DemoItem: Identifiable {
var id: Int
var windowID: UUID
var name: String = ""
var count: Int = 0
}
Expand All @@ -339,6 +369,7 @@ func makeDemoDatabase() throws -> any DatabaseWriter {
"""
CREATE TABLE "demoItems" (
"id" INTEGER PRIMARY KEY,
"windowID" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT '',
"count" INTEGER NOT NULL DEFAULT 0
)
Expand Down
64 changes: 56 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,24 +103,39 @@ BEGIN
END
```

### With explicit barrier management
### What a barrier captures

```swift
@Dependency(\.defaultUndoEngine) var undoEngine
A barrier claims exactly the writes made inside its `undoable` block. Barriers may
overlap freely — concurrent barriers, or one opened inside another, each keep their
own changes, and undoing one never disturbs another.

Only writes made inside a barrier are tracked. A write outside one is applied
normally but is not undoable:

let barrierId = try undoEngine.beginBarrier("Set Rating")
try database.write { db in
```swift
try database.write { db in // not undoable
try Article.find(id).update { $0.rating = 5 }.execute(db)
}
try undoEngine.endBarrier(barrierId)

try undoable("Set Rating") { // undoable
try database.write { db in
try Article.find(id).update { $0.rating = 5 }.execute(db)
}
}
```

Tracking follows Swift's structured concurrency, so it reaches through `async`
writes and child tasks. It does not reach into a `Task.detached`, whose writes are
outside the barrier and therefore untracked.

### Undo events

After each undo/redo, `UndoEngine` emits an `UndoEvent` with the affected table rows. Use this to drive UI responses like scrolling to a restored item or switching views.
After each undo/redo, the `UndoStack` that performed it emits an `UndoEvent` with the affected table rows. Use this to drive UI responses like scrolling to a restored item or switching views.

```swift
for await event in undoEngine.events() {
@Dependency(\.defaultUndoStack) var undoStack

for await event in undoStack.events() {
if let articleIds = event.ids(for: Article.self) {
// scroll to restored articles
}
Expand All @@ -132,6 +147,8 @@ for await event in undoEngine.events() {

`ids(for:)` returns `nil` when no rows of that table were affected, so `if let` naturally gates your response logic.

Events are scoped to the stack that performed the undo, so an undo in one window does not notify another. See [Multiple windows](#multiple-windows).

## ComposableArchitecture/SwiftUI Integration

```swift
Expand Down Expand Up @@ -183,6 +200,37 @@ struct MyView: View {
}
```

## Multiple windows

An undo scope is one `UndoStack` bound to one `UndoManager`. The database, engine, and
undo log are app-wide; the stack is not.

Give each window its own stack by scoping the dependency where its store is created:

```swift
struct MyWindow: View {
@State private var store = withDependencies {
$0.installDefaultUndoStack()
} operation: {
Store(initialState: MyFeature.State()) { MyFeature() }
}

var body: some View {
MyView(store: store)
}
}
```

The default stack is already app-wide, so a single-window app needs no setup at all. `installDefaultUndoStack()` exists to create an *additional* scope — call it once per window.

Each window then has its own undo/redo stack, its own Edit menu state, and its own
event stream. Barriers register with whichever stack is current when they close, and
undoing in one window never touches another's changes.

Because AppKit resolves `UndoManager` up the responder chain, this also gives you the
document case for free: several windows onto one `NSDocument` resolve to the *same*
`UndoManager`, so give them the same stack and they correctly share one undo history.

## License

This library is released under the MIT license. See [LICENSE](LICENSE) for details.
70 changes: 52 additions & 18 deletions Sources/SQLiteUndo/UndoBarrier.swift
Original file line number Diff line number Diff line change
@@ -1,37 +1,71 @@
import Foundation

/// A barrier represents a single undoable user action, grouping all database
/// changes that occurred between `beginBarrier` and `endBarrier`.
/// changes made while it was open.
///
/// When undo is performed, all changes within the barrier are reversed in
/// reverse chronological order.
///
/// ## Sequence Numbers
/// ## Entry Ownership
///
/// The `startSeq` and `endSeq` store the ORIGINAL sequence range when the
/// barrier was created. However, after undo/redo operations, the actual
/// entries in the undolog move to new sequence positions (seq numbers grow,
/// they are not reused). `UndoEngine` tracks the current seq range separately
/// in `barrierSeqRanges` - see that documentation for details.
/// Undolog rows are stamped with the barrier's `id` as they are captured, so a
/// barrier owns its entries no matter what else writes concurrently. Replaying
/// a barrier re-stamps the newly captured reverse entries with the same `id`,
/// so ownership survives any number of undo/redo cycles.
public struct UndoBarrier: Hashable, Sendable, Codable {
/// Unique identifier for this barrier.
/// Unique identifier for this barrier, and the key its undolog entries carry.
public let id: UUID
/// Display name for the action (shown in Edit > Undo menu).
public let name: String
/// Original first sequence number when barrier was created (may not reflect current position).
let startSeq: Int
/// Original last sequence number when barrier was created (may not reflect current position).
let endSeq: Int
/// The number of undolog entries captured when this barrier closed.
public let count: Int

public init(id: UUID, name: String, startSeq: Int, endSeq: Int) {
public init(id: UUID, name: String, count: Int) {
self.id = id
self.name = name
self.startSeq = startSeq
self.endSeq = endSeq
self.count = count
}
}

/// Run an operation inside a barrier: open one, claim the writes it makes, close it.
///
/// This is the only place a barrier is paired with its `_undoBarrierID` scope. A
/// barrier claims writes solely while that task local carries its ID, so opening one
/// any other way captures nothing at all — keeping the pairing here means callers
/// can't get it wrong.
func withBarrierScope<T>(
_ name: String,
begin: (String) throws -> UUID,
end: (UUID) throws -> Void,
cancel: (UUID) throws -> Void,
operation: () throws -> T
) throws -> T {
let id = try begin(name)
do {
let result = try $_undoBarrierID.withValue(id.uuidString) { try operation() }
try end(id)
return result
} catch {
try cancel(id)
throw error
}
}

/// The number of undolog entries in this barrier.
public var count: Int {
endSeq - startSeq + 1
/// Run an async operation inside a barrier. See ``withBarrierScope(_:begin:end:cancel:operation:)``.
func withBarrierScope<T: Sendable>(
_ name: String,
begin: (String) throws -> UUID,
end: (UUID) throws -> Void,
cancel: (UUID) throws -> Void,
operation: @Sendable () async throws -> T
) async throws -> T {
let id = try begin(name)
do {
let result = try await $_undoBarrierID.withValue(id.uuidString) { try await operation() }
try end(id)
return result
} catch {
try cancel(id)
throw error
}
}
Loading
Loading