feat(history): add "Revert to this point" button in Time Machine panel

Forward Automerge change that overwrites content fields with snapshot data,
preserving meta and full history. Also fixes pre-existing TS errors in
folk-flow-river (undefined exitX) and test-full-loop (StepResult type).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 19:08:15 -07:00
parent 47a3fe32fb
commit aa4a200f32
5 changed files with 63 additions and 2 deletions

View File

@ -1435,6 +1435,33 @@ export class CommunitySync extends EventTarget {
return Automerge.view(this.#doc, heads);
}
/**
* Revert document content to the state at a given change hash.
* Creates a forward change (preserving full history) and syncs to peers.
* Meta (space name/slug/config) is preserved only content is reverted.
*/
revertToHash(hash: string): void {
const snapshot = Automerge.view(this.#doc, [hash]);
const data = JSON.parse(JSON.stringify(snapshot)) as CommunityDoc;
const newDoc = Automerge.change(this.#doc, makeChangeMessage(`Revert to change ${hash.slice(0, 8)}`), (doc) => {
doc.shapes = data.shapes || {};
if (data.layers) doc.layers = data.layers;
if (data.flows) doc.flows = data.flows;
if (data.connections) doc.connections = data.connections;
if (data.groups) doc.groups = data.groups;
if (data.nestedSpaces) doc.nestedSpaces = data.nestedSpaces;
if (data.activeLayerId !== undefined) doc.activeLayerId = data.activeLayerId;
if (data.layerViewMode !== undefined) doc.layerViewMode = data.layerViewMode;
if (data.commentPins) doc.commentPins = data.commentPins;
// Preserve meta — don't revert space name/slug/config
});
this._applyDocChange(newDoc);
this.#undoStack.length = 0;
this.#redoStack.length = 0;
}
/**
* Get parsed history entries for the activity feed.
*/

View File

@ -517,7 +517,7 @@ function renderBranch(b: BranchLayout): string {
<path d="${spinePath}" fill="none" stroke="white" stroke-width="1.5" opacity="0.2" stroke-dasharray="4 12" style="animation:riverCurrent 1s linear infinite;animation-delay:-0.3s"/>` : "";
// Midpoint for label
const midX = (b.x1 + exitX + b.x2) / 3;
const midX = (b.x1 + b.x2) / 2;
const midY = (b.y1 + b.y2) / 2 - 12;
return `${outerStroke}${innerStroke}${waterFlow}

View File

@ -30,7 +30,7 @@ async function step(name: string, fn: () => Promise<StepResult>) {
return result;
} catch (err: any) {
console.log("ERROR", err.message);
return { ok: false, error: err.message };
return { ok: false, error: err.message } as StepResult;
}
}

View File

@ -413,6 +413,7 @@ export class RStackHistoryPanel extends HTMLElement {
</div>
` : ""}
</div>
<button class="revert-btn" id="revert-btn">Revert to this point</button>
`;
// Update the label
@ -446,6 +447,18 @@ export class RStackHistoryPanel extends HTMLElement {
this._onScrub(parseInt(scrubber.value, 10));
});
}
// Revert to history point
sr.getElementById("revert-btn")?.addEventListener("click", () => {
const entry = this._entries[this._timeMachineIndex];
if (!entry?.hash) return;
if (!confirm(`Revert to change from ${new Date(entry.time).toLocaleString()} by ${entry.username}?\n\nThis creates a new change that restores the document to that state.`)) return;
this.dispatchEvent(new CustomEvent("revert-requested", {
bubbles: true, composed: true,
detail: { hash: entry.hash, index: this._timeMachineIndex },
}));
this.close();
});
}
// ── Helpers ──
@ -743,6 +756,21 @@ const PANEL_CSS = `
word-break: break-word;
}
.revert-btn {
width: 100%;
margin-top: 16px;
padding: 10px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.revert-btn:hover { background: rgba(239, 68, 68, 0.2); }
/* Load more */
.load-more {
padding: 12px 20px;

View File

@ -3065,6 +3065,12 @@
const historyPanel = document.querySelector('rstack-history-panel');
if (historyPanel) historyPanel.setDoc(sync.doc);
// Wire history revert
document.querySelector("rstack-history-panel")?.addEventListener("revert-requested", (e) => {
const { hash } = (e as CustomEvent).detail;
if (hash) sync.revertToHash(hash);
});
// Non-blocking: open IndexedDB + load cache in background
// UI handlers below register immediately regardless of this outcome
(async () => {