Repository navigation
Proposal: provide undo/redo functionality through a patchwork provider - #469
Draft
iFreilicht wants to merge 8 commits into
Draft
iFreilicht wants to merge 8 commits into
iFreilicht wants to merge 8 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWZt33guhcqjUKM8uw4izp
The new @inkandswitch/patchwork-undo package gives every tool undo of its document edits through undoFor(element, handle): one history per realm and document, shared by every tool editing it, undoing only the realm's own changes as new changes, with undo runs that the next edit records as a step. It binds Cmd/Ctrl+Z once per page, and an <undo-provider>, mounted by setup() next to <repo-provider>, lets host UI reach any document's history. Helpers integrate CodeMirror and tldraw; React and Solid get bindings. @inkandswitch/patchwork-providers gains connect() and onCommand, so a consumer can send commands back over its subscription. Tests: unit and collaboration scenarios, a fuzzer against a snapshot oracle, comparisons with CodeMirror's and tldraw's own histories, and Playwright end-to-end tests with two browser profiles. The design is in docs/proposals/document-undo.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@inkandswitch/patchwork-undo/prosemirror's undoBoundaries plugin replaces prosemirror-history: it marks the editor so Cmd/Ctrl+Z goes to the service, and ends a step at closeHistory(tr). Rich text's block markers now count for text adjacency, and a restored block marker is inserted with splitBlock, so @automerge/prosemirror sees a block. A comparison test runs the same transactions against prosemirror-history. Editors now hand their keys to the service only when marked with data-patchwork-undo (CodeMirror's undoBoundaries marks its editor), so unbound editors such as comment drafts keep their own undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Undo now restores formatting: a step's mark and unmark operations are inverted per range, each part back to the format it had before, with the same conflict policy as values. Re-inserted characters and copied texts get their formatting back, and visibility compares formatting, which a text's JSON doesn't show. Fixes found by fuzzing with formatting: ranges are mapped through a change's sequential patches, several formatting operations in one change are simulated in order, re-inserted characters stay in order next to copies placed past tombstones, and a restored object copy loses edits made inside it within an undo run. The ProseMirror comparison test now covers bold, unbold and typing in formatted text; the fuzzer sets and removes bold, italic and links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Repo Patchwork builds (each tab's and the automerge protocol handler worker's, on plain and keyhive sites) passes its signer's hex verifying key as the Automerge author, so getActorsForAuthor lists the actors a browser profile wrote as, across reloads. The automerge-repo patch adds RepoConfig.author: documents are created with it, and it is set again after every mutation, because a load into an empty document drops it. signerIdentity(signer) in the bootloader's signer module gives the peer id and key; patchwork.signer is now set on keyhive sites too. The undo proposal's Persistence issue describes the author and its limits; rebuilding steps from it is not implemented yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new history rebuilds steps from the realm's earlier changes: its own actor's and those of every actor with the document's Automerge author, which Patchwork sets to the signer's key. The newest 10 steps are rebuilt at once and the next 10 whenever undo reaches the oldest rebuilt one (UndoOptions.restore; 0 turns it off), so a long history costs one metadata read, not decoding every change. Rebuilt changes of one actor at most a second apart form a step, since history keeps no checkpoints or millisecond times. Undo and redo presses carry the change message "patchwork-undo" (PRESS_MESSAGE), so an earlier run comes back as one step. A document's first change and the one that sets @Patchwork are never rebuilt: undoing them would take away the structure tools expect. The inverse compared changes by seq, which counts each actor's changes separately; it now uses each change's position in the document's change order. The proposal describes restoring in 4.15 and its limits in 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Name the JS wrapper in @automerge/automerge as the culprit and the wasm Automerge.marks as the correct function the engine calls, and soften the editor's note on 4.9 to ask for a rewrite only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lead item 2 with the actual bug (a historical cursor lookup that works until the document changes) and note what a fix removes. Place item 4's panic in the Rust core, cite automerge#1593, whose fix is on main but not yet released, and note what the fix changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iFreilicht
marked this pull request as draft
November 1, 2026 10:03
Contributor
Author
|
@pvh tagging you as requested :) |
This was referenced Nov 1, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR: The basic idea is to add a provider and API that enables tool authors to support undo/redo actions with minimal effort. This ensures undo/redo behaves consistently across tools and reduces design and maintenance burden on tool authors. Existing functionality in Automerge and Patchwork can be leveraged for implementation.
Design choices
The approach in this proposal implements this functionality under the following design choices:
- no change was made within
groupDelayMs(default 500ms, same as CodeMirror'snewGroupDelay)- a tool called
checkpoint(), for example after a section of code was pasted- a text change was made in another location in the text
If one of these events occurs, the step is completed and added to the history, a new step is created, and the cycle continues anew.
- Insert X -> Document contains
X- Insert A -> Document contains
XA- Insert B -> Document contains
XAB- Press Ctrl+Z -> Document contains
XA- Insert C -> Document contains
XAC- Press Ctrl+Z -> Document contains
XA- Press Ctrl+Z -> Document contains
XAB, NOTXOne term for this problem is The Great Undo-Redo Quandary.
Proposed solution
New global provider
Currently,
<patchwork-view>is wrapped in a<repo-provider>. This proposal would add<undo-provider>as well, which enables a frame-tool to connect topatchwork:undoto send the host commandsundoandredoand enables editor tools to connect topatchwork:undo-sourceevents (by callingundoFor, see Basic use) to receive those host commands (see Additions to patchwork's core for details on what "connect" means here):Use by tool authors
Basic use
To use this facility, most tool authors will only need to call the function
undoFor<T>(element: HTMLElement | null, handle: DocHandle<T>, options: UndoOptions = {}): Undothat creates anUndo-object for interacting with the shared history forelementdisplaying the documenthandle. It also wires up event handlers onhandleto react to changes in the document to add new steps to the history and connects topatchwork:undo-sourceon the provider. This MUST be called in some way for a tool to participate in the Undo-History (though framework-specific hooks are also provided, see below):At this point, Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y (Cmd+Z/Cmd+Shift+Z on macOS) will already behave as expected. The only other thing tool authors should do is call
undoBinding.dispose()when their tool is closed to unregister event handlers, close connections and avoid memory leaks:For most tools, this will already be sufficient to implement Undo/Redo!
Advanced use, frontend-framework-agnostic
However, if you want (or need) additional programmatic control, you can interact with the object returned from
undoFor. Its type isUndo, which is roughly defined like this:canUndo,canRedoand reacting to their change withsubscribeare useful for updating UI elements based on the current state of the historycheckpointis recommended when a tool has more information about an Undo/Redo-Boundary than the provider; for example, when typing, pasting text and typing again, a User will expect three steps in the Undo-History, but our heuristics might not be sufficient to capture thatonAppliedcan be useful to react to undo- and redo-events. Especially of note is the fieldappliedin its result, which tells you if the undo-event even made any changes to the document at all (which might not be the case depending on changes of other users)undoandredocan be useful when providing your own controls or if you want to programmatically undo and redo multiple changes. For example, while the undo provider already skips steps that change nothing in the document, changes to the document that are not visible in the UI (like a whitespace change in a value that is formatted in the viewer anyway) can still occur, so you might want to callundoyourself once more to ensure the user always gets a visible changeUse in frameworks
For ReactJS and SolidJS, the code in this proposal also provides hooks that wrap
undoFor,subscribe, unsubscribe anddisposefor you:useUndo(element: HTMLElement | null | undefined, handle: DocHandle<T> | undefined, options?: UndoOptions): (UndoState & { binding: Undo }) | undefinedcreateUndo<T>(element: ElementSource, handle: Accessor<DocHandle<T> | undefined>, options?: UndoOptions): UndoControlsCaveat when building a text editor tool
When building a text editor, a tool author might decide to use a
<textarea>or<div contenteditable="true">as the HTML element containing the actual text of the document. CodeMirror and ProseMirror for example use the latter. However, because a tool might contain many such elements (as well as<input>and<select>) for purposes other than displaying and editing the actual document context, we want browsers to handle Ctrl+Z for them by default. To support this, our custom handler for Ctrl+Z (which is registered on thewindowobject) ignores events coming from the following elements by default and lets the browser handle the shortcut:contenteditableorcontenteditable="true"<textarea><input><select>If a tool author wants to display the document content in any such element, they MUST mark that HTML element with
data-patchwork-undoto signal to our event handler that undo/redo events originating from that element should be handled by it and not by the browser.The helpers for CodeMirror and ProseMirror set this marker on the correct element for you.
Integration into the UI and discoverability
Currently, the implemented undo commands are keyboard shortcuts only, but because
undo-provideris global likerepo-provider, a frame-tool could add UI elements to trigger these actions if desired. Most probably, a new patchwork base componentpatchwork-undo-buttonsshould be created to make this as easy as possible for authors of frame-tools. A functionwatchUndothat allows sending undo/redo-commands and listening toUndoStateof the last selected document is already implemented in the provider.Additions to patchwork's core
The solution prototyped in this proposal requires additional functionality in
core.First, the ability to not just subscribe to a topic and receive messages that way, but also to send messages via the same connection. For this,
connectwas added in the prototype, which returns aConnectionobject:Second, for persistent undo between document loads, we pre-populate the history with Automerge commits from our own signer. To track the signer in commits, we now populate Automerge's
authorfield with the hex verifying key of the signer returned byloadOrCreateSignerfor plain-sync repos andhive.constructSubductionSigner()(which is injected byautomerge-repo-keyhive) for keyhive repos. This seems like the only sensible value for theauthorfield anyway.Integration with CodeMirror, ProseMirror and tldraw
These components are integrated in tools in patchwork-base and have their own history management built in. Fortunately, they're quite extensible. The prototype provides helpers for all these editors which make the integration quite simple, see ./packages/undo/src/codemirror.ts, ./packages/undo/src/prosemirror.ts and ./packages/undo/src/tldraw.ts.
However, for making tool authors' lives easier, I would propose to also add new packages
@inkandswitch/patchwork-prosemirrorand@inkandswitch/patchwork-codemirrorwhich - similarly to @automerge/prosemirror and @automerge/codemirror - provide an easy to use plugin/extension that bundles and correctly initializes those helpers.Additionally, we should of course revise the packages for those three tools in patchwork-base to use the new provider once it actually exists.
Automerge limitations
The prototype in this proposal needs to work around multiple bugs/limitations in automerge:
view()insidechange(At)returns document without earlier edits automerge/automerge#1598 (would get fixed by Keep edits made before view() inside change() automerge/automerge#1606), and Reading history or saving inside achange()callback splits the changes into multiple changes automerge/automerge#1607 (would get fixed by javascript: Don't split the change when reading inside change() automerge/automerge#1659). We work around those by doing an undo in two steps; first a plan step that reads everything at the old head before.change()is called, then an execute step that runs inside of.change().applieddepending on that, seechangedVisibly.InvalidCursoron trailing deleted characters at historical heads automerge/automerge#1660getCursorPosition, we just read the entire text we care about between two cursors and index into that ourselves.loadignores the passedauthorand loading it withloadIncrementaldrops theauthorthat was previously set withsetAuthor. As we patch automerge-repo anyway, the fix for that was just added to that patch.Open topics
appliedinUndoResultis annoying. Ideally, ifcanUndo/canRedoare true, runningundo/redowould be guaranteed to succeed. However, an invisible action can occur if I undo an addition that somebody else already removed, for example. I'm not sure how to fix this best. Knowing if an undo will apply in advance is impossible without actually trying to execute it. How much of an issue this even is in practice remains to be seen.authoris the same for all tabs per browser, while the in-memory history is different for every tab. This seems like an acceptable compromise to me.Closing words
There is a much more detailed version of this proposal in ./docs/proposals/document-undo.md; I did my best to comb through it and make the AI-speak palatable, but I feel like this PR description is the better overview.
There's also more detailed documentation in ./packages/undo/README.md, which is surprisingly readable.
One thing I really didn't touch upon in this writing is how a change is actually undone, mostly because I don't think this matters much for the proposal or the UX. I've read the code and Claude's specification (see 4.9 Inverting a step) on that and it just seems like uninteresting tedium that I'm glad to have a machine take care of.
The code in this proposal is quite well tested, benchmarked and fuzzed, but I wouldn't say it's ready to merge. Let's talk about the general idea and API first, and if we agree that we want to move forward with this, I'll get this ready for prime-time.
Disclosure of AI usage
I used Claude Opus 5.5 for the entire prototype. Almost none of the code in this proposal was written by me directly. The entire text in this PR description was written by me personally to ensure that I understand to a reasonable degree what the code I'm submitting actually does. I've reviewed and manually rewritten some parts of the proposal document.