Skip to content

Commit e5525a5

Browse files
committed
feat(studio): add persistent undo redo (#537)
Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops. The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit. - Adds a persistent per-project edit-history model for file snapshots. - Stores undo/redo stacks in IndexedDB so history survives Studio refreshes. - Records source editor saves, manual DOM edits, and timeline mutations. - Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`. - Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content. - Keeps history available in memory if IndexedDB persistence fails during a session. - Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper. Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit. Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot. - `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass - `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass - `bun --filter @hyperframes/studio typecheck` - `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors - `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` - `git diff --check` - `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck - Lefthook pre-commit -> lint, format, typecheck pass - Lefthook commit-msg -> commitlint pass - Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`. - Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`. - Refreshed Studio and verified Undo stayed enabled. - Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned. - Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move. - Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`. - Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed. - The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed. - The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request.
1 parent 368f965 commit e5525a5

10 files changed

Lines changed: 1649 additions & 102 deletions

packages/studio/src/App.tsx

Lines changed: 241 additions & 102 deletions
Large diffs are not rendered by default.
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createEmptyEditHistory } from "../utils/editHistory";
3+
import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
4+
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
5+
import {
6+
createPersistentEditHistoryController,
7+
createPersistentEditHistoryStore,
8+
} from "./usePersistentEditHistory";
9+
10+
describe("createPersistentEditHistoryController", () => {
11+
it("records history and reloads it for the same project", async () => {
12+
const storage = createMemoryEditHistoryStorage();
13+
const first = await createPersistentEditHistoryController({
14+
projectId: "project-1",
15+
storage,
16+
now: () => 100,
17+
onChange: () => {},
18+
});
19+
20+
await first.recordEdit({
21+
label: "Move layer",
22+
kind: "manual",
23+
files: { "index.html": { before: "a", after: "b" } },
24+
});
25+
26+
const second = await createPersistentEditHistoryController({
27+
projectId: "project-1",
28+
storage,
29+
now: () => 200,
30+
onChange: () => {},
31+
});
32+
33+
expect(second.snapshot().canUndo).toBe(true);
34+
expect(second.snapshot().undoLabel).toBe("Move layer");
35+
expect(second.snapshot().undoPaths).toEqual(["index.html"]);
36+
});
37+
38+
it("undo applies files through the provided callback and persists redo state", async () => {
39+
const storage = createMemoryEditHistoryStorage();
40+
const controller = await createPersistentEditHistoryController({
41+
projectId: "project-1",
42+
storage,
43+
now: () => 100,
44+
onChange: () => {},
45+
});
46+
await controller.recordEdit({
47+
label: "Move layer",
48+
kind: "manual",
49+
files: { "index.html": { before: "a", after: "b" } },
50+
});
51+
52+
const result = await controller.undo({
53+
readFile: async (path) => {
54+
expect(path).toBe("index.html");
55+
return "b";
56+
},
57+
writeFile: async (path, content) => {
58+
expect(path).toBe("index.html");
59+
expect(content).toBe("a");
60+
},
61+
});
62+
expect(result.ok).toBe(true);
63+
64+
expect(controller.snapshot().canUndo).toBe(false);
65+
expect(controller.snapshot().canRedo).toBe(true);
66+
expect(controller.snapshot().redoPaths).toEqual(["index.html"]);
67+
});
68+
69+
it("keeps in-memory history when storage saves fail", async () => {
70+
const storage: EditHistoryStorageAdapter = {
71+
async get() {
72+
return null;
73+
},
74+
async set() {
75+
throw new Error("IndexedDB unavailable");
76+
},
77+
async delete() {},
78+
};
79+
const controller = await createPersistentEditHistoryController({
80+
projectId: "project-1",
81+
storage,
82+
now: () => 100,
83+
onChange: () => {},
84+
});
85+
86+
await expect(
87+
controller.recordEdit({
88+
label: "Move layer",
89+
kind: "manual",
90+
files: { "index.html": { before: "a", after: "b" } },
91+
}),
92+
).resolves.toBeUndefined();
93+
94+
expect(controller.snapshot().canUndo).toBe(true);
95+
});
96+
97+
it("serializes concurrent record edits against the latest state", async () => {
98+
const storage = createMemoryEditHistoryStorage();
99+
let timestamp = 100;
100+
const store = createPersistentEditHistoryStore({
101+
projectId: "project-1",
102+
storage,
103+
initialState: createEmptyEditHistory(),
104+
now: () => timestamp++,
105+
onChange: () => {},
106+
});
107+
108+
await Promise.all([
109+
store.recordEdit({
110+
label: "Move layer",
111+
kind: "manual",
112+
files: { "index.html": { before: "a", after: "b" } },
113+
}),
114+
store.recordEdit({
115+
label: "Resize layer",
116+
kind: "manual",
117+
files: { "index.html": { before: "b", after: "c" } },
118+
}),
119+
]);
120+
121+
expect(store.snapshot().state.undo.map((entry) => entry.label)).toEqual([
122+
"Move layer",
123+
"Resize layer",
124+
]);
125+
});
126+
127+
it("still coalesces concurrent source edits that share a coalesce key", async () => {
128+
const storage = createMemoryEditHistoryStorage();
129+
let timestamp = 100;
130+
const store = createPersistentEditHistoryStore({
131+
projectId: "project-1",
132+
storage,
133+
initialState: createEmptyEditHistory(),
134+
now: () => timestamp++,
135+
onChange: () => {},
136+
});
137+
138+
await Promise.all([
139+
store.recordEdit({
140+
label: "Edit source",
141+
kind: "source",
142+
coalesceKey: "source:index.html",
143+
files: { "index.html": { before: "a", after: "b" } },
144+
}),
145+
store.recordEdit({
146+
label: "Edit source",
147+
kind: "source",
148+
coalesceKey: "source:index.html",
149+
files: { "index.html": { before: "b", after: "c" } },
150+
}),
151+
]);
152+
153+
expect(store.snapshot().state.undo).toHaveLength(1);
154+
expect(store.snapshot().state.undo[0].files["index.html"].before).toBe("a");
155+
expect(store.snapshot().state.undo[0].files["index.html"].after).toBe("c");
156+
});
157+
158+
it("reads undo hashes from the live top entry during queued undo calls", async () => {
159+
const storage = createMemoryEditHistoryStorage();
160+
let timestamp = 100;
161+
const store = createPersistentEditHistoryStore({
162+
projectId: "project-1",
163+
storage,
164+
initialState: createEmptyEditHistory(),
165+
now: () => timestamp++,
166+
onChange: () => {},
167+
});
168+
await store.recordEdit({
169+
label: "Edit first file",
170+
kind: "manual",
171+
files: { "first.html": { before: "first-before", after: "first-after" } },
172+
});
173+
await store.recordEdit({
174+
label: "Edit second file",
175+
kind: "manual",
176+
files: { "second.html": { before: "second-before", after: "second-after" } },
177+
});
178+
179+
const files: Record<string, string> = {
180+
"first.html": "first-after",
181+
"second.html": "second-after",
182+
};
183+
const readPaths: string[] = [];
184+
185+
await Promise.all([
186+
store.undo({
187+
readFile: async (path) => {
188+
readPaths.push(path);
189+
return files[path];
190+
},
191+
writeFile: async (path, content) => {
192+
files[path] = content;
193+
},
194+
}),
195+
store.undo({
196+
readFile: async (path) => {
197+
readPaths.push(path);
198+
return files[path];
199+
},
200+
writeFile: async (path, content) => {
201+
files[path] = content;
202+
},
203+
}),
204+
]);
205+
206+
expect(readPaths).toEqual(["second.html", "first.html"]);
207+
expect(files).toEqual({
208+
"first.html": "first-before",
209+
"second.html": "second-before",
210+
});
211+
expect(store.snapshot().canUndo).toBe(false);
212+
expect(store.snapshot().canRedo).toBe(true);
213+
});
214+
215+
it("rolls back files when an undo write fails partway through", async () => {
216+
const storage = createMemoryEditHistoryStorage();
217+
const store = createPersistentEditHistoryStore({
218+
projectId: "project-1",
219+
storage,
220+
initialState: createEmptyEditHistory(),
221+
now: () => 100,
222+
onChange: () => {},
223+
});
224+
await store.recordEdit({
225+
label: "Edit files",
226+
kind: "manual",
227+
files: {
228+
"first.html": { before: "first-before", after: "first-after" },
229+
"second.html": { before: "second-before", after: "second-after" },
230+
},
231+
});
232+
233+
const files: Record<string, string> = {
234+
"first.html": "first-after",
235+
"second.html": "second-after",
236+
};
237+
const result = store.undo({
238+
readFile: async (path) => files[path],
239+
writeFile: async (path, content) => {
240+
if (path === "second.html" && content === "second-before") {
241+
throw new Error("write failed");
242+
}
243+
files[path] = content;
244+
},
245+
});
246+
247+
await expect(result).rejects.toThrow("write failed");
248+
expect(files).toEqual({
249+
"first.html": "first-after",
250+
"second.html": "second-after",
251+
});
252+
expect(store.snapshot().undoLabel).toBe("Edit files");
253+
expect(store.snapshot().canRedo).toBe(false);
254+
});
255+
});

0 commit comments

Comments
 (0)