Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
343 changes: 241 additions & 102 deletions packages/studio/src/App.tsx

Large diffs are not rendered by default.

255 changes: 255 additions & 0 deletions packages/studio/src/hooks/usePersistentEditHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import { describe, expect, it } from "vitest";
import { createEmptyEditHistory } from "../utils/editHistory";
import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
import {
createPersistentEditHistoryController,
createPersistentEditHistoryStore,
} from "./usePersistentEditHistory";

describe("createPersistentEditHistoryController", () => {
it("records history and reloads it for the same project", async () => {
const storage = createMemoryEditHistoryStorage();
const first = await createPersistentEditHistoryController({
projectId: "project-1",
storage,
now: () => 100,
onChange: () => {},
});

await first.recordEdit({
label: "Move layer",
kind: "manual",
files: { "index.html": { before: "a", after: "b" } },
});

const second = await createPersistentEditHistoryController({
projectId: "project-1",
storage,
now: () => 200,
onChange: () => {},
});

expect(second.snapshot().canUndo).toBe(true);
expect(second.snapshot().undoLabel).toBe("Move layer");
expect(second.snapshot().undoPaths).toEqual(["index.html"]);
});

it("undo applies files through the provided callback and persists redo state", async () => {
const storage = createMemoryEditHistoryStorage();
const controller = await createPersistentEditHistoryController({
projectId: "project-1",
storage,
now: () => 100,
onChange: () => {},
});
await controller.recordEdit({
label: "Move layer",
kind: "manual",
files: { "index.html": { before: "a", after: "b" } },
});

const result = await controller.undo({
readFile: async (path) => {
expect(path).toBe("index.html");
return "b";
},
writeFile: async (path, content) => {
expect(path).toBe("index.html");
expect(content).toBe("a");
},
});
expect(result.ok).toBe(true);

expect(controller.snapshot().canUndo).toBe(false);
expect(controller.snapshot().canRedo).toBe(true);
expect(controller.snapshot().redoPaths).toEqual(["index.html"]);
});

it("keeps in-memory history when storage saves fail", async () => {
const storage: EditHistoryStorageAdapter = {
async get() {
return null;
},
async set() {
throw new Error("IndexedDB unavailable");
},
async delete() {},
};
const controller = await createPersistentEditHistoryController({
projectId: "project-1",
storage,
now: () => 100,
onChange: () => {},
});

await expect(
controller.recordEdit({
label: "Move layer",
kind: "manual",
files: { "index.html": { before: "a", after: "b" } },
}),
).resolves.toBeUndefined();

expect(controller.snapshot().canUndo).toBe(true);
});

it("serializes concurrent record edits against the latest state", async () => {
const storage = createMemoryEditHistoryStorage();
let timestamp = 100;
const store = createPersistentEditHistoryStore({
projectId: "project-1",
storage,
initialState: createEmptyEditHistory(),
now: () => timestamp++,
onChange: () => {},
});

await Promise.all([
store.recordEdit({
label: "Move layer",
kind: "manual",
files: { "index.html": { before: "a", after: "b" } },
}),
store.recordEdit({
label: "Resize layer",
kind: "manual",
files: { "index.html": { before: "b", after: "c" } },
}),
]);

expect(store.snapshot().state.undo.map((entry) => entry.label)).toEqual([
"Move layer",
"Resize layer",
]);
});

it("still coalesces concurrent source edits that share a coalesce key", async () => {
const storage = createMemoryEditHistoryStorage();
let timestamp = 100;
const store = createPersistentEditHistoryStore({
projectId: "project-1",
storage,
initialState: createEmptyEditHistory(),
now: () => timestamp++,
onChange: () => {},
});

await Promise.all([
store.recordEdit({
label: "Edit source",
kind: "source",
coalesceKey: "source:index.html",
files: { "index.html": { before: "a", after: "b" } },
}),
store.recordEdit({
label: "Edit source",
kind: "source",
coalesceKey: "source:index.html",
files: { "index.html": { before: "b", after: "c" } },
}),
]);

expect(store.snapshot().state.undo).toHaveLength(1);
expect(store.snapshot().state.undo[0].files["index.html"].before).toBe("a");
expect(store.snapshot().state.undo[0].files["index.html"].after).toBe("c");
});

it("reads undo hashes from the live top entry during queued undo calls", async () => {
const storage = createMemoryEditHistoryStorage();
let timestamp = 100;
const store = createPersistentEditHistoryStore({
projectId: "project-1",
storage,
initialState: createEmptyEditHistory(),
now: () => timestamp++,
onChange: () => {},
});
await store.recordEdit({
label: "Edit first file",
kind: "manual",
files: { "first.html": { before: "first-before", after: "first-after" } },
});
await store.recordEdit({
label: "Edit second file",
kind: "manual",
files: { "second.html": { before: "second-before", after: "second-after" } },
});

const files: Record<string, string> = {
"first.html": "first-after",
"second.html": "second-after",
};
const readPaths: string[] = [];

await Promise.all([
store.undo({
readFile: async (path) => {
readPaths.push(path);
return files[path];
},
writeFile: async (path, content) => {
files[path] = content;
},
}),
store.undo({
readFile: async (path) => {
readPaths.push(path);
return files[path];
},
writeFile: async (path, content) => {
files[path] = content;
},
}),
]);

expect(readPaths).toEqual(["second.html", "first.html"]);
expect(files).toEqual({
"first.html": "first-before",
"second.html": "second-before",
});
expect(store.snapshot().canUndo).toBe(false);
expect(store.snapshot().canRedo).toBe(true);
});

it("rolls back files when an undo write fails partway through", async () => {
const storage = createMemoryEditHistoryStorage();
const store = createPersistentEditHistoryStore({
projectId: "project-1",
storage,
initialState: createEmptyEditHistory(),
now: () => 100,
onChange: () => {},
});
await store.recordEdit({
label: "Edit files",
kind: "manual",
files: {
"first.html": { before: "first-before", after: "first-after" },
"second.html": { before: "second-before", after: "second-after" },
},
});

const files: Record<string, string> = {
"first.html": "first-after",
"second.html": "second-after",
};
const result = store.undo({
readFile: async (path) => files[path],
writeFile: async (path, content) => {
if (path === "second.html" && content === "second-before") {
throw new Error("write failed");
}
files[path] = content;
},
});

await expect(result).rejects.toThrow("write failed");
expect(files).toEqual({
"first.html": "first-after",
"second.html": "second-after",
});
expect(store.snapshot().undoLabel).toBe("Edit files");
expect(store.snapshot().canRedo).toBe(false);
});
});
Loading
Loading