@hyperframes/studio 0.7.59 → 0.7.60

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 (45) hide show
  1. package/dist/assets/{hyperframes-player-Bm07FLkl.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-B995FG46.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-FvzmPhfG.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/{index-CrkAdJkb.js → index-cH6NfVV_.js} +198 -198
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +250 -108
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +1 -1
  11. package/src/components/StudioRightPanel.tsx +13 -2
  12. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
  13. package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
  14. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  15. package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
  16. package/src/components/editor/propertyPanelFlatTextSection.tsx +1 -1
  17. package/src/contexts/FileManagerContext.tsx +3 -0
  18. package/src/contexts/PanelLayoutContext.tsx +3 -0
  19. package/src/hooks/timelineEditingHelpers.ts +2 -2
  20. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  21. package/src/hooks/useDomEditCommits.ts +2 -2
  22. package/src/hooks/useDomEditSession.ts +1 -1
  23. package/src/hooks/useEditorSave.ts +1 -1
  24. package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
  25. package/src/hooks/useFileManager.ts +76 -12
  26. package/src/hooks/usePanelLayout.test.ts +118 -0
  27. package/src/hooks/usePanelLayout.ts +24 -1
  28. package/src/hooks/usePreviewPersistence.ts +4 -1
  29. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  30. package/src/hooks/useRazorSplit.test.tsx +88 -0
  31. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  32. package/src/hooks/useRazorSplit.ts +50 -16
  33. package/src/hooks/useTimelineEditing.ts +2 -0
  34. package/src/hooks/useTimelineEditingTypes.ts +2 -1
  35. package/src/hooks/useTimelineGroupEditing.ts +1 -1
  36. package/src/utils/domEditSaveQueue.test.ts +19 -0
  37. package/src/utils/domEditSaveQueue.ts +2 -1
  38. package/src/utils/sdkCutover.test.ts +21 -5
  39. package/src/utils/sdkEditTransaction.ts +5 -4
  40. package/src/utils/studioFileHistory.ts +6 -4
  41. package/src/utils/studioFileVersion.test.ts +36 -0
  42. package/src/utils/studioFileVersion.ts +23 -0
  43. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  44. package/src/utils/studioSaveDiagnostics.ts +21 -0
  45. package/dist/assets/index-Dj5p8U_A.css +0 -1
@@ -0,0 +1,118 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { usePanelLayout } from "./usePanelLayout";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ vi.doUnmock("../components/editor/manualEditingAvailability");
13
+ vi.resetModules();
14
+ });
15
+
16
+ function renderPanelLayoutWith(hook: typeof usePanelLayout) {
17
+ const host = document.createElement("div");
18
+ document.body.append(host);
19
+ const root = createRoot(host);
20
+ let current: ReturnType<typeof usePanelLayout> | null = null;
21
+
22
+ function Harness() {
23
+ current = hook();
24
+ return null;
25
+ }
26
+
27
+ act(() => {
28
+ root.render(React.createElement(Harness));
29
+ });
30
+
31
+ return {
32
+ getState: (): ReturnType<typeof usePanelLayout> => {
33
+ if (!current) throw new Error("usePanelLayout did not render");
34
+ return current;
35
+ },
36
+ unmount: () => act(() => root.unmount()),
37
+ };
38
+ }
39
+
40
+ function renderPanelLayout() {
41
+ return renderPanelLayoutWith(usePanelLayout);
42
+ }
43
+
44
+ describe("usePanelLayout — right inspector panes", () => {
45
+ it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => {
46
+ const harness = renderPanelLayout();
47
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
48
+
49
+ act(() => harness.getState().toggleRightInspectorPane("layers"));
50
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
51
+
52
+ harness.unmount();
53
+ });
54
+
55
+ it("toggleRightInspectorPane refuses to turn off the last remaining pane", () => {
56
+ const harness = renderPanelLayout();
57
+ act(() => harness.getState().toggleRightInspectorPane("design"));
58
+ // Only "design" was on; toggling it off would leave both false — guarded.
59
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
60
+ harness.unmount();
61
+ });
62
+
63
+ it("setExclusiveRightInspectorPane is radio-style — selecting one turns the other off", () => {
64
+ const harness = renderPanelLayout();
65
+ act(() => harness.getState().toggleRightInspectorPane("layers"));
66
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
67
+
68
+ act(() => harness.getState().setExclusiveRightInspectorPane("layers"));
69
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false });
70
+
71
+ act(() => harness.getState().setExclusiveRightInspectorPane("design"));
72
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
73
+
74
+ harness.unmount();
75
+ });
76
+
77
+ it("setRightPanelTab additively opens a pane when the flat inspector is off (legacy behavior)", async () => {
78
+ vi.resetModules();
79
+ vi.doMock("../components/editor/manualEditingAvailability", async () => {
80
+ const actual = await vi.importActual<
81
+ typeof import("../components/editor/manualEditingAvailability")
82
+ >("../components/editor/manualEditingAvailability");
83
+ return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
84
+ });
85
+ const { usePanelLayout: usePanelLayoutFlatOff } = await import("./usePanelLayout");
86
+ const harness = renderPanelLayoutWith(usePanelLayoutFlatOff);
87
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
88
+
89
+ act(() => harness.getState().setRightPanelTab("layers"));
90
+ // Legacy (split-view) behavior: additive, both panes end up open.
91
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: true });
92
+
93
+ harness.unmount();
94
+ });
95
+
96
+ it("setRightPanelTab is flat-aware: exclusivity holds for callers other than a direct in-panel tab click", async () => {
97
+ vi.resetModules();
98
+ vi.doMock("../components/editor/manualEditingAvailability", async () => {
99
+ const actual = await vi.importActual<
100
+ typeof import("../components/editor/manualEditingAvailability")
101
+ >("../components/editor/manualEditingAvailability");
102
+ return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
103
+ });
104
+ const { usePanelLayout: usePanelLayoutFlatOn } = await import("./usePanelLayout");
105
+ const harness = renderPanelLayoutWith(usePanelLayoutFlatOn);
106
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
107
+
108
+ // Element-select / block-params-close / header Inspector-button callers
109
+ // all reach setRightPanelTab directly, not through the in-panel tab
110
+ // click's own setExclusiveRightInspectorPane call — this must still
111
+ // enforce exclusivity under the flat flag, or both tabs end up
112
+ // highlighted while only one pane actually renders.
113
+ act(() => harness.getState().setRightPanelTab("layers"));
114
+ expect(harness.getState().rightInspectorPanes).toEqual({ layers: true, design: false });
115
+
116
+ harness.unmount();
117
+ });
118
+ });
@@ -6,6 +6,7 @@ import type {
6
6
  } from "../utils/studioHelpers";
