Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
previewPersistence,
pendingTimelineEditPathRef,
reloadPreview,
onOutsideChange: editHistory.noteOutsideChange,
});
const invalidateGsapCacheRef = useRef<() => void>(() => {});
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
Expand Down Expand Up @@ -254,6 +255,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
writeProjectFile: fileManager.writeProjectFile,
showToast,
syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
showHistoryRestoreNow: previewPersistence.showHistoryRestoreNow,
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
handleCopy,
handlePaste,
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ interface UseAppHotkeysParams {
writeProjectFile: (path: string, content: string) => Promise<void>;
showToast: (message: string, tone?: "error" | "info") => void;
syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"];
waitForPendingDomEditSaves: () => Promise<void>;
handleCopy: () => boolean;
handlePaste: () => Promise<void>;
Expand Down Expand Up @@ -124,6 +125,7 @@ export function useAppHotkeys({
writeProjectFile,
showToast,
syncHistoryPreviewAfterApply,
showHistoryRestoreNow,
waitForPendingDomEditSaves,
handleCopy,
handlePaste,
Expand All @@ -150,6 +152,7 @@ export function useAppHotkeys({
writeProjectFile,
showToast,
syncHistoryPreviewAfterApply,
showHistoryRestoreNow,
waitForPendingDomEditSaves,
onAfterUndoRedo,
activeCompPath,
Expand Down
166 changes: 166 additions & 0 deletions packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// @vitest-environment happy-dom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
createStudioApi,
openProjectHistory,
type StudioApiAdapter,
} from "@hyperframes/studio-server";
import { useEditHistoryActions } from "./useEditHistoryActions";
import { usePersistentEditHistory } from "./usePersistentEditHistory";
import { usePreviewPersistence } from "./usePreviewPersistence";

const page = (left: string, top = "0px") =>
`<!doctype html><html><head></head><body><div id="root" data-composition-id="main"><div id="box" style="position: absolute; left: ${left}"></div><div id="other" style="position: absolute; top: ${top}"></div></div></body></html>`;
const BEFORE = page("10px");
const AFTER = page("50px");
const OUTSIDE = page("50px", "99px");

const cleanup: Array<() => unknown> = [];
let scratch = "";
beforeEach(() => {
scratch = mkdtempSync(join(tmpdir(), "hf-undo-paint-"));
});
afterEach(async () => {
for (const step of cleanup.splice(0).reverse()) await step();
rmSync(scratch, { recursive: true, force: true });
vi.unstubAllGlobals();
});

/** Studio's undo wired as App.tsx wires it, over the real history engine, with a live preview document. */
async function studio() {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const dir = join(scratch, "project");
mkdirSync(dir);
const path = join(dir, "index.html");
writeFileSync(path, BEFORE);
const engine = await openProjectHistory({
projectDir: dir,
historyRoot: join(scratch, "history"),
});
cleanup.push(() => engine.close());
const api = createStudioApi({
listProjects: () => [],
resolveProject: (id: string) => (id === "demo" ? { id, dir } : null),
history: () => engine,
} as unknown as StudioApiAdapter);
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
api.request(url.replace(/^\/api/, ""), init),
);
const iframe = document.createElement("iframe");
document.body.append(iframe);
cleanup.push(() => iframe.remove());
const reloads = vi.fn();
const readFile = async (p: string) => readFileSync(join(dir, p), "utf8");
let history!: ReturnType<typeof usePersistentEditHistory>;
let persistence!: ReturnType<typeof usePreviewPersistence>;
let actions!: ReturnType<typeof useEditHistoryActions>;
function Harness() {
history = usePersistentEditHistory({ projectId: "demo" });
persistence = usePreviewPersistence({
showToast: () => {},
readOptionalProjectFile: async () => "",
writeProjectFile: async () => {},
recordEdit: async () => {},
previewIframeRef: { current: iframe },
activeCompPathRef: { current: "index.html" },
reloadPreview: reloads,
});
actions = useEditHistoryActions({
editHistory: history,
readOptionalProjectFile: async () => "",
readProjectFile: readFile,
writeProjectFile: async (p, content) => writeFileSync(join(dir, p), content),
showToast: () => {},
syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
showHistoryRestoreNow: persistence.showHistoryRestoreNow,
waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves,
});
return null;
}
const root = createRoot(document.createElement("div"));
await act(async () => root.render(createElement(Harness)));
cleanup.push(() => act(() => root.unmount()));
const box = () => iframe.contentDocument!.getElementById("box")!.style.left;
const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
const show = (html: string) => {
iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
html,
"text/html",
).documentElement.innerHTML;
};
/** A move Studio saved: the live preview already shows it, then the file and the history claim land. */
const edit = async () => {
show(AFTER);
writeFileSync(path, AFTER);
await act(() =>
history.recordEdit({
label: "Move layer",
files: { "index.html": { before: BEFORE, after: AFTER } },
}),
);
await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
};
return {
history: () => history,
persistence: () => persistence,
actions: () => actions,
box,
other,
show,
path,
edit,
file: () => readFileSync(path, "utf8"),
reloads,
};
}

it("undo and redo of a style edit paint in the key's own task, before the server answers", async () => {
const s = await studio();
await s.edit();

const undone = s.actions().undo();
expect(s.box()).toBe("10px");
await act(() => undone);
expect(s.file()).toBe(BEFORE);
expect(s.box()).toBe("10px");

await vi.waitFor(() => expect(s.history().redoLabel).toBeTruthy());
const redone = s.actions().redo();
expect(s.box()).toBe("50px");
await act(() => redone);
expect(s.file()).toBe(AFTER);
expect(s.box()).toBe("50px");
expect(s.reloads).not.toHaveBeenCalled();
});

it("an undo pressed while a save is still running waits for the server instead of guessing", async () => {
const s = await studio();
await s.edit();
let finish!: () => void;
const saving = new Promise<void>((resolve) => (finish = resolve));
void s.persistence().queueDomEditSave(() => saving);

const undone = s.actions().undo();
expect(s.box()).toBe("50px");
finish();
await act(() => undone);
expect(s.file()).toBe(BEFORE);
expect(s.box()).toBe("10px");
});

it("an undo after an outside write ends with the preview showing the file the server restored", async () => {
const s = await studio();
await s.edit();
writeFileSync(s.path, OUTSIDE);
s.show(OUTSIDE);

await act(() => s.actions().undo());
expect(s.file()).toBe(AFTER);
expect(s.other()).toBe("0px");
expect(s.box()).toBe("50px");
});
76 changes: 67 additions & 9 deletions packages/studio/src/hooks/useEditHistoryActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,36 @@ import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryA
let root: Root | null = null;
afterEach(() => act(() => root?.unmount()));

function mount(result: {
ok: boolean;
reason?: string;
message?: string;
label?: string;
paths?: string[];
}) {
type RestoreFiles = Record<string, { previous: string; restored: string }>;
type Prediction = { id: string; files: RestoreFiles };

function mount(
result: {
ok: boolean;
reason?: string;
message?: string;
label?: string;
paths?: string[];
undoes?: string;
files?: RestoreFiles;
},
predicted?: Prediction,
) {
const editHistory = {
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
predict: () => predicted ?? null,
};
const putBack = vi.fn();
const deps = {
editHistory,
readOptionalProjectFile: vi.fn(async () => ""),
readProjectFile: vi.fn(async () => ""),
writeProjectFile: vi.fn(async () => undefined),
showToast: vi.fn(),
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
syncHistoryPreviewAfterApply: vi.fn(async (_restore: unknown) => undefined),
putBack,
showHistoryRestoreNow: vi.fn((_files: RestoreFiles) => putBack),
waitForPendingDomEditSaves: vi.fn(async () => undefined),
onAfterUndoRedo: vi.fn(),
activeCompPath: "index.html",
Expand All @@ -44,7 +56,53 @@ function mount(result: {
return { deps, actions };
}

const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored: "A" } } };
const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };

describe("useEditHistoryActions", () => {
it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
const { deps, actions } = mount(
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
PREDICTED,
);
await act(() => actions.undo());
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
expect(deps.putBack).not.toHaveBeenCalled();
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
paths: ["index.html"],
files: { "index.html": { previous: "A", restored: "A2" } },
});
});

