ruby · 5 min read
Ruby design patterns: make undo and redo survive a new branch
Implement the Command pattern with a tested history cursor. Handle undo-all, branching, failed commands and external edits without negative-index surprises.
Type "a", type "b", undo twice, then type "x". What should redo do?
For a conventional editor history, nothing. The new edit starts a different branch. The abandoned "a" and "b" commands must not reappear. This tiny sequence is a more useful test of the Command pattern than a diagram containing a receiver and an invoker.
The Ruby experiment builds an append-only text editor with undo and redo. It keeps the model small enough to test every boundary, including the surprisingly dangerous case where all previous commands have been undone.
Store the number of applied commands
History keeps an array of commands and a cursor. The cursor is a count: every command before it is applied, and every command at or after it is available for redo. An empty history has cursor zero.
This convention avoids using minus one to mean "nothing applied." Ruby arrays interpret negative indices relative to the end. A slice from zero through minus one returns the entire array, so it is a poor expression of an empty applied prefix. The Array reference documents the indexing and prefix operations; the test suite checks the particular counterexample directly.
Executing a new command makes the branching rule explicit:
def execute(command) raise ArgumentError, "use a fresh command" if @commands.any? { |old| old.equal?(command) } command.execute @commands = @commands.take(@cursor) + [command] @cursor += 1 trueendTake retains exactly the applied prefix. After undoing everything, taking zero returns an empty array. Adding the new command leaves one command and no redo tail.
The history also rejects an object already present in its command list. A command holds execution state; reusing the same object as a second independent edit would mix two positions in history. Construct a fresh command for each user action.
The command owns the reversal
An Append command stores its document and a copied suffix. On execution it records the previous text and the resulting text. Undo restores the previous snapshot.
That is the Command pattern's practical contribution here: History decides which action is next, while the command knows what reversing that action means. History does not need to know how many characters were appended or how a future replace-selection action would work.
Snapshots are an intentionally simple choice. With a large document and many edits, storing a whole previous string for every command becomes expensive. Deltas can reduce that cost, but then the inverse operation must account for positions, content changes and intervening edits. The small lab establishes behavior before optimizing storage.
The document returns a frozen string. Callers cannot append through its reader and silently change a snapshot. A replace method provides an explicit external-edit path, which the tests use to challenge the history.
Move the cursor only after success
Undo and redo are symmetric:
def undo return false if @cursor.zero? @commands.fetch(@cursor - 1).undo @cursor -= 1 trueend
def redo return false if @cursor == @commands.length @commands.fetch(@cursor).execute @cursor += 1 trueendAt either boundary they return false instead of indexing outside the list. Otherwise, the command runs first and the cursor moves second. If the command raises, the cursor still describes the history state from before the attempt.
The same order applies when starting a new branch: execute the new command before discarding the old redo tail. A test supplies a command that raises before doing anything. The previous redo branch must remain usable.
That test has a narrow meaning. History does not implement a transaction around arbitrary commands. If a command changes three things and raises after the second, leaving the cursor unchanged will not undo those effects. Each command needs its own atomicity or compensation contract. Sending an email is not reversed by removing an object from an array.
Detect a document that no longer matches
Before undo, Append checks that the current text equals the text it produced. Before redo, it checks the expected previous text. An outside replacement causes an exception instead of silently overwriting the new content.
This equality check catches the external-edit scenario included in the lab. It is not collaborative editing and does not detect every history of changes: another actor could change the document and later restore identical text. A real multi-user editor would need version identity and an explicit conflict model.
Keeping that limitation visible prevents a local consistency check from becoming a claim about distributed editing. The example is a single-user, in-memory history; restarting the process loses it.
Which other patterns would help?
A factory could construct different command types from toolbar actions. A decorator could add tracing around commands. A service object could coordinate saving a document. None is necessary to repair the branch invariant demonstrated here.
Add one when it separates an actual responsibility. A tracing decorator, for example, must preserve the wrapped command's return values and exceptions. A save service must define what happens if persistence fails. The pattern name does not supply those contracts.
For this lab, three small classes are enough: Document stores text, Append changes and reverses it, and History selects the next action. More indirection would make the failed sequence harder to follow.
Reproduce the boundaries
Download the complete editor lab. The recorded environment is Ruby 3.3.2 with Minitest 6.0.6; install that exact test dependency if missing.
ruby example.rb --seed 42The local run passed 8 tests and 29 assertions. They cover normal undo/redo, branching from the middle, branching after undo-all, empty history, outside edits, failed commands, copied input and the negative-index counterexample.
When changing a history implementation, keep the "a, b, undo, undo, x" case close. It describes a user-visible rule in six actions, and it catches a bug that an attractive class diagram cannot.
Found a mistake or tried a different approach?
Send Alex a note ↗