@hyperframes/studio 0.8.100 → 0.8.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/assets/{hyperframes-player-fWt87gyh.js → hyperframes-player-CFJLrHRF.js} +1 -1
  2. package/dist/assets/{index-D6TjRAvz.js → index-BBNVnnyH.js} +114 -114
  3. package/dist/assets/{index-DofdzdxJ.js → index-C33EDBL2.js} +1 -1
  4. package/dist/assets/{index-D34Oy5DN.js → index-CJOzbslX.js} +1 -1
  5. package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
  6. package/dist/chunk-7X4XRP3L.js.map +1 -0
  7. package/dist/index.d.ts +91 -21
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +403 -226
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +2 -0
  14. package/src/components/dock/Dock.test.tsx +78 -3
  15. package/src/components/dock/Dock.tsx +45 -18
  16. package/src/components/dock/dockLayout.ts +27 -14
  17. package/src/components/dock/dockLayoutSchema.ts +11 -4
  18. package/src/components/dock/dockLayoutStore.ts +4 -2
  19. package/src/hooks/appHotkeysDispatch.ts +1 -7
  20. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
  21. package/src/hooks/useAppHotkeys.ts +18 -45
  22. package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
  23. package/src/hooks/useEditHistoryActions.test.tsx +67 -9
  24. package/src/hooks/useEditHistoryActions.ts +36 -8
  25. package/src/hooks/usePersistentEditHistory.test.ts +46 -0
  26. package/src/hooks/usePersistentEditHistory.ts +101 -19
  27. package/src/hooks/usePreviewPersistence.ts +25 -2
  28. package/src/hooks/useStudioExternalFileChanges.ts +7 -3
  29. package/src/index.ts +4 -0
  30. package/src/player/components/timelineDragDrop.test.tsx +61 -5
  31. package/src/player/components/timelineDragDrop.ts +29 -8
  32. package/src/player/components/useTimelineProviderState.tsx +1 -0
  33. package/src/utils/domEditSaveQueue.ts +7 -2
  34. package/src/utils/gsapSoftReload.ts +1 -1
  35. package/src/utils/gsapUndoRestore.test.ts +46 -0
  36. package/src/utils/gsapUndoRestore.ts +166 -91
  37. package/src/utils/studioPendingEdits.ts +14 -5
  38. package/src/utils/studioUiPreferences.ts +8 -6
  39. package/dist/chunk-U3EE4ACX.js.map +0 -1
@@ -8,13 +8,7 @@ import {
8
8
  applyCaptionModelToIframe,
9
9
  isCaptionPreviewVisible,
10
10
  } from "../captions/components/CaptionOverlayUtils";
11
- import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
12
- import {
13
- type HotkeyCallbacks,
14
- dispatchModifierKey,
15
- dispatchPlainKey,
16
- handleUndoRedoKey,
17
- } from "./appHotkeysDispatch";
11
+ import { type HotkeyCallbacks, dispatchModifierKey, dispatchPlainKey } from "./appHotkeysDispatch";
18
12
  import {
19
13
  useEditHistoryActions,
20
14
  type EditHistoryHandle,
@@ -36,9 +30,14 @@ function safeAddListener(t: EventTarget | null, type: string, h: EventListener,
36
30
  /* cross-origin */
37
31
  }
38
32
  }
39
- function safeRemoveListener(t: EventTarget | null, type: string, h: EventListener) {
33
+ function safeRemoveListener(
34
+ t: EventTarget | null,
35
+ type: string,
36
+ h: EventListener,
37
+ capture = false,
38
+ ) {
40
39
  try {
41
- t?.removeEventListener(type, h);
40
+ t?.removeEventListener(type, h, capture);
42
41
  } catch {
43
42
  /* cross-origin */
44
43
  }
@@ -86,6 +85,7 @@ interface UseAppHotkeysParams {
86
85
  writeProjectFile: (path: string, content: string) => Promise<void>;
87
86
  showToast: (message: string, tone?: "error" | "info") => void;
88
87
  syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
88
+ showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"];
89
89
  waitForPendingDomEditSaves: () => Promise<void>;
90
90
  handleCopy: () => boolean;
91
91
  handlePaste: () => Promise<void>;
@@ -124,6 +124,7 @@ export function useAppHotkeys({
124
124
  writeProjectFile,
125
125
  showToast,
126
126
  syncHistoryPreviewAfterApply,
127
+ showHistoryRestoreNow,
127
128
  waitForPendingDomEditSaves,
128
129
  handleCopy,
129
130
  handlePaste,
@@ -150,6 +151,7 @@ export function useAppHotkeys({
150
151
  writeProjectFile,
151
152
  showToast,
152
153
  syncHistoryPreviewAfterApply,
154
+ showHistoryRestoreNow,
153
155
  waitForPendingDomEditSaves,
154
156
  onAfterUndoRedo,
155
157
  activeCompPath,
@@ -229,52 +231,23 @@ export function useAppHotkeys({
229
231
 
230
232
  // ── Preview iframe forwarding ──
231
233
 
232
- const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
233
- if (!(event.metaKey || event.ctrlKey) || shouldIgnoreHistoryShortcut(event.target)) return;
234
- handleUndoRedoKey(
235
- event,
236
- () => void cbRef.current.handleUndo(),
237
- () => void cbRef.current.handleRedo(),
238
- );
239
- }, []);
240
-
241
234
  /**
242
- * Give the preview iframe the app's hotkeys, because a keypress lands in
243
- * whichever document has focus and clicking the canvas puts focus in there.
244
- *
245
- * Must run on every iframe LOAD, not once when the element mounts: a reload
246
- * keeps the same element (so no ref callback) and the same WindowProxy (so an
247
- * identity check sees no change) while replacing the inner window that holds
248
- * the listeners. Attaching once left Delete dead in the canvas after the first
249
- * reload — press it with a selection and nothing happened, no toast, nothing
250
- * to explain it — while undo/redo kept working because they re-attached here.
235
+ * Give the preview iframe the app's hotkeys: clicking the canvas puts focus in there.
236
+ * Runs on every iframe LOAD: a reload keeps the element and WindowProxy but replaces the
237
+ * inner window holding the listeners, which once left Delete dead after the first reload.
251
238
  */
252
239
  const syncPreviewHotkeys = useCallback(
253
240
  (iframe: HTMLIFrameElement | null) => {
254
241
  previewHistoryCleanupRef.current?.();
255
242
  previewHistoryCleanupRef.current = null;
256
243
  const win = iframeContentWindow(iframe);
257
- let doc: Document | null = null;
258
- try {
259
- doc = iframe?.contentDocument ?? null;
260
- } catch {
261
- doc = null;
262
- }
263
- if (!win && !doc) return;
264
- const handler = handleHistoryHotkey as EventListener;
244
+ if (!win) return;
265
245
  const appHandler = handleAppKeyDown as EventListener;
266
- safeAddListener(win, "keydown", handler, true);
267
- // Window only: the history pair also listens on the document, and a
268
- // capture listener on both would run the app handler twice per press.
246
+ // Window only: a capture listener on the document too would run it twice per press.
269
247
  safeAddListener(win, "keydown", appHandler, true);
270
- doc?.addEventListener("keydown", handleHistoryHotkey, true);
271
- previewHistoryCleanupRef.current = () => {
272
- safeRemoveListener(win, "keydown", handler);
273
- safeRemoveListener(win, "keydown", appHandler);
274
- doc?.removeEventListener("keydown", handleHistoryHotkey, true);
275
- };
248
+ previewHistoryCleanupRef.current = () => safeRemoveListener(win, "keydown", appHandler, true);
276
249
  },
277
- [handleAppKeyDown, handleHistoryHotkey],
250
+ [handleAppKeyDown],
278
251
  );
279
252
 
280
253
  useEffect(
@@ -0,0 +1,166 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
8
+ import {
9
+ createStudioApi,
10
+ openProjectHistory,
11
+ type StudioApiAdapter,
12
+ } from "@hyperframes/studio-server";
13
+ import { useEditHistoryActions } from "./useEditHistoryActions";
14
+ import { usePersistentEditHistory } from "./usePersistentEditHistory";
15
+ import { usePreviewPersistence } from "./usePreviewPersistence";
16
+
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>`;
19
+ const BEFORE = page("10px");
20
+ const AFTER = page("50px");
21
+ const OUTSIDE = page("50px", "99px");
22
+
23
+ const cleanup: Array<() => unknown> = [];
24
+ let scratch = "";
25
+ beforeEach(() => {
26
+ scratch = mkdtempSync(join(tmpdir(), "hf-undo-paint-"));
27
+ });
28
+ afterEach(async () => {
29
+ for (const step of cleanup.splice(0).reverse()) await step();
30
+ rmSync(scratch, { recursive: true, force: true });
31
+ vi.unstubAllGlobals();
32
+ });
33
+
34
+ /** Studio's undo wired as App.tsx wires it, over the real history engine, with a live preview document. */
35
+ async function studio() {
36
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
37
+ const dir = join(scratch, "project");
38
+ mkdirSync(dir);
39
+ const path = join(dir, "index.html");
40
+ writeFileSync(path, BEFORE);
41
+ const engine = await openProjectHistory({
42
+ projectDir: dir,
43
+ historyRoot: join(scratch, "history"),
44
+ });
45
+ cleanup.push(() => engine.close());
46
+ const api = createStudioApi({
47
+ listProjects: () => [],
48
+ resolveProject: (id: string) => (id === "demo" ? { id, dir } : null),
49
+ history: () => engine,
50
+ } as unknown as StudioApiAdapter);
51
+ vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
52
+ api.request(url.replace(/^\/api/, ""), init),
53
+ );
54
+ const iframe = document.createElement("iframe");
55
+ document.body.append(iframe);
56
+ cleanup.push(() => iframe.remove());
57
+ const reloads = vi.fn();
58
+ const readFile = async (p: string) => readFileSync(join(dir, p), "utf8");
59
+ let history!: ReturnType<typeof usePersistentEditHistory>;
60
+ let persistence!: ReturnType<typeof usePreviewPersistence>;
61
+ let actions!: ReturnType<typeof useEditHistoryActions>;
62
+ function Harness() {
63
+ history = usePersistentEditHistory({ projectId: "demo" });
64
+ persistence = usePreviewPersistence({
65
+ showToast: () => {},
66
+ readOptionalProjectFile: async () => "",
67
+ writeProjectFile: async () => {},
68
+ recordEdit: async () => {},
69
+ previewIframeRef: { current: iframe },
70
+ activeCompPathRef: { current: "index.html" },
71
+ reloadPreview: reloads,
72
+ });
73
+ actions = useEditHistoryActions({
74
+ editHistory: history,
75
+ readOptionalProjectFile: async () => "",
76
+ readProjectFile: readFile,
77
+ writeProjectFile: async (p, content) => writeFileSync(join(dir, p), content),
78
+ showToast: () => {},
79
+ syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
80
+ showHistoryRestoreNow: persistence.showHistoryRestoreNow,
81
+ waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves,
82
+ });
83
+ return null;
84
+ }
85
+ const root = createRoot(document.createElement("div"));
86
+ await act(async () => root.render(createElement(Harness)));
87
+ cleanup.push(() => act(() => root.unmount()));
88
+ const box = () => iframe.contentDocument!.getElementById("box")!.style.left;
89
+ const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
90
+ const show = (html: string) => {
91
+ iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
92
+ html,
93
+ "text/html",
94
+ ).documentElement.innerHTML;
95
+ };
96
+ /** A move Studio saved: the live preview already shows it, then the file and the history claim land. */
97
+ const edit = async () => {
98
+ show(AFTER);
99
+ writeFileSync(path, AFTER);
100
+ await act(() =>
101
+ history.recordEdit({
102
+ label: "Move layer",
103
+ files: { "index.html": { before: BEFORE, after: AFTER } },
104
+ }),
105
+ );
106
+ await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
107
+ };
108
+ return {
109
+ history: () => history,
110
+ persistence: () => persistence,
111
+ actions: () => actions,
112
+ box,
113
+ other,
114
+ show,
115
+ path,
116
+ edit,
117
+ file: () => readFileSync(path, "utf8"),
118
+ reloads,
119
+ };
120
+ }
121
+
122
+ it("undo and redo of a style edit paint in the key's own task, before the server answers", async () => {
123
+ const s = await studio();
124
+ await s.edit();
125
+
126
+ const undone = s.actions().undo();
127
+ expect(s.box()).toBe("10px");
128
+ await act(() => undone);
129
+ expect(s.file()).toBe(BEFORE);
130
+ expect(s.box()).toBe("10px");
131
+
132
+ await vi.waitFor(() => expect(s.history().redoLabel).toBeTruthy());
133
+ const redone = s.actions().redo();
134
+ expect(s.box()).toBe("50px");
135
+ await act(() => redone);
136
+ expect(s.file()).toBe(AFTER);
137
+ expect(s.box()).toBe("50px");
138
+ expect(s.reloads).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("an undo pressed while a save is still running waits for the server instead of guessing", async () => {
142
+ const s = await studio();
143
+ await s.edit();
144
+ let finish!: () => void;
145
+ const saving = new Promise<void>((resolve) => (finish = resolve));
146
+ void s.persistence().queueDomEditSave(() => saving);
147
+
148
+ const undone = s.actions().undo();
149
+ expect(s.box()).toBe("50px");
150
+ finish();
151
+ await act(() => undone);
152
+ expect(s.file()).toBe(BEFORE);
153
+ expect(s.box()).toBe("10px");
154
+ });
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
+ });
@@ -11,24 +11,36 @@ import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryA
11
11
  let root: Root | null = null;
12
12
  afterEach(() => act(() => root?.unmount()));
13
13
 
14
- function mount(result: {
15
- ok: boolean;
16
- reason?: string;
17
- message?: string;
18
- label?: string;
19
- paths?: string[];
20
- }) {
14
+ type RestoreFiles = Record<string, { previous: string; restored: string }>;
15
+ type Prediction = { id: string; files: RestoreFiles };
16
+
17
+ function mount(
18
+ result: {
19
+ ok: boolean;
20
+ reason?: string;
21
+ message?: string;
22
+ label?: string;
23
+ paths?: string[];
24
+ undoes?: string;
25
+ files?: RestoreFiles;
26
+ },
27
+ predicted?: Prediction,
28
+ ) {
21
29
  const editHistory = {
22
30
  undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
23
31
  redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
32
+ predict: () => predicted ?? null,
24
33
  };
34
+ const putBack = vi.fn();
25
35
  const deps = {
26
36
  editHistory,
27
37
  readOptionalProjectFile: vi.fn(async () => ""),
28
38
  readProjectFile: vi.fn(async () => ""),
29
39
  writeProjectFile: vi.fn(async () => undefined),
30
40
  showToast: vi.fn(),
31
- syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
41
+ syncHistoryPreviewAfterApply: vi.fn(async (_restore: unknown) => undefined),
42
+ putBack,
43
+ showHistoryRestoreNow: vi.fn((_files: RestoreFiles) => putBack),
32
44
  waitForPendingDomEditSaves: vi.fn(async () => undefined),
33
45
  onAfterUndoRedo: vi.fn(),
34
46
  activeCompPath: "index.html",
@@ -44,7 +56,53 @@ function mount(result: {
44
56
  return { deps, actions };
45
57
  }
46
58
 
59
+ const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored: "A" } } };
60
+ const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
61
+
47
62
  describe("useEditHistoryActions", () => {
63
+ it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
64
+ const { deps, actions } = mount(
65
+ { ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
66
+ PREDICTED,
67
+ );
68
+ await act(() => actions.undo());
69
+ expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
70
+ expect(deps.putBack).not.toHaveBeenCalled();
71
+ expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
72
+ paths: ["index.html"],
73
+ files: { "index.html": { previous: "A", restored: "A2" } },
74
+ });
75
+ });
76
+
77
+ it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
78
+ const { deps, actions } = mount(
79
+ {
80
+ ok: true,
81
+ label: "Undid: Outside",
82
+ paths: ["index.html"],
83
+ undoes: "e0",
84
+ files: SERVER_FILES,
85
+ },
86
+ PREDICTED,
87
+ );
88
+ await act(() => actions.undo());
89
+ expect(deps.putBack).toHaveBeenCalledTimes(1);
90
+ expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalledWith({
91
+ paths: ["index.html"],
92
+ files: SERVER_FILES,
93
+ });
94
+ });
95
+
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
+
48
106
  it("undo resyncs the preview and toasts the step as the history names it", async () => {
49
107
  const { deps, actions } = mount({ ok: true, label: "Undid: Move clip", paths: ["index.html"] });
50
108
  await act(() => actions.undo());
@@ -80,7 +138,7 @@ describe("useEditHistoryActions", () => {
80
138
  expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
81
139
  });
82
140
 
83
- it("says why when the history could not take the step", async () => {
141
+ it("says why when the history could not take the step, with nothing shown to take back", async () => {
84
142
  const { deps, actions } = mount({ ok: false, reason: "failed", message: "disk full" });
85
143
  await act(() => actions.undo());
86
144
  expect(deps.showToast).toHaveBeenCalledWith("Undo failed: disk full", "error");
@@ -2,6 +2,7 @@
2
2
  import { useCallback, useMemo } from "react";
3
3
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
4
4
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
5
+ import type { RestoreFiles } from "../utils/gsapUndoRestore";
5
6
 
6
7
  interface HistoryResult {
7
8
  ok: boolean;
@@ -9,8 +10,9 @@ interface HistoryResult {
9
10
  message?: string;
10
11
  label?: string;
11
12
  paths?: string[];
13
+ undoes?: string;
12
14
  /** Per-file restored/previous content, used to soft-apply the preview. */
13
- files?: Record<string, { previous: string; restored: string }>;
15
+ files?: RestoreFiles;
14
16
  }
15
17
  interface HistoryFileCallbacks {
16
18
  readFile: (path: string) => Promise<string>;
@@ -19,6 +21,7 @@ interface HistoryFileCallbacks {
19
21
  export interface EditHistoryHandle {
20
22
  undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
21
23
  redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
24
+ predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
22
25
  state: {
23
26
  undo: ReadonlyArray<{ createdAt: number }>;
24
27
  redo: ReadonlyArray<{ createdAt: number }>;
@@ -26,12 +29,13 @@ export interface EditHistoryHandle {
26
29
  }
27
30
 
28
31
  export interface UseEditHistoryActionsOptions {
29
- editHistory: Pick<EditHistoryHandle, "undo" | "redo">;
32
+ editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict">;
30
33
  readOptionalProjectFile: (path: string) => Promise<string>;
31
34
  readProjectFile: (path: string) => Promise<string>;
32
35
  writeProjectFile: (path: string, content: string) => Promise<void>;
33
36
  showToast: (message: string, tone?: "error" | "info") => void;
34
37
  syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
38
+ showHistoryRestoreNow?: (files: RestoreFiles) => (() => void) | null;
35
39
  waitForPendingDomEditSaves: () => Promise<void>;
36
40
  onAfterUndoRedo?: (restore: Pick<HistoryResult, "paths" | "files">) => void;
37
41
  /** Active composition path — decides whether undo/redo must resync the SDK session. */
@@ -48,6 +52,7 @@ export function useEditHistoryActions({
48
52
  writeProjectFile,
49
53
  showToast,
50
54
  syncHistoryPreviewAfterApply,
55
+ showHistoryRestoreNow,
51
56
  waitForPendingDomEditSaves,
52
57
  onAfterUndoRedo,
53
58
  activeCompPath,
@@ -67,11 +72,22 @@ export function useEditHistoryActions({
67
72
  const apply = useCallback(
68
73
  async (direction: "undo" | "redo") => {
69
74
  const noun = direction === "undo" ? "Undo" : "Redo";
70
- await waitForPendingDomEditSaves();
71
- const result = await editHistory[direction]({
72
- readFile: readHistoryFile,
73
- serialize: serializeHistoryFiles,
74
- });
75
+ // Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
76
+ const predicted = editHistory.predict?.(direction) ?? null;
77
+ const putBack = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
78
+ let result: HistoryResult = { ok: false, reason: "failed" };
79
+ let serverSteppedShown = false;
80
+ try {
81
+ await waitForPendingDomEditSaves();
82
+ result = await editHistory[direction]({
83
+ readFile: readHistoryFile,
84
+ serialize: serializeHistoryFiles,
85
+ });
86
+ serverSteppedShown =
87
+ Boolean(putBack && result.ok && result.label) && result.undoes === predicted?.id;
88
+ } finally {
89
+ if (putBack && !serverSteppedShown) putBack();
90
+ }
75
91
  if (!result.ok && result.reason === "content-mismatch") {
76
92
  showToast(
77
93
  `Can't ${direction}: ${result.paths?.join(", ")} changed since that edit.`,
@@ -84,7 +100,8 @@ export function useEditHistoryActions({
84
100
  return;
85
101
  }
86
102
  if (result.ok && result.label) {
87
- const restore = { paths: result.paths, files: result.files };
103
+ const files = serverSteppedShown ? fromShown(result.files, predicted!.files) : result.files;
104
+ const restore = { paths: result.paths, files };
88
105
  onAfterUndoRedo?.(restore);
89
106
  if (activeCompPath && result.paths?.includes(activeCompPath)) {
90
107
  forceReloadSdkSession?.();
@@ -98,6 +115,7 @@ export function useEditHistoryActions({
98
115
  readHistoryFile,
99
116
  showToast,
100
117
  syncHistoryPreviewAfterApply,
118
+ showHistoryRestoreNow,
101
119
  waitForPendingDomEditSaves,
102
120
  serializeHistoryFiles,
103
121
  onAfterUndoRedo,
@@ -110,3 +128,13 @@ export function useEditHistoryActions({
110
128
  const redo = useCallback(() => apply("redo"), [apply]);
111
129
  return useMemo(() => ({ undo, redo }), [undo, redo]);
112
130
  }
131
+
132
+ function fromShown(files: RestoreFiles | undefined, shown: RestoreFiles) {
133
+ if (!files) return files;
134
+ return Object.fromEntries(
135
+ Object.entries(files).map(([path, f]) => [
136
+ path,
137
+ { previous: shown[path]?.restored ?? f.previous, restored: f.restored },
138
+ ]),
139
+ );
140
+ }
@@ -76,6 +76,7 @@ it("an edit Studio saved is undone and redone by the project's history, with the
76
76
  expect(undone).toEqual({
77
77
  ok: true,
78
78
  label: "Undid: Moved Title",
79
+ undoes: expect.any(String),
79
80
  paths: ["index.html"],
80
81
  files: { "index.html": { previous: "B", restored: "A" } },
81
82
  });
@@ -241,3 +242,48 @@ it("a step that cannot reach the server says so", async () => {
241
242
  message: "Studio could not reach its server.",
242
243
  });
243
244
  });
245
+
246
+ it("predicts a step from what this tab wrote, and not while a claim or step may have moved the history", async () => {
247
+ const { hook, save, readFile } = await studio();
248
+ save("B");
249
+ const claim = hook().recordEdit({
250
+ label: "Moved Title",
251
+ files: { "index.html": { before: "A", after: "B" } },
252
+ });
253
+ expect(hook().predict("undo")).toBeNull();
254
+ await act(() => claim);
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));
264
+
265
+ const undone = hook().undo({ readFile });
266
+ expect(hook().predict("undo")).toBeNull();
267
+ expect((await act(() => undone)).undoes).toBe(predicted.id);
268
+ await vi.waitFor(() =>
269
+ expect(hook().predict("redo")?.files).toEqual({
270
+ "index.html": { previous: "A", restored: "B" },
271
+ }),
272
+ );
273
+ });
274
+
275
+ it("an undo taken before the view caught up with the edit still reports the preview's before and after", async () => {
276
+ const { hook, save, readFile } = await studio();
277
+ save("B");
278
+ const record = hook().recordEdit({
279
+ label: "Moved Title",
280
+ files: { "index.html": { before: "A", after: "B" } },
281
+ });
282
+ const undo = hook().undo;
283
+ await act(() => record);
284
+ const undone = await act(() => undo({ readFile }));
285
+ expect(undone).toMatchObject({
286
+ ok: true,
287
+ files: { "index.html": { previous: "B", restored: "A" } },
288
+ });
289
+ });