@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -0,0 +1,429 @@
1
+ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
2
+ import { readStudioFileChangePath } from "../components/editor/manualEdits";
3
+ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
4
+ import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
5
+ import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
6
+ import { consumeStudioWriteToken } from "../utils/studioFileVersion";
7
+
8
+ type ExternalChangeDrainResult =
9
+ | { status: "clean" }
10
+ | { status: "conflict"; error: StudioFileConflictError }
11
+ | { status: "failed"; error: unknown };
12
+
13
+ export type ExternalFileChangeBlockedState =
14
+ | {
15
+ status: "conflict";
16
+ generation: number;
17
+ error: StudioFileConflictError;
18
+ payload: unknown;
19
+ }
20
+ | {
21
+ status: "failed";
22
+ generation: number;
23
+ path: string;
24
+ error: unknown;
25
+ payload: unknown;
26
+ studioContent: string | null;
27
+ recovered: boolean;
28
+ };
29
+
30
+ interface ExternalFileChangeCoordinatorOptions {
31
+ projectId: string | null;
32
+ activeCompPath: string | null;
33
+ recoveryFilePath?: string | null;
34
+ pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
35
+ drainPendingChanges: () => Promise<ExternalChangeDrainResult>;
36
+ getPendingCandidate?: () => { path: string; content: string } | null;
37
+ discardPendingChanges: () => void;
38
+ reloadPreview: () => void;
39
+ reloadSdkSession: (path: string) => void;
40
+ persistConflictSnapshot: (projectId: string, conflict: StudioFileConflictError) => Promise<void>;
41
+ persistFailureSnapshot?: (
42
+ projectId: string,
43
+ filePath: string,
44
+ studioContent: string,
45
+ externalVersion: string | null,
46
+ externalContent: string | null,
47
+ error: unknown,
48
+ ) => Promise<void>;
49
+ loadConflictSnapshot?: (
50
+ projectId: string,
51
+ filePath: string,
52
+ ) => Promise<ExternalConflictSnapshot | null>;
53
+ deleteConflictSnapshot?: (projectId: string, filePath: string) => Promise<void>;
54
+ overwriteConflict: (conflict: StudioFileConflictError) => Promise<void>;
55
+ readProjectFile: (path: string) => Promise<string>;
56
+ onUseExternalFile?: (path: string, content: string) => void;
57
+ resetSaveQueues?: () => void;
58
+ }
59
+
60
+ export interface ExternalFileChangeCoordinatorHandle {
61
+ blocked: ExternalFileChangeBlockedState | null;
62
+ retry: () => Promise<void>;
63
+ useExternalFile: () => Promise<void>;
64
+ keepStudioFile: () => Promise<void>;
65
+ }
66
+
67
+ interface HotTestAdapter {
68
+ on(event: string, handler: (payload?: unknown) => void): void;
69
+ off(event: string, handler: (payload?: unknown) => void): void;
70
+ }
71
+
72
+ function testHotAdapter(): HotTestAdapter | null {
73
+ const value = (globalThis as { __HF_STUDIO_HOT_TEST_ADAPTER__?: unknown })
74
+ .__HF_STUDIO_HOT_TEST_ADAPTER__;
75
+ if (!value || typeof value !== "object") return null;
76
+ const candidate = value as Partial<HotTestAdapter>;
77
+ return typeof candidate.on === "function" && typeof candidate.off === "function"
78
+ ? (candidate as HotTestAdapter)
79
+ : null;
80
+ }
81
+
82
+ function readFileChangeContent(payload: unknown): string | null {
83
+ if (!payload || typeof payload !== "object") return null;
84
+ const record = payload as Record<string, unknown>;
85
+ if (typeof record.content === "string") return record.content;
86
+ return "data" in record ? readFileChangeContent(record.data) : null;
87
+ }
88
+
89
+ function readFileChangeVersion(payload: unknown): string | null {
90
+ if (!payload || typeof payload !== "object") return null;
91
+ const record = payload as Record<string, unknown>;
92
+ if (typeof record.version === "string") return record.version;
93
+ return "data" in record ? readFileChangeVersion(record.data) : null;
94
+ }
95
+
96
+ function readFileChangeWriteToken(payload: unknown): string | null {
97
+ if (!payload || typeof payload !== "object") return null;
98
+ const record = payload as Record<string, unknown>;
99
+ if (typeof record.writeToken === "string") return record.writeToken;
100
+ return "data" in record ? readFileChangeWriteToken(record.data) : null;
101
+ }
102
+
103
+ function eventIdentity(path: string, payload: unknown): string | null {
104
+ const version = readFileChangeVersion(payload);
105
+ if (version) return `${path}\0${version}`;
106
+ const content = readFileChangeContent(payload);
107
+ return content == null ? null : `${path}\0${content.length}\0${content}`;
108
+ }
109
+
110
+ export function useExternalFileChangeCoordinator({
111
+ projectId,
112
+ activeCompPath,
113
+ recoveryFilePath = activeCompPath,
114
+ pendingTimelineEditPathRef,
115
+ drainPendingChanges,
116
+ getPendingCandidate,
117
+ discardPendingChanges,
118
+ reloadPreview,
119
+ reloadSdkSession,
120
+ persistConflictSnapshot,
121
+ persistFailureSnapshot,
122
+ loadConflictSnapshot,
123
+ deleteConflictSnapshot,
124
+ overwriteConflict,
125
+ readProjectFile,
126
+ onUseExternalFile,
127
+ resetSaveQueues,
128
+ }: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
129
+ const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
130
+ const generationRef = useRef(0);
131
+ const mountedRef = useRef(true);
132
+ const lastEventIdentityRef = useRef<string | null>(null);
133
+ const blockedRef = useRef(blocked);
134
+ const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
135
+ blockedRef.current = blocked;
136
+
137
+ useEffect(() => {
138
+ mountedRef.current = true;
139
+ return () => {
140
+ mountedRef.current = false;
141
+ generationRef.current += 1;
142
+ };
143
+ }, []);
144
+
145
+ useEffect(() => {
146
+ generationRef.current += 1;
147
+ setBlocked(null);
148
+ lastEventIdentityRef.current = null;
149
+ }, [projectId, activeCompPath]);
150
+
151
+ useEffect(() => {
152
+ if (!projectId || !recoveryFilePath || !loadConflictSnapshot) return;
153
+ const generation = ++generationRef.current;
154
+ let cancelled = false;
155
+ void loadConflictSnapshot(projectId, recoveryFilePath)
156
+ .then((snapshot) => {
157
+ if (cancelled || !snapshot || !mountedRef.current || generation !== generationRef.current) {
158
+ return;
159
+ }
160
+ const payload = {
161
+ path: snapshot.filePath,
162
+ version: snapshot.externalVersion,
163
+ content: snapshot.externalContent,
164
+ };
165
+ if (snapshot.kind === "failed") {
166
+ setBlocked({
167
+ status: "failed",
168
+ generation,
169
+ path: snapshot.filePath,
170
+ error: new Error(snapshot.failureMessage),
171
+ payload,
172
+ studioContent: snapshot.studioContent,
173
+ recovered: true,
174
+ });
175
+ } else {
176
+ const error = new StudioFileConflictError({
177
+ filePath: snapshot.filePath,
178
+ currentVersion: snapshot.externalVersion,
179
+ currentContent: snapshot.externalContent,
180
+ attemptedContent: snapshot.studioContent,
181
+ });
182
+ setBlocked({ status: "conflict", generation, error, payload });
183
+ }
184
+ })
185
+ .catch(() => {
186
+ // Storage may be unavailable in restricted browser contexts. A failed
187
+ // best-effort restore must not create an unhandled rejection or block
188
+ // a project that has no known recovery record.
189
+ });
190
+ return () => {
191
+ cancelled = true;
192
+ };
193
+ }, [loadConflictSnapshot, projectId, recoveryFilePath]);
194
+
195
+ const reloadAcceptedGeneration = useCallback(
196
+ (path: string) => {
197
+ reloadPreview();
198
+ reloadSdkSession(path);
199
+ },
200
+ [reloadPreview, reloadSdkSession],
201
+ );
202
+
203
+ const persistSnapshotInOrder = useCallback(async (write: () => Promise<void>) => {
204
+ const next = snapshotWriteTailRef.current.catch(() => undefined).then(write);
205
+ snapshotWriteTailRef.current = next.then(
206
+ () => undefined,
207
+ () => undefined,
208
+ );
209
+ await next;
210
+ }, []);
211
+
212
+ const processChange = useCallback(
213
+ // fallow-ignore-next-line complexity
214
+ async (payload: unknown, allowDuplicate = false) => {
215
+ const path = readStudioFileChangePath(payload);
216
+ if (!path || !projectId) return;
217
+ const pendingTimelinePaths = pendingTimelineEditPathRef.current;
218
+ // The old path-only suppression could drop a real agent/user write that
219
+ // raced ahead of the timeline write receipt. Clear the legacy marker but
220
+ // decide ownership only from the exact write token/content below.
221
+ pendingTimelinePaths.delete(path);
222
+
223
+ const content = readFileChangeContent(payload);
224
+ if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return;
225
+ if (content != null && isSelfWriteEcho(path, content)) return;
226
+
227
+ const identity = eventIdentity(path, payload);
228
+ if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) return;
229
+ lastEventIdentityRef.current = identity;
230
+ const generation = ++generationRef.current;
231
+ const result = await drainPendingChanges();
232
+ if (!mountedRef.current || generation !== generationRef.current) return;
233
+
234
+ if (result.status === "clean") {
235
+ const previousBlocked = blockedRef.current;
236
+ if (previousBlocked?.status === "failed" && deleteConflictSnapshot) {
237
+ try {
238
+ await deleteConflictSnapshot(projectId, path);
239
+ } catch (error) {
240
+ if (mountedRef.current && generation === generationRef.current) {
241
+ setBlocked({ ...previousBlocked, generation, error });
242
+ }
243
+ return;
244
+ }
245
+ }
246
+ if (!mountedRef.current || generation !== generationRef.current) return;
247
+ setBlocked(null);
248
+ reloadAcceptedGeneration(path);
249
+ return;
250
+ }
251
+ if (result.status === "failed") {
252
+ const candidate = getPendingCandidate?.();
253
+ const studioContent = candidate?.path === path ? candidate.content : null;
254
+ let error = result.error;
255
+ if (studioContent != null && persistFailureSnapshot) {
256
+ try {
257
+ await persistSnapshotInOrder(() =>
258
+ persistFailureSnapshot(
259
+ projectId,
260
+ path,
261
+ studioContent,
262
+ readFileChangeVersion(payload),
263
+ content,
264
+ result.error,
265
+ ),
266
+ );
267
+ } catch (snapshotError) {
268
+ error = new Error(
269
+ `Studio could not save the edit or its recovery snapshot: ${
270
+ snapshotError instanceof Error ? snapshotError.message : String(snapshotError)
271
+ }`,
272
+ { cause: result.error },
273
+ );
274
+ }
275
+ }
276
+ if (!mountedRef.current || generation !== generationRef.current) return;
277
+ setBlocked({
278
+ status: "failed",
279
+ generation,
280
+ path,
281
+ error,
282
+ payload,
283
+ studioContent,
284
+ recovered: false,
285
+ });
286
+ return;
287
+ }
288
+ try {
289
+ await persistSnapshotInOrder(() => persistConflictSnapshot(projectId, result.error));
290
+ } catch (error) {
291
+ if (!mountedRef.current || generation !== generationRef.current) return;
292
+ setBlocked({
293
+ status: "failed",
294
+ generation,
295
+ path,
296
+ error,
297
+ payload,
298
+ studioContent: result.error.attemptedContent,
299
+ recovered: false,
300
+ });
301
+ return;
302
+ }
303
+ if (!mountedRef.current || generation !== generationRef.current) return;
304
+ setBlocked({ status: "conflict", generation, error: result.error, payload });
305
+ },
306
+ [
307
+ projectId,
308
+ pendingTimelineEditPathRef,
309
+ drainPendingChanges,
310
+ deleteConflictSnapshot,
311
+ getPendingCandidate,
312
+ persistConflictSnapshot,
313
+ persistFailureSnapshot,
314
+ persistSnapshotInOrder,
315
+ reloadAcceptedGeneration,
316
+ ],
317
+ );
318
+
319
+ useEffect(() => {
320
+ const handler = (payload?: unknown) => processChange(payload);
321
+ const adapter = testHotAdapter();
322
+ if (adapter) {
323
+ adapter.on("hf:file-change", handler);
324
+ return () => adapter.off("hf:file-change", handler);
325
+ }
326
+ if (import.meta.hot) {
327
+ import.meta.hot.on("hf:file-change", handler);
328
+ return () => import.meta.hot?.off?.("hf:file-change", handler);
329
+ }
330
+ const eventSource = new EventSource("/api/events");
331
+ eventSource.addEventListener("file-change", handler);
332
+ return () => eventSource.close();
333
+ }, [processChange]);
334
+
335
+ const retry = useCallback(async () => {
336
+ const current = blockedRef.current;
337
+ if (!current || current.status === "conflict" || current.recovered) return;
338
+ resetSaveQueues?.();
339
+ lastEventIdentityRef.current = null;
340
+ await processChange(current.payload, true);
341
+ }, [processChange, resetSaveQueues]);
342
+
343
+ const useExternalFile = useCallback(
344
+ // fallow-ignore-next-line complexity
345
+ async () => {
346
+ const current = blockedRef.current;
347
+ if (!current || !projectId || current.generation !== generationRef.current) return;
348
+ const path = current.status === "conflict" ? current.error.filePath : current.path;
349
+ const external =
350
+ current.status === "conflict" && current.error.currentContent != null
351
+ ? current.error.currentContent
352
+ : await readProjectFile(path);
353
+ if (current.generation !== generationRef.current) return;
354
+ discardPendingChanges();
355
+ resetSaveQueues?.();
356
+ onUseExternalFile?.(path, external);
357
+ await deleteConflictSnapshot?.(projectId, path);
358
+ setBlocked(null);
359
+ reloadAcceptedGeneration(path);
360
+ },
361
+ [
362
+ deleteConflictSnapshot,
363
+ discardPendingChanges,
364
+ onUseExternalFile,
365
+ projectId,
366
+ readProjectFile,
367
+ reloadAcceptedGeneration,
368
+ resetSaveQueues,
369
+ ],
370
+ );
371
+
372
+ // fallow-ignore-next-line complexity
373
+ const keepStudioFile = useCallback(async () => {
374
+ const current = blockedRef.current;
375
+ if (!current || !projectId) return;
376
+ if (current.generation !== generationRef.current) return;
377
+ let conflict: StudioFileConflictError;
378
+ if (current.status === "conflict") {
379
+ conflict = current.error;
380
+ } else {
381
+ if (!current.recovered || current.studioContent == null) return;
382
+ try {
383
+ const currentContent =
384
+ readFileChangeContent(current.payload) ?? (await readProjectFile(current.path));
385
+ conflict = new StudioFileConflictError({
386
+ filePath: current.path,
387
+ currentVersion: readFileChangeVersion(current.payload),
388
+ currentContent,
389
+ attemptedContent: current.studioContent,
390
+ });
391
+ } catch (error) {
392
+ if (current.generation === generationRef.current) setBlocked({ ...current, error });
393
+ return;
394
+ }
395
+ }
396
+ try {
397
+ await overwriteConflict(conflict);
398
+ } catch (error) {
399
+ if (current.generation === generationRef.current) {
400
+ setBlocked({
401
+ status: "failed",
402
+ generation: current.generation,
403
+ path: conflict.filePath,
404
+ error,
405
+ payload: current.payload,
406
+ studioContent: conflict.attemptedContent,
407
+ recovered: current.status === "failed" && current.recovered,
408
+ });
409
+ }
410
+ return;
411
+ }
412
+ if (current.generation !== generationRef.current) return;
413
+ discardPendingChanges();
414
+ resetSaveQueues?.();
415
+ await deleteConflictSnapshot?.(projectId, conflict.filePath);
416
+ setBlocked(null);
417
+ reloadAcceptedGeneration(conflict.filePath);
418
+ }, [
419
+ deleteConflictSnapshot,
420
+ discardPendingChanges,
421
+ overwriteConflict,
422
+ projectId,
423
+ readProjectFile,
424
+ reloadAcceptedGeneration,
425
+ resetSaveQueues,
426
+ ]);
427
+
428
+ return { blocked, retry, useExternalFile, keepStudioFile };
429
+ }
@@ -20,15 +20,65 @@ vi.mock("./useEditorSave", () => ({
20
20
  useEditorSave: () => ({
21
21
  saveRafRef: { current: null },
22
22
  handleContentChange: vi.fn(),
23
+ getPendingCandidate: vi.fn(() => null),
24
+ flushPendingSave: vi.fn(async () => ({ status: "clean" as const })),
25
+ discardPendingSave: vi.fn(),
23
26
  }),
24
27
  }));