7
7
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
8
8
  import { trackStudioEvent } from "../utils/studioTelemetry";
9
+ import { STUDIO_FLAT_INSPECTOR_ENABLED } from "../components/editor/manualEditingAvailability";
9
10
 
10
11
  export interface InitialPanelLayoutState {
11
12
  rightCollapsed?: boolean | null;
@@ -80,7 +81,20 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
80
81
  const trackedSetRightPanelTab = useCallback(
81
82
  (tab: RightPanelTab) => {
82
83
  if (tab === "design" || tab === "layers") {
83
- setRightInspectorPanes((panes) => ({ ...panes, [tab]: true }));
84
+ // Flat inspector: Layers always renders full-height by itself (see
85
+ // StudioRightPanel's render gate), so this MUST land on the same
86
+ // radio-style exclusivity setExclusiveRightInspectorPane enforces for
87
+ // the direct in-panel tab click — every OTHER path that reaches here
88
+ // (element select, closing block-params, the header Inspector
89
+ // button, and this function's own callers outside an active
90
+ // inspector tab) would otherwise additively leave both panes `true`
91
+ // and reproduce the "both tabs highlight, only one renders" bug this
92
+ // still-additive branch used to cause under the flat flag.
93
+ setRightInspectorPanes(
94
+ STUDIO_FLAT_INSPECTOR_ENABLED
95
+ ? { design: tab === "design", layers: tab === "layers" }
96
+ : (panes) => ({ ...panes, [tab]: true }),
97
+ );
84
98
  }
85
99
  setRightPanelTab(tab);
86
100
  trackStudioEvent("tab_switch", { panel: "right_panel", tab });
@@ -96,6 +110,14 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
96
110
  });
97
111
  }, []);
98
112
 
113
+ // Radio-style variant for the flat inspector: Layers always renders full-
114
+ // height by itself there (never split-shared with Design), so leaving both
115
+ // panes independently toggleable would highlight both tabs as "active"
116
+ // while only one actually shows. Selecting one turns the other off.
117
+ const setExclusiveRightInspectorPane = useCallback((pane: RightInspectorPane) => {
118
+ setRightInspectorPanes({ design: pane === "design", layers: pane === "layers" });
119
+ }, []);
120
+
99
121
  return {
100
122
  leftWidth,
101
123
  setLeftWidth,
@@ -109,6 +131,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
109
131
  setRightPanelTab: trackedSetRightPanelTab,
110
132
  rightInspectorPanes,
111
133
  toggleRightInspectorPane,
134
+ setExclusiveRightInspectorPane,
112
135
  toggleLeftSidebar,
113
136
  handlePanelResizeStart,
114
137
  handlePanelResizeMove,
@@ -133,7 +133,10 @@ export function usePreviewPersistence({
133
133
  if (!domEditSaveQueueRef.current) {
134
134
  domEditSaveQueueRef.current = createDomEditSaveQueue({
135
135
  onOpen: (event) => {
136
- const message = "Auto-save is paused. Check your connection.";
136
+ const message =
137
+ event.statusCode === 409
138
+ ? "Save paused: this file changed elsewhere. Reload and review the latest version before reapplying your edit."
139
+ : "Auto-save is paused. Check your connection.";
137
140
  setDomEditSaveQueuePaused(message);
138
141
  showToastRef.current(message, "error");
139
142
  trackStudioEvent("save_queue_paused", {
@@ -76,9 +76,10 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
76
76
  // Mirror the server: rewrites the GSAP script for the new id, writes to
77
77
  // disk, returns the final content.
78
78
  disk["index.html"] = SPLIT_GSAP;
79
- return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
79
+ const version = `"test-gsap-${SPLIT_GSAP.length}"`;
80
+ return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP, version }), {
80
81
  status: 200,
81
- headers: { "Content-Type": "application/json" },
82
+ headers: { "Content-Type": "application/json", ETag: version },
82
83
  });
83
84
  }
84
85
  // The fixture has no GSAP script — mirror the server's 400 response.
@@ -89,9 +90,16 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
89
90
  }
90
91
  if (u.includes("/file-mutations/split-element/")) {
91
92
  disk["index.html"] = SPLIT;
93
+ const version = `"test-split-${SPLIT.length}"`;
92
94
  return new Response(
93
- JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
94
- { status: 200, headers: { "Content-Type": "application/json" } },
95
+ JSON.stringify({
96
+ ok: true,
97
+ changed: true,
98
+ content: SPLIT,
99
+ newId: "clip1-split",
100
+ version,
101
+ }),
102
+ { status: 200, headers: { "Content-Type": "application/json", ETag: version } },
95
103
  );
96
104
  }
97
105
  if (u.includes("/files/")) {
@@ -0,0 +1,88 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../player";
6
+ import { useRazorSplit } from "./useRazorSplit";
7
+
8
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ function jsonResponse(body: unknown, status = 200): Response {
11
+ return new Response(JSON.stringify(body), {
12
+ status,
13
+ headers: { "Content-Type": "application/json" },
14
+ });
15
+ }
16
+
17
+ describe("useRazorSplit mutation versions", () => {
18
+ afterEach(() => vi.restoreAllMocks());
19
+
20
+ it("observes each out-of-band mutation version before the OCC writer runs", async () => {
21
+ const original = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
22
+ const htmlSplit =
23
+ '<div id="clip" data-start="0" data-duration="2">Clip</div><div id="clip-split" data-start="2" data-duration="2">Clip</div>';
24
+ const final = `${htmlSplit}<script>window.__timelines = {}</script>`;
25
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
26
+ const url = String(input);
27
+ if (url.includes("/files/")) return jsonResponse({ content: original });
28
+ if (url.includes("/file-mutations/split-element/")) {
29
+ return jsonResponse({ ok: true, changed: true, content: htmlSplit, version: '"v-html"' });
30
+ }
31
+ if (url.includes("/gsap-mutations/")) {
32
+ return jsonResponse({ ok: true, changed: true, after: final, version: '"v-gsap"' });
33
+ }
34
+ throw new Error(`Unexpected request: ${url}`);
35
+ });
36
+
37
+ const order: string[] = [];
38
+ const observeProjectFileVersion = vi.fn((path: string, version: string | null) => {
39
+ order.push(`observe:${path}:${version}`);
40
+ });
41
+ const writeProjectFile = vi.fn(async () => {
42
+ order.push("write");
43
+ });
44
+ const recordEdit = vi.fn().mockResolvedValue(undefined);
45
+ let split: ((element: TimelineElement, splitTime: number) => Promise<void>) | undefined;
46
+ const container = document.createElement("div");
47
+ document.body.appendChild(container);
48
+ const root = createRoot(container);
49
+
50
+ function Harness() {
51
+ split = useRazorSplit({
52
+ projectId: "p1",
53
+ activeCompPath: "index.html",
54
+ showToast: vi.fn(),
55
+ writeProjectFile,
56
+ observeProjectFileVersion,
57
+ recordEdit,
58
+ domEditSaveTimestampRef: { current: 0 },
59
+ reloadPreview: vi.fn(),
60
+ }).handleRazorSplit;
61
+ return null;
62
+ }
63
+
64
+ await act(async () => root.render(React.createElement(Harness)));
65
+ await act(async () => {
66
+ await split?.(
67
+ {
68
+ id: "clip",
69
+ domId: "clip",
70
+ hfId: "clip",
71
+ tag: "div",
72
+ start: 0,
73
+ duration: 4,
74
+ track: 0,
75
+ timingSource: "authored",
76
+ },
77
+ 2,
78
+ );
79
+ });
80
+
81
+ expect(order).toEqual(['observe:index.html:"v-html"', 'observe:index.html:"v-gsap"', "write"]);
82
+ expect(writeProjectFile).toHaveBeenCalledWith("index.html", final, original);
83
+ expect(recordEdit).toHaveBeenCalledTimes(1);
84
+
85
+ act(() => root.unmount());
86
+ container.remove();
87
+ });
88
+ });
@@ -36,10 +36,11 @@ export function createSplitFetchMock(
36
36
  onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
37
37
  // Return content that differs from the original so `changed` is true.
38
38
  const after = `${disk[path]}<!--split-->`;
39
+ const version = `"test-${path}-${after.length}"`;
39
40
  disk[path] = after; // server writes the split to disk
40
- return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
41
+ return new Response(JSON.stringify({ ok: true, changed: true, content: after, version }), {
41
42
  status: 200,
42
- headers: { "Content-Type": "application/json" },
43
+ headers: { "Content-Type": "application/json", ETag: version },
43
44
  });
44
45
  }
45
46
  if (u.includes("/files/")) {
@@ -16,7 +16,8 @@ interface UseRazorSplitOptions {
16
16
  projectId: string | null;
17
17
  activeCompPath: string | null;
18
18
  showToast: (message: string, tone?: "error" | "info") => void;
19
- writeProjectFile: (path: string, content: string) => Promise<void>;
19
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
20
+ observeProjectFileVersion?: (path: string, version: string | null) => void;
20
21
  recordEdit: (input: RecordEditInput) => Promise<void>;
21
22
  domEditSaveTimestampRef: React.MutableRefObject<number>;
22
23
  reloadPreview: () => void;
@@ -48,7 +49,7 @@ async function splitHtmlElement(
48
49
  newId: string,
49
50
  elementStart: number,
50
51
  elementDuration: number,
51
- ): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
52
+ ): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
52
53
  const response = await fetch(
53
54
  `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
54
55
  {
@@ -64,9 +65,18 @@ async function splitHtmlElement(
64
65
  },
65
66
  );
66
67
  if (!response.ok) throw new Error("Split request failed");
67
- return (await response.json()) as { ok: boolean; changed?: boolean; content?: string };
68
+ const data = (await response.json()) as {
69
+ ok: boolean;
70
+ changed?: boolean;
71
+ content?: string;
72
+ version?: string;
73
+ };
74
+ const version = data.version ?? response.headers.get("etag");
75
+ if (!version) throw new Error("Split response did not include a content version");
76
+ return { ...data, version };
68
77
  }
69
78
 
79
+ // fallow-ignore-next-line complexity
70
80
  async function splitGsapAnimations(
71
81
  projectId: string,
72
82
  targetPath: string,
@@ -75,7 +85,7 @@ async function splitGsapAnimations(
75
85
  splitTime: number,
76
86
  elementStart: number,
77
87
  elementDuration: number,
78
- ): Promise<{ content: string | null; skippedSelectors?: string[] }> {
88
+ ): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
79
89
  const response = await fetch(
80
90
  `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
81
91
  {
@@ -101,10 +111,12 @@ async function splitGsapAnimations(
101
111
  const data = (await response.json()) as {
102
112
  ok?: boolean;
103
113
  after?: string;
114
+ version?: string;
104
115
  skippedSelectors?: string[];
105
116
  };
106
117
  return {
107
118
  content: data.ok && data.after ? data.after : null,
119
+ version: data.version ?? response.headers.get("etag") ?? undefined,
108
120
  skippedSelectors: data.skippedSelectors,
109
121
  };
110
122
  }
@@ -119,11 +131,11 @@ function getOriginalContent(originals: ReadonlyMap<string, string>, path: string
119
131
 
120
132
  async function restoreFilesToOriginal(
121
133
  originals: ReadonlyMap<string, string>,
122
- paths: Iterable<string>,
123
- writeProjectFile: (path: string, content: string) => Promise<void>,
134
+ snapshots: ReadonlyMap<string, { before: string; after: string }>,
135
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
124
136
  ): Promise<void> {
125
- for (const path of paths) {
126
- await writeProjectFile(path, getOriginalContent(originals, path));
137
+ for (const [path, snapshot] of snapshots) {
138
+ await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
127
139
  }
128
140
  }
129
141
 
@@ -149,17 +161,25 @@ async function splitElementsAtTime(
149
161
  activeCompPath: string | null,
150
162
  originals: ReadonlyMap<string, string>,
151
163
  snapshots: Map<string, { before: string; after: string }>,
152
- writeProjectFile: (path: string, content: string) => Promise<void>,
164
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
165
+ observeProjectFileVersion?: (path: string, version: string | null) => void,
153
166
  ): Promise<number> {
154
167
  let count = 0;
155
168
  for (const element of elements) {
156
- const result = await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
169
+ const result = await executeSplit(
170
+ pid,
171
+ element,
172
+ splitTime,
173
+ activeCompPath,
174
+ writeProjectFile,
175
+ observeProjectFileVersion,
176
+ );
157
177
  if (!result.changed) continue;
158
178
  snapshots.set(result.targetPath, {
159
179
  before: getOriginalContent(originals, result.targetPath),
160
180
  after: result.patchedContent,
161
181
  });
162
- await writeProjectFile(result.targetPath, result.patchedContent);
182
+ await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
163
183
  count++;
164
184
  }
165
185
  return count;
@@ -171,7 +191,8 @@ async function executeSplit(
171
191
  element: TimelineElement,
172
192
  splitTime: number,
173
193
  activeCompPath: string | null,
174
- writeProjectFile: (path: string, content: string) => Promise<void>,
194
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
195
+ observeProjectFileVersion?: (path: string, version: string | null) => void,
175
196
  ): Promise<{
176
197
  targetPath: string;
177
198
  originalContent: string;
@@ -209,6 +230,7 @@ async function executeSplit(
209
230
  if (!splitResult.changed) {
210
231
  return { targetPath, originalContent, patchedContent: originalContent, changed: false };
211
232
  }
233
+ observeProjectFileVersion?.(targetPath, splitResult.version);
212
234
 
213
235
  let patchedContent =
214
236
  typeof splitResult.content === "string" ? splitResult.content : originalContent;
@@ -226,11 +248,12 @@ async function executeSplit(
226
248
  element.duration,
227
249
  );
228
250
  if (gsapResult.content) patchedContent = gsapResult.content;
251
+ if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
229
252
  if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
230
253
  } catch (gsapError) {
231
254
  // GSAP mutation failed — the HTML split already wrote to disk.
232
255
  // Restore the original content to avoid a corrupt half-split state.
233
- await writeProjectFile(targetPath, originalContent);
256
+ await writeProjectFile(targetPath, originalContent, patchedContent);
234
257
  throw gsapError;
235
258
  }
236
259
  }
@@ -243,6 +266,7 @@ export function useRazorSplit({
243
266
  activeCompPath,
244
267
  showToast,
245
268
  writeProjectFile,
269
+ observeProjectFileVersion,
246
270
  recordEdit,
247
271
  domEditSaveTimestampRef,
248
272
  reloadPreview,
@@ -265,7 +289,14 @@ export function useRazorSplit({
265
289
 
266
290
  try {
267
291
  const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
268
- await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
292
+ await executeSplit(
293
+ pid,
294
+ element,
295
+ splitTime,
296
+ activeCompPath,
297
+ writeProjectFile,
298
+ observeProjectFileVersion,
299
+ );
269
300
 
270
301
  if (!changed) {
271
302
  showToast("Failed to split clip — playhead may be outside the clip", "error");
@@ -305,6 +336,7 @@ export function useRazorSplit({
305
336
  recordEdit,
306
337
  showToast,
307
338
  writeProjectFile,
339
+ observeProjectFileVersion,
308
340
  domEditSaveTimestampRef,
309
341
  reloadPreview,
310
342
  forceReloadSdkSession,
@@ -312,8 +344,8 @@ export function useRazorSplit({
312
344
  ],
313
345
  );
314
346
 
315
- // fallow-ignore-next-line complexity
316
347
  const handleRazorSplitAll = useCallback(
348
+ // fallow-ignore-next-line complexity
317
349
  async (splitTime: number) => {
318
350
  if (isRecordingRef?.current) {
319
351
  showToast("Cannot edit timeline while recording", "error");
@@ -338,6 +370,7 @@ export function useRazorSplit({
338
370
  originals,
339
371
  finalSnapshots,
340
372
  writeProjectFile,
373
+ observeProjectFileVersion,
341
374
  );
342
375
  if (splitCount === 0) return;
343
376
 
@@ -358,7 +391,7 @@ export function useRazorSplit({
358
391
  // Best-effort rollback — a failing restore write must not swallow the
359
392
  // original error's toast, which is what tells the user the split failed.
360
393
  try {
361
- await restoreFilesToOriginal(originals, finalSnapshots.keys(), writeProjectFile);
394
+ await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
362
395
  } catch {
363
396
  /* leave disk as-is; the original failure is reported below */
364
397
  }
@@ -371,6 +404,7 @@ export function useRazorSplit({
371
404
  recordEdit,
372
405
  showToast,
373
406
  writeProjectFile,
407
+ observeProjectFileVersion,
374
408
  domEditSaveTimestampRef,
375
409
  reloadPreview,
376
410
  forceReloadSdkSession,
@@ -46,6 +46,7 @@ export function useTimelineEditing({
46
46
  timelineElements,
47
47
  showToast,
48
48
  writeProjectFile,
49
+ observeProjectFileVersion,
49
50
  recordEdit,
50
51
  domEditSaveTimestampRef,
51
52
  reloadPreview,
@@ -503,6 +504,7 @@ export function useTimelineEditing({
503
504
  activeCompPath,
504
505
  showToast,
505
506
  writeProjectFile,
507
+ observeProjectFileVersion,
506
508
  recordEdit,
507
509
  domEditSaveTimestampRef,
508
510
  reloadPreview,
@@ -31,7 +31,8 @@ export interface UseTimelineEditingOptions {
31
31
  activeCompPath: string | null;
32
32
  timelineElements: TimelineElement[];
33
33
  showToast: (message: string, tone?: "error" | "info") => void;
34
- writeProjectFile: (path: string, content: string) => Promise<void>;
34
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
35
+ observeProjectFileVersion?: (path: string, version: string | null) => void;
35
36
  recordEdit: (input: RecordEditInput) => Promise<void>;
36
37
  domEditSaveTimestampRef: MutableRefObject<number>;
37
38
  reloadPreview: () => void;
@@ -60,7 +60,7 @@ interface UseTimelineGroupEditingOptions {
60
60
  sdkSession?: Composition | null;
61
61
  publishSdkSession?: PublishSdkSession;
62
62
  showToast: (message: string, tone?: "error" | "info") => void;
63
- writeProjectFile: (path: string, content: string) => Promise<void>;
63
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
64
64
  }
65
65
 
66
66
  function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
@@ -114,4 +114,23 @@ describe("dom edit save queue", () => {
114
114
  expect(onOpen).not.toHaveBeenCalled();
115
115
  queue.destroy();
116
116
  });
117
+
118
+ it("pauses immediately on a file conflict instead of retrying stale work", async () => {
119
+ const onOpen = vi.fn();
120
+ const queue = createDomEditSaveQueue({ failureThreshold: 5, onOpen });
121
+
122
+ await expect(
123
+ queue.enqueue(async () => {
124
+ throw new StudioSaveHttpError("File changed elsewhere", 409);
125
+ }),
126
+ ).rejects.toThrow("File changed elsewhere");
127
+
128
+ expect(onOpen).toHaveBeenCalledWith({
129
+ consecutiveFailures: 1,
130
+ errorMessage: "File changed elsewhere",
131
+ statusCode: 409,
132
+ });
133
+ await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
134
+ queue.destroy();
135
+ });
117
136
  });
@@ -59,7 +59,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
59
59
  return result;
60
60
  } catch (error) {
61
61
  consecutiveFailures += 1;
62
- if (consecutiveFailures >= failureThreshold) open(error);
62
+ if (getStudioSaveStatusCode(error) === 409 || consecutiveFailures >= failureThreshold)
63
+ open(error);
63
64
  throw error;
64
65
  }
65
66
  };
@@ -223,7 +223,7 @@ describe("sdkCutoverPersist", () => {
223
223
  target: "hf-abc",
224
224
  styles: { color: "red", opacity: "0.5" },
225
225
  });
226
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>");
226
+ expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>", "before");
227
227
  expect(deps.reloadPreview).toHaveBeenCalled();
228
228
  });
229
229
 
@@ -852,7 +852,11 @@ describe("sdkDeletePersist", () => {
852
852
  const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
853
853
  expect(result.status).toBe("committed");
854
854
  expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
855
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
855
+ expect(deps.writeProjectFile).toHaveBeenCalledWith(
856
+ "/comp.html",
857
+ "<html>after</html>",
858
+ "before",
859
+ );
856
860
  });
857
861
 
858
862
  it("records edit history with before/after diff", async () => {
@@ -937,7 +941,11 @@ describe("sdkTimingPersist", () => {
937
941
  duration: 5,
938
942
  trackIndex: 1,
939
943
  });
940
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
944
+ expect(deps.writeProjectFile).toHaveBeenCalledWith(
945
+ "/comp.html",
946
+ "<html>after</html>",
947
+ "<html>before</html>",
948
+ );
941
949
  });
942
950
 
943
951
  it("captures before-state before setTiming dispatch", async () => {
@@ -1158,7 +1166,11 @@ describe("sdkGsapTweenPersist", () => {
1158
1166
  "hf-box",
1159
1167
  expect.objectContaining({ method: "to" }),
1160
1168
  );
1161
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
1169
+ expect(deps.writeProjectFile).toHaveBeenCalledWith(
1170
+ "/comp.html",
1171
+ "<html>after</html>",
1172
+ "<html>before</html>",
1173
+ );
1162
1174
  });
1163
1175
 
1164
1176
  it("returns false for kind=add when element not found", async () => {
@@ -1262,7 +1274,11 @@ describe("sdkGsapKeyframePersist", () => {
1262
1274
  position: 50,
1263
1275
  value: { opacity: 0.5 },
1264
1276
  });
1265
- expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
1277
+ expect(deps.writeProjectFile).toHaveBeenCalledWith(
1278
+ "/comp.html",
1279
+ "<html>after</html>",
1280
+ "<html>before</html>",
1281
+ );
1266
1282
  expect(deps.reloadPreview).toHaveBeenCalled();
1267
1283
  });
1268
1284