How to interact with a (NS)UndoManager?

Hey all! We had a similar need and came up with the following approach. You can set up the state in a similar way to the following:

struct State: Equatable {
  var value: String = ""
}
enum Action {
  case onDidFinishLaunching // Called when you open your application window for the first time
  case closeApp             // Called when you close your application
  case changeString(String)
}
struct Environment {
  var undoClient: TCAUndoClient
}

Where the TCAUndoClient is a struct that provides the following public interface:

struct TCAUndoClient {
    var undoEffects: Effect<Action, Never>
    func register(_ undoAction: Action, _ actionName: String) -> Void
}

Now, you can implement this struct with something like the following (passing in your UndoManager)

struct TCAUndoClient {
    private var undos: PassthroughSubject<Action, Never> = PassthroughSubject()
    var undoEffects: Effect<Action, Never> {
        return undos.eraseToEffect()
    }
    init(undoManager: UndoManager) {
        self.undoManager = undoManager
    }
    weak var undoManager: UndoManager?
    func register(undoAction: Action, actionName: String) {
        undoManager?.registerUndo(withTarget: undos) { undos in
            undos.send(undoAction)
        }
        undoManager?.setActionName(actionName)
    }
}

Now, in the environment, we have access to a stream of Actions from the TCAUndoManager, which we can subscribe to when the app launches and unsubscribe from when the app closes. Your reducer would look like this:

let reducer = Reducer<State, Action, Environment> { state, action, environment in
  struct UndoEffectsCancellable: Hashable { }
  switch action {
  case .onDidFinishLaunching:
    // Subscribe to the stream of actions
    return undoManager.undoEffects
                    .map { $0 }
                    .cancellable(id: UndoEffectsCancellable(), cancelInFlight: true)
  case .closeApp:
    // Unsubscribe 
    return Effect.cancel(id: UndoEffectsCancellable())
  case let .changeString(string, store):
    let originalValue = state.string
    state.string = string
    return .fireAndForget { 
      environment.register(.changeString(originalValue), "Change String") 
    }
  }
}

And if you wanted to extract the undo logic entirely from your reducer, you could do something like this instead:

let reducer = Reducer<State, Action, Environment> { state, action, environment in
  struct UndoEffectsCancellable: Hashable { }
  switch action {
  case .onDidFinishLaunching:
    // Subscribe to the stream of actions
    return undoManager.undoEffects
                    .map { $0 }
                    .cancellable(id: UndoEffectsCancellable(), cancelInFlight: true)
  case .closeApp:
    // Unsubscribe 
    return Effect.cancel(id: UndoEffectsCancellable())
  case let .changeString(string, store):
    state.string = string
    return .none
  }
}.undoReducer()
extension Reducer {
    func undoReducer() -> Self {
        .init { (state, action, env) -> Effect<Action, Never> in
            switch action {
            case .changeString:
                let originalValue = state.string
                let effects = self.run(&state, action, env)
                environment.register(.changeString(originalValue), "Change String")                 
                return effects
            default:
                return self.run(&state, action, env)
            }
        }
    }
}
2 Likes