25
28
 
26
29
  import { useFileManager } from "./useFileManager";
30
+ import { resetStudioWriteTokens, studioFileContentVersion } from "../utils/studioFileVersion";
31
+ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
27
32
 
28
33
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
29
34
 
35
+ function useTestFileManager(projectId: string) {
36
+ return useFileManager({
37
+ projectId,
38
+ showToast: vi.fn(),
39
+ recordEdit: vi.fn(async () => {}),
40
+ domEditSaveTimestampRef: { current: 0 },
41
+ setRefreshKey: vi.fn(),
42
+ });
43
+ }
44
+
45
+ async function mountTestFileManager(projectId = "project-a") {
46
+ const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
47
+ function Probe() {
48
+ captured.manager = useTestFileManager(projectId);
49
+ return null;
50
+ }
51
+ const root = createRoot(document.createElement("div"));
52
+ await act(async () => root.render(<Probe />));
53
+ const manager = captured.manager;
54
+ if (!manager) throw new Error("file manager did not render");
55
+ return { manager, root };
56
+ }
57
+
58
+ async function mountOverwriteRequest(response: Response) {
59
+ const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
60
+ if (init?.method !== "PUT") {
61
+ throw new Error("overwrite unexpectedly performed a preflight read");
62
+ }
63
+ return Promise.resolve(response);
64
+ });
65
+ vi.stubGlobal("fetch", fetchMock);
66
+ return { ...(await mountTestFileManager()), fetchMock };
67
+ }
68
+
69
+ function createOverwriteConflict(currentVersion: string | null, currentContent: string | null) {
70
+ return new StudioFileConflictError({
71
+ filePath: "index.html",
72
+ currentVersion,
73
+ currentContent,
74
+ attemptedContent: "STUDIO",
75
+ });
76
+ }
77
+
30
78
  describe("useFileManager project ownership", () => {
31
79
  afterEach(() => {
80
+ resetStudioWriteTokens();
81
+ vi.useRealTimers();
32
82
  vi.unstubAllGlobals();
33
83
  });
34
84
 
@@ -54,13 +104,7 @@ describe("useFileManager project ownership", () => {
54
104
 
55
105
  const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
56
106
  function Probe({ projectId }: { projectId: string }) {
57
- captured.manager = useFileManager({
58
- projectId,
59
- showToast: vi.fn(),
60
- recordEdit: vi.fn(async () => {}),
61
- domEditSaveTimestampRef: { current: 0 },
62
- setRefreshKey: vi.fn(),
63
- });
107
+ captured.manager = useTestFileManager(projectId);
64
108
  return null;
65
109
  }
66
110
 
@@ -106,4 +150,92 @@ describe("useFileManager project ownership", () => {
106
150
 
107
151
  await act(async () => root.unmount());
108
152
  });
153
+
154
+ it("uses a fresh write token when a lost response retries a committed save", async () => {
155
+ vi.useFakeTimers();
156
+ let putAttempt = 0;
157
+ const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
158
+ if (!init?.method) {
159
+ return Promise.resolve({
160
+ ok: true,
161
+ json: async () => ({ content: "BEFORE", version: "v1" }),
162
+ } as Response);
163
+ }
164
+ putAttempt += 1;
165
+ if (putAttempt === 1) return Promise.reject(new TypeError("response lost"));
166
+ return Promise.resolve({ ok: true, json: async () => ({ version: "v2" }) } as Response);
167
+ });
168
+ vi.stubGlobal("fetch", fetchMock);
169
+
170
+ const { manager, root } = await mountTestFileManager();
171
+ await manager.readProjectFile("index.html");
172
+
173
+ const write = manager.writeProjectFile("index.html", "AFTER");
174
+ await vi.runAllTimersAsync();
175
+ await write;
176
+
177
+ const writeTokens = fetchMock.mock.calls
178
+ .filter(([, init]) => init?.method === "PUT")
179
+ .map(([, init]) => new Headers(init?.headers).get("X-Hyperframes-Write-Token"));
180
+ expect(writeTokens).toHaveLength(2);
181
+ expect(writeTokens[0]).toBeTruthy();
182
+ expect(writeTokens[1]).not.toBe(writeTokens[0]);
183
+
184
+ await act(async () => root.unmount());
185
+ });
186
+
187
+ it("overwrites the exact external content version with an If-Match precondition", async () => {
188
+ const { manager, root, fetchMock } = await mountOverwriteRequest({
189
+ ok: true,
190
+ json: async () => ({ version: "v3" }),
191
+ } as Response);
192
+ const conflict = createOverwriteConflict("v2", "EXTERNAL");
193
+
194
+ await manager.overwriteExternalConflict(conflict);
195
+
196
+ const [, init] = fetchMock.mock.calls[0] ?? [];
197
+ const headers = new Headers(init?.headers);
198
+ expect(init).toMatchObject({ method: "PUT", body: "STUDIO" });
199
+ expect(headers.get("If-Match")).toBe(await studioFileContentVersion("EXTERNAL"));
200
+ expect(headers.get("If-None-Match")).toBeNull();
201
+ await act(async () => root.unmount());
202
+ });
203
+
204
+ it("preserves a newer third-party edit when a content-less conflict version is stale", async () => {
205
+ const { manager, root, fetchMock } = await mountOverwriteRequest({
206
+ ok: false,
207
+ status: 409,
208
+ json: async () => ({ currentVersion: "v3", currentContent: "THIRD PARTY" }),
209
+ } as Response);
210
+ const conflict = createOverwriteConflict("v2", null);
211
+
212
+ await expect(manager.overwriteExternalConflict(conflict)).rejects.toMatchObject({
213
+ name: "StudioFileConflictError",
214
+ currentVersion: "v3",
215
+ currentContent: "THIRD PARTY",
216
+ attemptedContent: "STUDIO",
217
+ });
218
+
219
+ const [, init] = fetchMock.mock.calls[0] ?? [];
220
+ const headers = new Headers(init?.headers);
221
+ expect(headers.get("If-Match")).toBe("v2");
222
+ expect(headers.get("If-None-Match")).toBeNull();
223
+ await act(async () => root.unmount());
224
+ });
225
+
226
+ it("uses create-only semantics when the conflicted file was deleted", async () => {
227
+ const { manager, root, fetchMock } = await mountOverwriteRequest({
228
+ ok: true,
229
+ json: async () => ({ version: "v1" }),
230
+ } as Response);
231
+ const conflict = createOverwriteConflict(null, null);
232
+
233
+ await manager.overwriteExternalConflict(conflict);
234
+
235
+ const [, init] = fetchMock.mock.calls[0] ?? [];
236
+ const headers = new Headers(init?.headers);
237
+ expect(headers.get("If-Match")).toBeNull();
238
+ expect(headers.get("If-None-Match")).toBe("*");
239
+ await act(async () => root.unmount());
240
+ });
109
241
  });
