Hello!
In my Electron application I want to have a global undo/redo functionality.
The way I see undo/redo is there are two parts: "when" and "what". "When" do you create a new "undo point" and "what" exactly do you capture.
In a "mutable" world (I use terms very loosely) the typical way to implement undo/redo is a "command stack". Every time you execute an "undoable" action, you basically, create a "diff" (or command) that tells you how to mutate your model and how to revert these changes. So, in terms of "when" and "what" you have the following:
"when": when user performs an action which is somehow marked as "undoable"
"what": the action user performs produces a "command", a recipe to mutate your model, a "diff"
I would claim that such "when" definition is very natural from the point of user: I want to see "undo points" mapped to the actions I perform and not to the actions internal to the system
However, "what" in such mutable world is a nightmare. You have to be careful that you are inverting commands properly, which is not too hard for something like simple text editor, but totally not fun when your action changes data structure significantly. Also, if you change model without capturing such a "diff" you might end up in a situation you can no longer "undo" an action as inverted "diff" (or command "undo" implementation) does not apply anymore (or even worse, you end up corrupting you data).
Now, enter the world of immutable state and pure functions producing new state. The "what" part is now much simpler: you capture the value of the whole world state and that's it (let's forget about separate undo stacks for different documents in your app or non-undoable view state like splitter positions, etc)!
"when" is now a little bit problematic. It seems like typical approach (Redux, elm-undo-list) is simply to capture state every time where is an action. However, if your application is complex enough, the undo stack is at the top and the actions sink down to the components (at least this is how I understand they way one would design Elm application). Some of the actions might be no-op actions which in the end do not change anything. Or they might change some visual representation which is not worth being captured as an individual "undoable change".
It seems like there are couple of options I have in my Elm app:
1) You compare your new state to the old one and if it is same, don't create new "undo point". However, I see two issues with that: 1) sometimes action is essentially a no-op action, but the state changes anyway (like if I use Dict.get ... Maybe.withDefault to initialize state lazily). One might argue it's a bad practice, though 2) performance might be not that great if you have really huge data structures (totally speculative claim)
2) You somehow "sniff" your actions on the top level to figure out if you should capture "undo point" after an action. However, your higher-level component which keeps undo stack will end up knowing too much about lower level components.
3) Somehow explicitly indicate when you want a new "undo point" to be created
After giving it some though, I think that I actually want the last one, which is similar to the "mutable world" "when" (and that's why I mentioned it first):.
The problem here, though, is how to deliver message to capture the state from the bottom of the system (where your actions are actually processed)? One option is to pass some sort of "context" down the hierarchy of components to make them report "capture" event to the top component. However, this would require too much boilerplate code (in my opinion). I really don't want to care about the undo stack except in cases when I really need to interact with it (with "undo"/"redo" and "capture").
Then I tried using Effects which would post message to a mailbox which app would listen to and dispatch to the top-level component. It kind of works, but I had to implement Effects.message (see
https://github.com/evancz/elm-effects/issues/28 and all linked discussions) using native modules so I don't have to have these "NoOp" actions everywhere where I post "capture" event.
If you read to this point, you might be wondering, why am I writing this at all?
Well, it kind of bothers me that I might be going in the wrong direction. Maybe, I am simply not seeing the simple solution or not getting the Elm architecture right?
Any comments? Suggestions? Any similar or different experience? tl;dr?
P.S. Here is the code I currently have: