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
87 changes: 71 additions & 16 deletions Examples/UndoForMacOS/UndoForMacOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ struct DemoFeature {
@ObservableState
struct State {
@FetchAll(DemoItem.all) var items: [DemoItem]
var eventLog: [UndoEvent] = []
}

enum Action: UndoManageableAction {
Expand All @@ -52,6 +53,16 @@ struct DemoFeature {
UndoManagingReducer()
Reduce { state, action in
switch action {
case .undoManager(.event(let event)):
if let ids = event.ids(for: DemoItem.self) {
print(
event.kind,
event.name.debugDescription,
ids.map { $0.formatted() }
)
}
state.eventLog.append(event)
return .none
case .undoManager:
return .none

Expand Down Expand Up @@ -152,22 +163,24 @@ struct DemoView: View {
Button("Redo") { observableUndo.redo() }.disabled(!observableUndo.canRedo)
}

List {
ForEach(store.items) { item in
HStack {
Text(item.name)
Spacer()
Text("Count: \(item.count)")
.foregroundStyle(.secondary)
Button("+") {
store.send(.incrementCount(item.id))
}
.buttonStyle(.bordered)
Button("Delete") {
store.send(.deleteItem(item.id))
HStack(alignment: .top, spacing: 0) {
List {
ForEach(store.items) { item in
HStack {
Text(item.name)
Spacer()
Text("Count: \(item.count)")
.foregroundStyle(.secondary)
Button("+") {
store.send(.incrementCount(item.id))
}
.buttonStyle(.bordered)
Button("Delete") {
store.send(.deleteItem(item.id))
}
.buttonStyle(.bordered)
.tint(.red)
}
.buttonStyle(.bordered)
.tint(.red)
}
}
}
Expand Down Expand Up @@ -203,7 +216,49 @@ struct DemoView: View {
}
}
.padding()
.frame(width: 400)
.frame(width: 600)
.safeAreaInset(
edge: .trailing,
content: {
VStack(alignment: .leading, spacing: 4) {
Text("Undo Events")
.font(.caption)
.foregroundStyle(.secondary)
ScrollView {
VStack {
ForEach(Array(store.eventLog.enumerated().reversed()), id: \.offset) { _, event in
HStack(spacing: 6) {
Text(event.kind == .undo ? "undo" : "redo")
.font(.caption2.monospaced())
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(
event.kind == .undo ? Color.orange.opacity(0.2) : Color.blue.opacity(0.2)
)
.clipShape(RoundedRectangle(cornerRadius: 3))
VStack(alignment: .leading) {
Text(event.name)
.font(.caption)
Text(
event.affectedItems
.map { "\($0.tableName)#\($0.rowid)" }
.sorted()
.joined(separator: ", ")
)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding()
.frame(width: 200)
.background(.background)
}
)
.setUndoManager(store: store)
.onChange(of: undoManager, initial: true) { _, newValue in observableUndo.set(newValue) }
}
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ let package = Package(
name: "sqlite-undo",
platforms: [
.iOS(.v13),
.macOS(.v11),
.macOS(.v12),
],
products: [
.library(name: "SQLiteUndo", targets: ["SQLiteUndo"]),
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ try database.write { db in
try undoEngine.endBarrier(barrierId)
```

### 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.

```swift
for await event in undoEngine.events() {
if let articleIds = event.ids(for: Article.self) {
// scroll to restored articles
}
if let authorIds = event.ids(for: Author.self) {
// handle affected authors
}
}
```

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

## ComposableArchitecture/SwiftUI Integration

```swift
Expand All @@ -134,6 +151,11 @@ struct MyFeature {
UndoManagingReducer()
Reduce { state, action in
switch action {
case .undoManager(.event(let event)): // ✅ respond to undo/redo events
if let articleIds = event.ids(for: Article.self) {
// navigate to affected articles
}
return .none
case .undoManager:
return .none
case .setRating(let rating):
Expand Down
22 changes: 16 additions & 6 deletions Sources/SQLiteUndo/UndoCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ final class UndoCoordinator: Sendable {
private let untrackedTables: Set<String>
private let state = LockIsolated(State())

let events: AsyncStream<UndoEvent>
private let eventsContinuation: AsyncStream<UndoEvent>.Continuation

private struct State {
var openBarriers: [UUID: OpenBarrier] = [:]

Expand Down Expand Up @@ -60,6 +63,7 @@ final class UndoCoordinator: Sendable {
self.database = database ?? defaultDatabase
self.registeredTables = registeredTables
self.untrackedTables = untrackedTables
(self.events, self.eventsContinuation) = AsyncStream.makeStream()
}

/// Begin recording changes for a new undoable action.
Expand Down Expand Up @@ -189,14 +193,17 @@ final class UndoCoordinator: Sendable {
state.withValue { $0.barrierSeqRanges[barrier.id] }
?? SeqRange(startSeq: barrier.startSeq, endSeq: barrier.endSeq)

let newRange = try database.write { db in
let result = try database.write { db in
try db.performUndoRedo(startSeq: seqRange.startSeq, endSeq: seqRange.endSeq)
}

if let newRange {
if let result {
state.withValue {
$0.barrierSeqRanges[barrier.id] = newRange
$0.barrierSeqRanges[barrier.id] = result.seqRange
}
eventsContinuation.yield(
UndoEvent(kind: .undo, name: barrier.name, affectedItems: result.affectedItems)
)
}
}

Expand All @@ -213,14 +220,17 @@ final class UndoCoordinator: Sendable {
state.withValue { $0.barrierSeqRanges[barrier.id] }
?? SeqRange(startSeq: barrier.startSeq, endSeq: barrier.endSeq)

let newRange = try database.write { db in
let result = try database.write { db in
try db.performUndoRedo(startSeq: seqRange.startSeq, endSeq: seqRange.endSeq)
}

if let newRange {
if let result {
state.withValue {
$0.barrierSeqRanges[barrier.id] = newRange
$0.barrierSeqRanges[barrier.id] = result.seqRange
}
eventsContinuation.yield(
UndoEvent(kind: .redo, name: barrier.name, affectedItems: result.affectedItems)
)
}
}
}
6 changes: 6 additions & 0 deletions Sources/SQLiteUndo/UndoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ public struct UndoEngine: Sendable {
///
/// - Parameter id: The barrier ID from `beginBarrier`
public var cancelBarrier: @Sendable (_ id: UUID) throws -> Void

/// Stream of events emitted after each undo/redo operation.
public var events: @Sendable () -> AsyncStream<UndoEvent> = { .finished }
}

/// Whether undo tracking is active. Default true; set false inside `withUndoDisabled`.
Expand Down Expand Up @@ -224,6 +227,9 @@ extension UndoEngine: DependencyKey {
},
cancelBarrier: { id in
try coordinator.cancelBarrier(id)
},
events: {
coordinator.events
}
)
}
Expand Down
64 changes: 64 additions & 0 deletions Sources/SQLiteUndo/UndoEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation
import SQLiteData

/// Identifies a row that was modified during an undo/redo operation.
public struct AffectedItem: Sendable, Hashable {
public let tableName: String
public let rowid: Int

init(tableName: String, rowid: Int) {
self.tableName = tableName
self.rowid = rowid
}

public init<T: Table>(table: T.Type, rowid: Int) {
self.tableName = T.tableName
self.rowid = rowid
}

/// Extract a typed ID if this item belongs to the given table.
public func id<T: Table & Identifiable>(
as type: T.Type
) -> T.ID? where T.ID: BinaryInteger {
tableName == T.tableName ? T.ID(rowid) : nil
}
}

/// Emitted after each undo/redo operation with information about what changed.
public struct UndoEvent: Sendable, Equatable {
public enum Kind: Sendable, Equatable {
case undo, redo
}
public let kind: Kind
public let name: String
public let affectedItems: Set<AffectedItem>

public init(kind: Kind, name: String, affectedItems: Set<AffectedItem>) {
self.kind = kind
self.name = name
self.affectedItems = affectedItems
}

/// Returns the typed IDs of affected rows for the given table, or nil if none matched.
///
/// ```swift
/// if let itemIds = event.ids(for: Article.self) {
/// // respond undo/redo of Article
/// }
/// ```
public func ids<T: Table & Identifiable>(
for type: T.Type
) -> Set<T.ID>? where T.ID: BinaryInteger {
let ids = Set(affectedItems.compactMap { $0.id(as: type) })
return ids.isEmpty ? nil : ids
}
}

extension UndoEvent.Kind: CustomStringConvertible {
public var description: String {
switch self {
case .undo: return "Undo"
case .redo: return "Redo"
}
}
}
18 changes: 15 additions & 3 deletions Sources/SQLiteUndo/UndoOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,13 @@ extension Database {
/// The caller (UndoEngine) tracks the current seq range for each barrier and
/// updates it after this method returns.
///
/// - Returns: The new seq range for the captured entries, or nil if no entries were executed.
func performUndoRedo(startSeq: Int, endSeq: Int) throws -> UndoCoordinator.SeqRange? {
struct UndoRedoResult {
var seqRange: UndoCoordinator.SeqRange
var affectedItems: Set<AffectedItem>
}

/// - Returns: The new seq range and affected items, or nil if no entries were executed.
func performUndoRedo(startSeq: Int, endSeq: Int) throws -> UndoRedoResult? {
logger.debug("Performing undo/redo: seq \(startSeq)...\(endSeq)")

// Fetch entries to execute (in reverse order)
Expand All @@ -54,6 +59,13 @@ extension Database {
return nil
}

// Collect affected items before deleting entries
let affectedItems = Set(
entries
.filter { $0.trackedRowid != 0 }
.map { AffectedItem(tableName: $0.tableName, rowid: $0.trackedRowid) }
)

// Delete the entries
try deleteUndoLogEntries(from: startSeq, to: endSeq)

Expand All @@ -78,7 +90,7 @@ extension Database {
// Reconcile duplicates from BEFORE triggers firing during replay
try reconcileUndoLogEntries(from: newRange.startSeq, to: newRange.endSeq)
logger.debug("New seq range: \(newRange.startSeq)...\(newRange.endSeq)")
return newRange
return UndoRedoResult(seqRange: newRange, affectedItems: affectedItems)
}

return nil
Expand Down
Loading
Loading