Skip to content

Commit e9a5316

Browse files
committed
fix(studio): an undo after an outside write shows the file the server restored
1 parent 9cfc9f2 commit e9a5316

10 files changed

Lines changed: 215 additions & 146 deletions

‎packages/studio/src/App.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
155155
previewPersistence,
156156
pendingTimelineEditPathRef,
157157
reloadPreview,
158+
onOutsideChange: editHistory.noteOutsideChange,
158159
});
159160
const invalidateGsapCacheRef = useRef<() => void>(() => {});
160161
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);

‎packages/studio/src/hooks/useEditHistoryActions.paint.test.tsx‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ import { useEditHistoryActions } from "./useEditHistoryActions";
1414
import { usePersistentEditHistory } from "./usePersistentEditHistory";
1515
import { usePreviewPersistence } from "./usePreviewPersistence";
1616

17-
const page = (left: string) =>
18-
`<!doctype html><html><head></head><body><div id="root" data-composition-id="main"><div id="box" style="position: absolute; left: ${left}"></div></div></body></html>`;
17+
const page = (left: string, top = "0px") =>
18+
`<!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>`;
1919
const BEFORE = page("10px");
2020
const AFTER = page("50px");
21+
const OUTSIDE = page("50px", "99px");
2122

2223
const cleanup: Array<() => unknown> = [];
2324
let scratch = "";
@@ -85,6 +86,7 @@ async function studio() {
8586
await act(async () => root.render(createElement(Harness)));
8687
cleanup.push(() => act(() => root.unmount()));
8788
const box = () => iframe.contentDocument!.getElementById("box")!.style.left;
89+
const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
8890
const show = (html: string) => {
8991
iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
9092
html,
@@ -108,6 +110,9 @@ async function studio() {
108110
persistence: () => persistence,
109111
actions: () => actions,
110112
box,
113+
other,
114+
show,
115+
path,
111116
edit,
112117
file: () => readFileSync(path, "utf8"),
113118
reloads,
@@ -147,3 +152,15 @@ it("an undo pressed while a save is still running waits for the server instead o
147152
expect(s.file()).toBe(BEFORE);
148153
expect(s.box()).toBe("10px");
149154
});
155+
156+
it("an undo after an outside write ends with the preview showing the file the server restored", async () => {
157+
const s = await studio();
158+
await s.edit();
159+
writeFileSync(s.path, OUTSIDE);
160+
s.show(OUTSIDE);
161+
162+
await act(() => s.actions().undo());
163+
expect(s.file()).toBe(AFTER);
164+
expect(s.other()).toBe("0px");
165+
expect(s.box()).toBe("50px");
166+
});

‎packages/studio/src/hooks/useEditHistoryActions.test.tsx‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ let root: Root | null = null;
1212
afterEach(() => act(() => root?.unmount()));
1313

1414
type RestoreFiles = Record<string, { previous: string; restored: string }>;
15+
type Prediction = { id: string; files: RestoreFiles };
1516

1617
function mount(
1718
result: {
@@ -20,23 +21,26 @@ function mount(
2021
message?: string;
2122
label?: string;
2223
paths?: string[];
24+
undoes?: string;
2325
files?: RestoreFiles;
2426
},
25-
predicted?: RestoreFiles,
27+
predicted?: Prediction,
2628
) {
2729
const editHistory = {
2830
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
2931
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
3032
predict: () => predicted ?? null,
3133
};
34+
const putBack = vi.fn();
3235
const deps = {
3336
editHistory,
3437
readOptionalProjectFile: vi.fn(async () => ""),
3538
readProjectFile: vi.fn(async () => ""),
3639
writeProjectFile: vi.fn(async () => undefined),
3740
showToast: vi.fn(),
3841
syncHistoryPreviewAfterApply: vi.fn(async (_restore: unknown) => undefined),
39-
showHistoryRestoreNow: vi.fn((_files: RestoreFiles) => true),
42+
putBack,
43+
showHistoryRestoreNow: vi.fn((_files: RestoreFiles) => putBack),
4044
waitForPendingDomEditSaves: vi.fn(async () => undefined),
4145
onAfterUndoRedo: vi.fn(),
4246
activeCompPath: "index.html",
@@ -52,35 +56,53 @@ function mount(
5256
return { deps, actions };
5357
}
5458

55-
const PREDICTED = { "index.html": { previous: "B", restored: "A" } };
59+
const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored: "A" } } };
60+
const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
5661

5762
describe("useEditHistoryActions", () => {
5863
it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
59-
const files = { "index.html": { previous: "B", restored: "A2" } };
6064
const { deps, actions } = mount(
61-
{ ok: true, label: "Undid: Move", paths: ["index.html"], files },
65+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
6266
PREDICTED,
6367
);
6468
await act(() => actions.undo());
65-
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED);
69+
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
70+
expect(deps.putBack).not.toHaveBeenCalled();
6671
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
6772
paths: ["index.html"],
6873
files: { "index.html": { previous: "A", restored: "A2" } },
6974
});
7075
});
7176

72-
it("takes a shown step back when the server refuses it", async () => {
77+
it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
7378
const { deps, actions } = mount(
74-
{ ok: false, reason: "failed", message: "disk full" },
79+
{
80+
ok: true,
81+
label: "Undid: Outside",
82+
paths: ["index.html"],
83+
undoes: "e0",
84+
files: SERVER_FILES,
85+
},
7586
PREDICTED,
7687
);
7788
await act(() => actions.undo());
89+
expect(deps.putBack).toHaveBeenCalledTimes(1);
7890
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
7991
paths: ["index.html"],
80-
files: { "index.html": { previous: "A", restored: "B" } },
92+
files: SERVER_FILES,
8193
});
8294
});
8395

96+
it("puts a shown step back when the server refuses it", async () => {
97+
const { deps, actions } = mount(
98+
{ ok: false, reason: "failed", message: "disk full" },
99+
PREDICTED,
100+
);
101+
await act(() => actions.undo());
102+
expect(deps.putBack).toHaveBeenCalledTimes(1);
103+
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
104+
});
105+
84106
it("undo resyncs the preview and toasts the step as the history names it", async () => {
85107
const { deps, actions } = mount({ ok: true, label: "Undid: Move clip", paths: ["index.html"] });
86108
await act(() => actions.undo());

‎packages/studio/src/hooks/useEditHistoryActions.ts‎

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,26 @@
22
import { useCallback, useMemo } from "react";
33
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
44
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
5+
import type { RestoreFiles } from "../utils/gsapUndoRestore";
56

67
interface HistoryResult {
78
ok: boolean;
89
reason?: string;
910
message?: string;
1011
label?: string;
1112
paths?: string[];
13+
undoes?: string;
1214
/** Per-file restored/previous content, used to soft-apply the preview. */
13-
files?: Record<string, { previous: string; restored: string }>;
15+
files?: RestoreFiles;
1416
}
15-
type RestoreFiles = Record<string, { previous: string; restored: string }>;
1617
interface HistoryFileCallbacks {
1718
readFile: (path: string) => Promise<string>;
1819
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
1920
}
2021
export interface EditHistoryHandle {
2122
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
2223
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
23-
predict?: (direction: "undo" | "redo") => RestoreFiles | null;
24+
predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
2425
state: {
2526
undo: ReadonlyArray<{ createdAt: number }>;
2627
redo: ReadonlyArray<{ createdAt: number }>;
@@ -34,7 +35,7 @@ export interface UseEditHistoryActionsOptions {
3435
writeProjectFile: (path: string, content: string) => Promise<void>;
3536
showToast: (message: string, tone?: "error" | "info") => void;
3637
syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
37-
showHistoryRestoreNow?: (files: RestoreFiles) => boolean;
38+
showHistoryRestoreNow?: (files: RestoreFiles) => (() => void) | null;
3839
waitForPendingDomEditSaves: () => Promise<void>;
3940
onAfterUndoRedo?: (restore: Pick<HistoryResult, "paths" | "files">) => void;
4041
/** Active composition path — decides whether undo/redo must resync the SDK session. */
@@ -73,21 +74,19 @@ export function useEditHistoryActions({
7374
const noun = direction === "undo" ? "Undo" : "Redo";
7475
// Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
7576
const predicted = editHistory.predict?.(direction) ?? null;
76-
const shown = predicted && showHistoryRestoreNow?.(predicted) ? predicted : null;
77+
const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
7778
let result: HistoryResult = { ok: false, reason: "failed" };
79+
let serverSteppedShown = false;
7880
try {
7981
await waitForPendingDomEditSaves();
8082
result = await editHistory[direction]({
8183
readFile: readHistoryFile,
8284
serialize: serializeHistoryFiles,
8385
});
86+
serverSteppedShown =
87+
Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id;
8488
} finally {
85-
if (shown && !(result.ok && result.label)) {
86-
void syncHistoryPreviewAfterApply({
87-
paths: Object.keys(shown),
88-
files: swapRestore(shown),
89-
});
90-
}
89+
if (putBack && !serverSteppedShown) putBack();
9190
}
9291
if (!result.ok && result.reason === "content-mismatch") {
9392
showToast(
@@ -101,7 +100,8 @@ export function useEditHistoryActions({
101100
return;
102101
}
103102
if (result.ok && result.label) {
104-
const restore = { paths: result.paths, files: fromShown(result.files, shown) };
103+
const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files;
104+
const restore = { paths: result.paths, files };
105105
onAfterUndoRedo?.(restore);
106106
if (activeCompPath && result.paths?.includes(activeCompPath)) {
107107
forceReloadSdkSession?.();
@@ -129,17 +129,8 @@ export function useEditHistoryActions({
129129
return useMemo(() => ({ undo, redo }), [undo, redo]);
130130
}
131131

132-
function swapRestore(files: RestoreFiles): RestoreFiles {
133-
return Object.fromEntries(
134-
Object.entries(files).map(([path, f]) => [
135-
path,
136-
{ previous: f.restored, restored: f.previous },
137-
]),
138-
);
139-
}
140-
141-
function fromShown(files: RestoreFiles | undefined, shown: RestoreFiles | null) {
142-
if (!files || !shown) return files;
132+
function fromShown(files: RestoreFiles | undefined, shown: RestoreFiles) {
133+
if (!files) return files;
143134
return Object.fromEntries(
144135
Object.entries(files).map(([path, f]) => [
145136
path,

‎packages/studio/src/hooks/usePersistentEditHistory.test.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ it("an edit Studio saved is undone and redone by the project's history, with the
7676
expect(undone).toEqual({
7777
ok: true,
7878
label: "Undid: Moved Title",
79+
undoes: expect.any(String),
7980
paths: ["index.html"],
8081
files: { "index.html": { previous: "B", restored: "A" } },
8182
});
@@ -251,15 +252,23 @@ it("predicts a step from what this tab wrote, and not while a claim or step may
251252
});
252253
expect(hook().predict("undo")).toBeNull();
253254
await act(() => claim);
254-
await vi.waitFor(() =>
255-
expect(hook().predict("undo")).toEqual({ "index.html": { previous: "B", restored: "A" } }),
256-
);
255+
const predicted = await vi.waitFor(() => {
256+
const next = hook().predict("undo");
257+
expect(next?.files).toEqual({ "index.html": { previous: "B", restored: "A" } });
258+
return next!;
259+
});
260+
261+
hook().noteOutsideChange();
262+
expect(hook().predict("undo")).toBeNull();
263+
await vi.waitFor(() => expect(hook().predict("undo")?.id).toBe(predicted.id));
257264

258265
const undone = hook().undo({ readFile });
259266
expect(hook().predict("undo")).toBeNull();
260-
await act(() => undone);
267+
expect((await act(() => undone)).undoes).toBe(predicted.id);
261268
await vi.waitFor(() =>
262-
expect(hook().predict("redo")).toEqual({ "index.html": { previous: "A", restored: "B" } }),
269+
expect(hook().predict("redo")?.files).toEqual({
270+
"index.html": { previous: "A", restored: "B" },
271+
}),
263272
);
264273
});
265274

‎packages/studio/src/hooks/usePersistentEditHistory.ts‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server";
33
import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
4+
import type { RestoreFiles } from "../utils/gsapUndoRestore";
45

56
interface RecordEditInput {
67
label: string;
@@ -18,19 +19,15 @@ export interface UsePersistentEditHistoryOptions {
1819
projectId: string | null;
1920
}
2021

21-
interface ApplyRestoredFile {
22-
previous: string;
23-
restored: string;
24-
}
25-
2622
interface ApplyResult {
2723
ok: boolean;
2824
/** content-mismatch: `paths` changed after the step's entry. failed: `message` says why. */
2925
reason?: "empty" | "content-mismatch" | "failed";
3026
message?: string;
3127
label?: string;
3228
paths?: string[];
33-
files?: Record<string, ApplyRestoredFile>;
29+
undoes?: string;
30+
files?: RestoreFiles;
3431
}
3532

3633
interface NextStep {
@@ -87,7 +84,7 @@ function createOwnHistory() {
8784
},
8885
afterOf: (id: string | undefined): Record<string, string> =>
8986
Object.fromEntries(Object.entries((id && own.get(id)) || {}).map(([p, f]) => [p, f.after])),
90-
predict: (direction: "undo" | "redo"): Record<string, ApplyRestoredFile> | null => {
87+
predict: (direction: "undo" | "redo"): { id: string; files: RestoreFiles } | null => {
9188
const step = next?.[direction];
9289
const files = step ? own.get(step.id) : undefined;
9390
const paths = files ? Object.keys(files) : [];
@@ -97,12 +94,11 @@ function createOwnHistory() {
9794
!paths.every((p) => step!.paths.includes(p))
9895
)
9996
return null;
100-
return Object.fromEntries(
101-
paths.map((path) => [
102-
path,
103-
{ previous: files[path]!.after, restored: files[path]!.before },
104-
]),
105-
);
97+
const restore = paths.map((path) => [
98+
path,
99+
{ previous: files[path]!.after, restored: files[path]!.before },
100+
]);
101+
return { id: step!.id, files: Object.fromEntries(restore) };
106102
},
107103
clear: () => {
108104
own.clear();
@@ -171,7 +167,7 @@ async function restoredFiles(
171167
paths: readonly string[],
172168
previous: Record<string, string> | null,
173169
readFile: (path: string) => Promise<string>,
174-
): Promise<Record<string, ApplyRestoredFile> | undefined> {
170+
): Promise<RestoreFiles | undefined> {
175171
if (!previous || paths.some((path) => !(path in previous))) return undefined;
176172
const restored = await readAll(paths, readFile);
177173
if (!restored) return undefined;
@@ -261,6 +257,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
261257
return {
262258
ok: true,
263259
label: reply.entry.label,
260+
undoes: reply.entry.undoes,
264261
paths: changed,
265262
files: await restoredFiles(
266263
changed,
@@ -274,6 +271,11 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
274271
[projectId, view, refresh, own],
275272
);
276273

274+
const noteOutsideChange = useCallback(() => {
275+
own.overtake();
276+
void refresh();
277+
}, [own, refresh]);
278+
277279
const undo = useCallback((callbacks: ApplyCallbacks) => step("undo", callbacks), [step]);
278280
const redo = useCallback((callbacks: ApplyCallbacks) => step("redo", callbacks), [step]);
279281

@@ -299,5 +301,6 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
299301
undo,
300302
redo,
301303
predict: own.predict,
304+
noteOutsideChange,
302305
};
303306
}

0 commit comments

Comments
 (0)