@@ -10,7 +10,11 @@ import {
10
10
  StudioFileConflictError,
11
11
  StudioSaveNetworkError,
12
12
  } from "../utils/studioSaveDiagnostics";
13
- import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
13
+ import {
14
+ createStudioWriteToken,
15
+ markStudioWriteToken,
16
+ studioExpectedFileVersion,
17
+ } from "../utils/studioFileVersion";
14
18
  import { useFileTree } from "./useFileTree";
15
19
  import { useEditorSave } from "./useEditorSave";
16
20
 
@@ -117,8 +121,11 @@ export function useFileManager({
117
121
  throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
118
122
  }
119
123
  }
120
- const writeToken = createStudioWriteToken();
121
124
  await retryStudioSave(async () => {
125
+ // Each request gets its own receipt identity. If a committed request loses its response,
126
+ // the retry can produce a second filesystem receipt that must be suppressed independently.
127
+ const writeToken = createStudioWriteToken();
128
+ markStudioWriteToken(writeToken);
122
129
  let response: Response;
123
130
  try {
124
131
  response = await fetch(
@@ -191,7 +198,7 @@ export function useFileManager({
191
198
 
192
199
  // ── Editor save (debounced content change) ──
193
200
 
194
- const { saveRafRef, handleContentChange } = useEditorSave({
201
+ const editorSave = useEditorSave({
195
202
  editingPathRef,
196
203
  projectIdRef,
197
204
  readProjectFile,
@@ -201,6 +208,24 @@ export function useFileManager({
201
208
  setRefreshKey,
202
209
  showToast,
203
210
  });
211
+ const { saveRafRef, handleContentChange } = editorSave;
212
+
213
+ const overwriteExternalConflict = useCallback(
214
+ async (conflict: StudioFileConflictError) => {
215
+ if (conflict.currentContent != null) {
216
+ await writeProjectFile(
217
+ conflict.filePath,
218
+ conflict.attemptedContent,
219
+ conflict.currentContent,
220
+ );
221
+ } else {
222
+ fileVersions.set(conflict.filePath, conflict.currentVersion);
223
+ await writeProjectFile(conflict.filePath, conflict.attemptedContent);
224
+ }
225
+ updateEditingFileContent(conflict.filePath, conflict.attemptedContent);
226
+ },
227
+ [fileVersions, updateEditingFileContent, writeProjectFile],
228
+ );
204
229
 
205
230
  // ── File select ──
206
231
 
@@ -491,11 +516,15 @@ export function useFileManager({
491
516
  editingPathRef,
492
517
  projectIdRef,
493
518
  saveRafRef,
519
+ flushPendingSourceSave: editorSave.flushPendingSave,
520
+ discardPendingSourceSave: editorSave.discardPendingSave,
521
+ getPendingSourceCandidate: editorSave.getPendingCandidate,
494
522
  importedFontAssetsRef,
495
523
 
496
524
  // Core I/O
497
525
  readProjectFile,
498
526
  writeProjectFile,
527
+ overwriteExternalConflict,
499
528
  readOptionalProjectFile,
500
529
  observeProjectFileVersion,
501
530
  updateEditingFileContent,