it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
const { deps, actions } = mount(
{
ok: true,
label: "Undid: Outside",
paths: ["index.html"],
undoes: "e0",
files: SERVER_FILES,
},
PREDICTED,
);
await act(() => actions.undo());
expect(deps.putBack).toHaveBeenCalledTimes(1);
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
paths: ["index.html"],
files: SERVER_FILES,
});
});

it("puts a shown step back when the server refuses it", async () => {
const { deps, actions } = mount(
{ ok: false, reason: "failed", message: "disk full" },
PREDICTED,
);
await act(() => actions.undo());
expect(deps.putBack).toHaveBeenCalledTimes(1);
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
});

it("undo resyncs the preview and toasts the step as the history names it", async () => {
const { deps, actions } = mount({ ok: true, label: "Undid: Move clip", paths: ["index.html"] });
await act(() => actions.undo());
Expand Down Expand Up @@ -80,7 +138,7 @@ describe("useEditHistoryActions", () => {
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
});

it("says why when the history could not take the step", async () => {
it("says why when the history could not take the step, with nothing shown to take back", async () => {
const { deps, actions } = mount({ ok: false, reason: "failed", message: "disk full" });
await act(() => actions.undo());
expect(deps.showToast).toHaveBeenCalledWith("Undo failed: disk full", "error");
Expand Down
Loading
Loading