@hyperframes/studio 0.7.92 → 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 (108) hide show
  1. package/dist/assets/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CDTN2ZVa.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-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +20 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7055 -5321
  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/editor/propertyPanel3dTransform.tsx +1 -1
  16. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  19. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  20. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  21. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  22. package/src/components/editor/propertyPanelTypes.ts +9 -3
  23. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  24. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  25. package/src/components/sidebar/AssetCard.tsx +4 -3
  26. package/src/components/sidebar/AudioRow.tsx +4 -3
  27. package/src/contexts/FileManagerContext.tsx +12 -0
  28. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  29. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  30. package/src/hooks/externalFileReloadBus.ts +18 -0
  31. package/src/hooks/gsapDragCommit.test.ts +39 -1
  32. package/src/hooks/gsapDragCommit.ts +12 -40
  33. package/src/hooks/gsapEditOutcome.ts +72 -0
  34. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  35. package/src/hooks/gsapResizeIntercept.ts +44 -16
  36. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  37. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  38. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  39. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  40. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  42. package/src/hooks/useDomGeometryCommits.ts +20 -0
  43. package/src/hooks/useEditorSave.test.tsx +131 -0
  44. package/src/hooks/useEditorSave.ts +113 -39
  45. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  47. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  48. package/src/hooks/useFileManager.ts +32 -3
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  50. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  51. package/src/hooks/useGsapAwareEditing.ts +88 -27
  52. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  54. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  55. package/src/hooks/usePreviewPersistence.ts +22 -58
  56. package/src/hooks/useSdkSession.ts +8 -52
  57. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  58. package/src/hooks/useStudioSdkSessions.ts +2 -7
  59. package/src/hooks/useStudioSessionStart.ts +15 -0
  60. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  61. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  62. package/src/player/components/Timeline.test.ts +24 -5
  63. package/src/player/components/Timeline.tsx +40 -40
  64. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  65. package/src/player/components/TimelineClip.test.tsx +17 -2
  66. package/src/player/components/TimelineClip.tsx +15 -3
  67. package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
  68. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  69. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  70. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  71. package/src/player/components/TimelineLanes.test.tsx +64 -6
  72. package/src/player/components/TimelineLanes.tsx +81 -64
  73. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  74. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  75. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  77. package/src/player/components/TimelineTrackRow.tsx +58 -7
  78. package/src/player/components/timelineDiamondTypes.ts +14 -7
  79. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  80. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  81. package/src/player/components/timelineLaneProps.ts +3 -0
  82. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  83. package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
  84. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  85. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  86. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  87. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  88. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  89. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  90. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  91. package/src/player/components/useTimelineRowVirtualization.ts +16 -26
  92. package/src/player/store/liveTime.ts +11 -0
  93. package/src/player/store/playerStore.test.ts +29 -22
  94. package/src/player/store/playerStore.ts +24 -29
  95. package/src/player/store/timelineFocusState.ts +15 -0
  96. package/src/utils/domEditSaveQueue.test.ts +37 -1
  97. package/src/utils/domEditSaveQueue.ts +22 -4
  98. package/src/utils/externalConflictStorage.test.ts +95 -0
  99. package/src/utils/externalConflictStorage.ts +172 -0
  100. package/src/utils/studioFileVersion.test.ts +28 -1
  101. package/src/utils/studioFileVersion.ts +30 -0
  102. package/src/utils/studioPendingEdits.test.ts +108 -1
  103. package/src/utils/studioPendingEdits.ts +39 -2
  104. package/src/utils/studioSaveDiagnostics.ts +5 -0
  105. package/dist/assets/index-BXkzp_OU.css +0 -1
  106. package/dist/assets/index-DIntkrQl.js +0 -428
  107. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  108. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -20,9 +20,9 @@ interface UseTimelineRowVirtualizationInput {
20
20
  viewport: TimelineScrollViewportSnapshot;
21
21
  rowGeometry: TimelineRowGeometry;
22
22
  sessionEpoch: number;
23
- elements: TimelineElement[];
23
+ elements: readonly TimelineElement[];
24
24
  selectedElementId: string | null;
25
- revealElementId: string | null;
25
+ focusedRowKey?: number;
26
26
  draggedRowKey?: number;
27
27
  resizingElementIds?: readonly string[];
28
28
  clipContextMenuRowKey?: number;
@@ -31,19 +31,12 @@ interface UseTimelineRowVirtualizationInput {
31
31
  syncScrollViewport: (element: HTMLDivElement, isScrolling?: boolean) => void;
32
32
  }
33
33
 
34
- interface TimelineDomFocusPin {
35
- readonly rowKey?: number;
36
- readonly elementId?: string;
37
- }
38
-
39
- function getTimelineDomFocusPin(target: EventTarget | null): TimelineDomFocusPin | undefined {
34
+ function getFocusedTimelineRowKey(target: EventTarget | null): number | undefined {
40
35
  if (!(target instanceof Element)) return undefined;
41
36
  const value = target.closest<HTMLElement>("[data-timeline-row-key]")?.dataset.timelineRowKey;
42
- const parsedRowKey = value === undefined ? undefined : Number(value);
43
- const rowKey =
44
- parsedRowKey !== undefined && Number.isFinite(parsedRowKey) ? parsedRowKey : undefined;
45
- const elementId = target.closest<HTMLElement>("[data-el-id]")?.dataset.elId;
46
- return rowKey === undefined && elementId === undefined ? undefined : { rowKey, elementId };
37
+ if (value === undefined) return undefined;
38
+ const rowKey = Number(value);
39
+ return Number.isFinite(rowKey) ? rowKey : undefined;
47
40
  }
48
41
 
49
42
  export function useTimelineRowVirtualization({
@@ -53,7 +46,7 @@ export function useTimelineRowVirtualization({
53
46
  sessionEpoch,
54
47
  elements,
55
48
  selectedElementId,
56
- revealElementId,
49
+ focusedRowKey,
57
50
  draggedRowKey,
58
51
  resizingElementIds,
59
52
  clipContextMenuRowKey,
@@ -62,21 +55,17 @@ export function useTimelineRowVirtualization({
62
55
  syncScrollViewport,
63
56
  }: UseTimelineRowVirtualizationInput) {
64
57
  const enabled = STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED;
65
- const [domFocusPin, setDomFocusPin] = useState<TimelineDomFocusPin>();
58
+ const [domFocusedRowKey, setDomFocusedRowKey] = useState<number>();
66
59
  const onTimelineFocus = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
67
- setDomFocusPin(getTimelineDomFocusPin(event.target));
60
+ setDomFocusedRowKey(getFocusedTimelineRowKey(event.target));
68
61
  }, []);
69
62
  const onTimelineBlur = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
70
- setDomFocusPin(getTimelineDomFocusPin(event.relatedTarget));
63
+ setDomFocusedRowKey(getFocusedTimelineRowKey(event.relatedTarget));
71
64
  }, []);
72
- const focusIdentity = useMemo(
65
+ const selectedIdentity = useMemo(
73
66
  () => resolveTimelineFocusIdentity(elements, selectedElementId),
74
67
  [elements, selectedElementId],
75
68
  );
76
- const revealIdentity = useMemo(
77
- () => resolveTimelineFocusIdentity(elements, revealElementId),
78
- [elements, revealElementId],
79
- );
80
69
  const resizingRowKeys = useMemo(
81
70
  () =>
82
71
  resizingElementIds
@@ -89,16 +78,18 @@ export function useTimelineRowVirtualization({
89
78
  [
90
79
  draggedRowKey,
91
80
  ...resizingRowKeys,
92
- revealIdentity?.rowKey,
81
+ selectedIdentity?.rowKey,
82
+ focusedRowKey,
93
83
  clipContextMenuRowKey,
94
84
  keyframeContextMenuRowKey,
95
85
  ].filter((rowKey): rowKey is number => rowKey !== undefined),
96
86
  [
97
87
  clipContextMenuRowKey,
98
88
  draggedRowKey,
89
+ focusedRowKey,
99
90
  keyframeContextMenuRowKey,
100
91
  resizingRowKeys,
101
- revealIdentity,
92
+ selectedIdentity,
102
93
  ],
103
94
  );
104
95
  const virtualRows = useTimelineVirtualRows({
@@ -108,7 +99,7 @@ export function useTimelineRowVirtualization({
108
99
  rowGeometry,
109
100
  sessionEpoch,
110
101
  pinnedRowKeys,
111
- focusedRowKey: domFocusPin?.rowKey ?? focusIdentity?.rowKey,
102
+ focusedRowKey: domFocusedRowKey ?? focusedRowKey,
112
103
  });
113
104
 
114
105
  const previousLayoutRef = useRef(rowGeometry);
@@ -141,7 +132,6 @@ export function useTimelineRowVirtualization({
141
132
  return {
142
133
  enabled,
143
134
  virtualRows,
144
- focusedElementId: domFocusPin?.elementId,
145
135
  timelineFocusProps: { onFocus: onTimelineFocus, onBlur: onTimelineBlur },
146
136
  };
147
137
  }
@@ -0,0 +1,11 @@
1
+ // ponytail: Playback RAF updates the playhead/time display without per-frame React renders.
2
+ type TimeListener = (time: number) => void;
3
+ const timeListeners = new Set<TimeListener>();
4
+
5
+ export const liveTime = {
6
+ notify: (time: number) => timeListeners.forEach((listener) => listener(time)),
7
+ subscribe: (listener: TimeListener) => {
8
+ timeListeners.add(listener);
9
+ return () => timeListeners.delete(listener);
10
+ },
11
+ };
@@ -492,29 +492,36 @@ describe("usePlayerStore", () => {
492
492
  });
493
493
  });
494
494
 
495
- describe("clipRevealRequest", () => {
496
- it("starts null and carries the requested element id", () => {
497
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
498
- usePlayerStore.getState().requestClipReveal("el-1");
499
- expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
500
- });
501
-
502
- it("bumps the nonce on repeat requests for the same clip", () => {
503
- usePlayerStore.getState().requestClipReveal("el-1");
504
- const first = usePlayerStore.getState().clipRevealRequest;
505
- usePlayerStore.getState().requestClipReveal("el-1");
506
- const second = usePlayerStore.getState().clipRevealRequest;
507
- expect(second?.nonce).not.toBe(first?.nonce);
508
- });
509
-
510
- it("clears via clearClipRevealRequest and on reset", () => {
511
- usePlayerStore.getState().requestClipReveal("el-1");
512
- usePlayerStore.getState().clearClipRevealRequest();
513
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
495
+ describe("timelineFocus", () => {
496
+ it("stamps project scope and carries the requested logical id", () => {
497
+ usePlayerStore.getState().beginTimelineSession("project-a");
498
+ usePlayerStore.getState().requestTimelineFocus("clip:el-1");
499
+ expect(usePlayerStore.getState().timelineFocus).toMatchObject({
500
+ id: "clip:el-1",
501
+ projectId: "project-a",
502
+ sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
503
+ });
504
+ const store = usePlayerStore.getState();
505
+ store.requestTimelineFocus("clip:el-1");
506
+ const first = usePlayerStore.getState().timelineFocus;
507
+ if (!first) throw new Error("expected timeline focus request");
508
+ store.clearTimelineFocus(first.nonce);
509
+ store.reset();
510
+ store.requestTimelineFocus("clip:el-1");
511
+ const second = usePlayerStore.getState().timelineFocus;
512
+ expect(second?.nonce).toBe(first.nonce + 1);
514
513
 
515
- usePlayerStore.getState().requestClipReveal("el-2");
516
- usePlayerStore.getState().reset();
517
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
514
+ store.beginTimelineSession("project-a");
515
+ store.requestTimelineFocus("clip:el-1");
516
+ const stale = usePlayerStore.getState().timelineFocus;
517
+ if (!stale) throw new Error("expected timeline focus request");
518
+ store.requestTimelineFocus("clip:el-2");
519
+ const replacement = usePlayerStore.getState().timelineFocus;
520
+ if (!replacement) throw new Error("expected replacement timeline focus request");
521
+ store.clearTimelineFocus(stale.nonce);
522
+ expect(usePlayerStore.getState().timelineFocus).toBe(replacement);
523
+ store.beginTimelineSession("project-b");
524
+ expect(usePlayerStore.getState().timelineFocus).toBeNull();
518
525
  });
519
526
  });
520
527
 
@@ -10,8 +10,10 @@ import {
10
10
  } from "../../utils/studioUiPreferences";
11
11
  import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
12
12
  import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
13
+ import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
13
14
 
14
15
  export type { KeyframeCacheEntry } from "./keyframeSlice";
16
+ export { liveTime } from "./liveTime";
15
17
 
16
18
  export interface TimelineElement {
17
19
  id: string;
@@ -220,15 +222,10 @@ interface PlayerState extends KeyframeSlice {
220
222
  requestSeek: (time: number) => void;
221
223
  clearSeekRequest: () => void;
222
224
 
223
- /**
224
- * Request the timeline to scroll a clip into view (e.g. clicking an
225
- * already-added asset card in the sidebar). Consumed and cleared by
226
- * useTimelineRevealClip. The nonce makes repeat requests for the same
227
- * clip observable so a second click re-reveals after the user scrolls away.
228
- */
229
- clipRevealRequest: { elementId: string; nonce: number } | null;
230
- requestClipReveal: (elementId: string) => void;
231
- clearClipRevealRequest: () => void;
225
+ timelineFocus: TimelineFocusRequest | null;
226
+ timelineFocusNonce: number;
227
+ requestTimelineFocus: (id: string) => void;
228
+ clearTimelineFocus: (nonce: number) => void;
232
229
 
233
230
  lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
234
231
  setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
@@ -279,19 +276,6 @@ interface BeatHistoryEntry {
279
276
  label: string;
280
277
  }
281
278
 
282
- // Lightweight pub-sub for current time during playback.
283
- // Bypasses React state so the RAF loop can update the playhead/time display
284
- // without triggering re-renders on every frame.
285
- type TimeListener = (time: number) => void;
286
- const _timeListeners = new Set<TimeListener>();
287
- export const liveTime = {
288
- notify: (t: number) => _timeListeners.forEach((cb) => cb(t)),
289
- subscribe: (cb: TimeListener) => {
290
- _timeListeners.add(cb);
291
- return () => _timeListeners.delete(cb);
292
- },
293
- };
294
-
295
279
  export function createTimelineResetState() {
296
280
  return {
297
281
  isPlaying: false,
@@ -313,8 +297,8 @@ export function createTimelineResetState() {
313
297
  focusedEaseSegment: null,
314
298
  selectedElementIds: new Set<string>(),
315
299
  requestedSeekTime: null,
316
- clipRevealRequest: null,
317
300
  lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
301
+ timelineFocus: null,
318
302
  keyframeCache: new Map<string, KeyframeCacheEntry>(),
319
303
  gsapAnimations: new Map<string, GsapAnimation[]>(),
320
304
  beatAnalysis: null,
@@ -387,12 +371,23 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
387
371
  requestSeek: (time) => set({ requestedSeekTime: time }),
388
372
  clearSeekRequest: () => set({ requestedSeekTime: null }),
389
373
 
390
- clipRevealRequest: null,
391
- requestClipReveal: (elementId) =>
392
- set((s) => ({
393
- clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
394
- })),
395
- clearClipRevealRequest: () => set({ clipRevealRequest: null }),
374
+ timelineFocus: null,
375
+ timelineFocusNonce: 0,
376
+ requestTimelineFocus: (id) =>
377
+ set((s) => {
378
+ const nonce = s.timelineFocusNonce + 1;
379
+ return {
380
+ timelineFocusNonce: nonce,
381
+ timelineFocus: createTimelineFocusRequest(
382
+ id,
383
+ s.timelineProjectId,
384
+ s.timelineSessionEpoch,
385
+ nonce,
386
+ ),
387
+ };
388
+ }),
389
+ clearTimelineFocus: (nonce) =>
390
+ set((s) => (s.timelineFocus?.nonce === nonce ? { timelineFocus: null } : s)),
396
391
 
397
392
  lintFindingsByElement: new Map(),
398
393
  setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
@@ -0,0 +1,15 @@
1
+ export interface TimelineFocusRequest {
2
+ id: string;
3
+ projectId: string | null;
4
+ sessionEpoch: number;
5
+ nonce: number;
6
+ }
7
+
8
+ export function createTimelineFocusRequest(
9
+ id: string,
10
+ projectId: string | null,
11
+ sessionEpoch: number,
12
+ nonce: number,
13
+ ): TimelineFocusRequest {
14
+ return { id, projectId, sessionEpoch, nonce };
15
+ }
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createDomEditSaveQueue } from "./domEditSaveQueue";
3
- import { StudioSaveHttpError } from "./studioSaveDiagnostics";
3
+ import { StudioFileConflictError, StudioSaveHttpError } from "./studioSaveDiagnostics";
4
4
 
5
5
  describe("dom edit save queue", () => {
6
6
  afterEach(() => {
@@ -115,6 +115,23 @@ describe("dom edit save queue", () => {
115
115
  queue.destroy();
116
116
  });
117
117
 
118
+ it("clears a stale drain failure after a successful save", async () => {
119
+ const failure = new Error("temporary failure");
120
+ const queue = createDomEditSaveQueue();
121
+
122
+ await expect(
123
+ queue.enqueue(async () => {
124
+ throw failure;
125
+ }),
126
+ ).rejects.toBe(failure);
127
+ await expect(queue.waitForIdle()).resolves.toEqual({ status: "failed", error: failure });
128
+
129
+ await queue.enqueue(async () => undefined);
130
+
131
+ await expect(queue.waitForIdle()).resolves.toEqual({ status: "clean" });
132
+ queue.destroy();
133
+ });
134
+
118
135
  it("pauses immediately on a file conflict instead of retrying stale work", async () => {
119
136
  const onOpen = vi.fn();
120
137
  const queue = createDomEditSaveQueue({ failureThreshold: 5, onOpen });
@@ -133,4 +150,23 @@ describe("dom edit save queue", () => {
133
150
  await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
134
151
  queue.destroy();
135
152
  });
153
+
154
+ it("returns the original conflict from a drain instead of erasing it", async () => {
155
+ const conflict = new StudioFileConflictError({
156
+ filePath: "index.html",
157
+ currentVersion: "external-v2",
158
+ currentContent: "external",
159
+ attemptedContent: "studio",
160
+ });
161
+ const queue = createDomEditSaveQueue();
162
+
163
+ await expect(
164
+ queue.enqueue(async () => {
165
+ throw conflict;
166
+ }),
167
+ ).rejects.toBe(conflict);
168
+
169
+ await expect(queue.waitForIdle()).resolves.toEqual({ status: "conflict", error: conflict });
170
+ queue.destroy();
171
+ });
136
172
  });
@@ -1,4 +1,9 @@
1
- import { getStudioSaveErrorMessage, getStudioSaveStatusCode } from "./studioSaveDiagnostics";
1
+ import {
2
+ getStudioSaveErrorMessage,
3
+ getStudioSaveStatusCode,
4
+ StudioFileConflictError,
5
+ type StudioSaveDrainResult,
6
+ } from "./studioSaveDiagnostics";
2
7
 
3
8
  interface DomEditSaveQueueOpenEvent {
4
9
  consecutiveFailures: number;
@@ -14,11 +19,13 @@ interface DomEditSaveQueueOptions {
14
19
 
15
20
  export interface DomEditSaveQueue {
16
21
  enqueue: <T>(save: () => Promise<T>) => Promise<T>;
17
- waitForIdle: () => Promise<void>;
22
+ waitForIdle: () => Promise<DomEditSaveDrainResult>;
18
23
  reset: () => void;
19
24
  destroy: () => void;
20
25
  }
21
26
 
27
+ export type DomEditSaveDrainResult = StudioSaveDrainResult;
28
+
22
29
  const DEFAULT_FAILURE_THRESHOLD = 5;
23
30
 
24
31
  export class DomEditSaveQueueOpenError extends Error {
@@ -34,11 +41,13 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
34
41
  let tail = Promise.resolve();
35
42
  let consecutiveFailures = 0;
36
43
  let breakerOpen = false;
44
+ let drainError: unknown = null;
37
45
 
38
46
  const reset = (notify = true) => {
39
47
  const wasOpen = breakerOpen;
40
48
  consecutiveFailures = 0;
41
49
  breakerOpen = false;
50
+ drainError = null;
42
51
  if (notify && wasOpen) options.onReset?.();
43
52
  };
44
53
 
@@ -55,9 +64,13 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
55
64
  const run = async <T>(save: () => Promise<T>): Promise<T> => {
56
65
  try {
57
66
  const result = await save();
58
- if (!breakerOpen) consecutiveFailures = 0;
67
+ if (!breakerOpen) {
68
+ consecutiveFailures = 0;
69
+ drainError = null;
70
+ }
59
71
  return result;
60
72
  } catch (error) {
73
+ drainError = error;
61
74
  consecutiveFailures += 1;
62
75
  if (getStudioSaveStatusCode(error) === 409 || consecutiveFailures >= failureThreshold)
63
76
  open(error);
@@ -76,8 +89,13 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
76
89
  return queued;
77
90
  },
78
91
 
79
- async waitForIdle() {
92
+ async waitForIdle(): Promise<DomEditSaveDrainResult> {
80
93
  await tail.catch(() => undefined);
94
+ if (drainError instanceof StudioFileConflictError) {
95
+ return { status: "conflict", error: drainError };
96
+ }
97
+ if (drainError != null) return { status: "failed", error: drainError };
98
+ return { status: "clean" };
81
99
  },
82
100
 
83
101
  reset,
@@ -0,0 +1,95 @@
1
+ import { IDBFactory } from "fake-indexeddb";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ createMemoryExternalConflictStorage,
5
+ deleteExternalConflictSnapshot,
6
+ loadExternalConflictSnapshot,
7
+ persistExternalConflictSnapshot,
8
+ type ExternalConflictSnapshot,
9
+ } from "./externalConflictStorage";
10
+ import { StudioFileConflictError } from "./studioSaveDiagnostics";
11
+
12
+ describe("external conflict snapshots", () => {
13
+ beforeEach(() => {
14
+ vi.stubGlobal("indexedDB", new IDBFactory());
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ vi.unstubAllGlobals();
20
+ });
21
+
22
+ it("durably records both complete file versions before resolution", async () => {
23
+ const storage = createMemoryExternalConflictStorage();
24
+ const snapshot: ExternalConflictSnapshot = {
25
+ kind: "conflict",
26
+ projectId: "project-a",
27
+ filePath: "index.html",
28
+ externalVersion: "v2",
29
+ externalContent: "external full file",
30
+ studioContent: "studio full file",
31
+ createdAt: 100,
32
+ };
33
+
34
+ await storage.set(snapshot);
35
+ expect(await storage.get("project-a", "index.html")).toEqual(snapshot);
36
+ await storage.delete("project-a", "index.html");
37
+ expect(await storage.get("project-a", "index.html")).toBeNull();
38
+ });
39
+
40
+ it("records a failed final Studio candidate for remount recovery", async () => {
41
+ const storage = createMemoryExternalConflictStorage();
42
+ const snapshot: ExternalConflictSnapshot = {
43
+ kind: "failed",
44
+ projectId: "project-a",
45
+ filePath: "index.html",
46
+ externalVersion: null,
47
+ externalContent: null,
48
+ studioContent: "recover me",
49
+ failureMessage: "network unavailable",
50
+ createdAt: 101,
51
+ };
52
+
53
+ await storage.set(snapshot);
54
+ expect(await storage.get("project-a", "index.html")).toEqual(snapshot);
55
+ });
56
+
57
+ it("persists, loads, and deletes a snapshot through the production IndexedDB adapter", async () => {
58
+ vi.spyOn(Date, "now").mockReturnValue(1234);
59
+ const conflict = new StudioFileConflictError({
60
+ filePath: "index.html",
61
+ currentVersion: "v2",
62
+ currentContent: "external full file",
63
+ attemptedContent: "studio full file",
64
+ });
65
+
66
+ await persistExternalConflictSnapshot("indexeddb-project", conflict);
67
+ await expect(loadExternalConflictSnapshot("indexeddb-project", "index.html")).resolves.toEqual({
68
+ kind: "conflict",
69
+ projectId: "indexeddb-project",
70
+ filePath: "index.html",
71
+ externalVersion: "v2",
72
+ externalContent: "external full file",
73
+ studioContent: "studio full file",
74
+ createdAt: 1234,
75
+ });
76
+ await deleteExternalConflictSnapshot("indexeddb-project", "index.html");
77
+ await expect(
78
+ loadExternalConflictSnapshot("indexeddb-project", "index.html"),
79
+ ).resolves.toBeNull();
80
+ });
81
+
82
+ it("rejects persistence when IndexedDB is unavailable", async () => {
83
+ vi.stubGlobal("indexedDB", undefined);
84
+ const conflict = new StudioFileConflictError({
85
+ filePath: "index.html",
86
+ currentVersion: "v2",
87
+ currentContent: "external",
88
+ attemptedContent: "studio",
89
+ });
90
+
91
+ await expect(persistExternalConflictSnapshot("project-a", conflict)).rejects.toThrow(
92
+ "IndexedDB is unavailable; conflict recovery snapshot was not saved",
93
+ );
94
+ });
95
+ });
@@ -0,0 +1,172 @@
1
+ import type { StudioFileConflictError } from "./studioSaveDiagnostics";
2
+
3
+ interface ExternalRecoverySnapshotBase {
4
+ projectId: string;
5
+ filePath: string;
6
+ externalVersion: string | null;
7
+ externalContent: string | null;
8
+ studioContent: string;
9
+ createdAt: number;
10
+ }
11
+
12
+ export type ExternalConflictSnapshot =
13
+ | (ExternalRecoverySnapshotBase & { kind: "conflict" })
14
+ | (ExternalRecoverySnapshotBase & { kind: "failed"; failureMessage: string });
15
+
16
+ export interface ExternalConflictStorage {
17
+ get(projectId: string, filePath: string): Promise<ExternalConflictSnapshot | null>;
18
+ set(snapshot: ExternalConflictSnapshot): Promise<void>;
19
+ delete(projectId: string, filePath: string): Promise<void>;
20
+ }
21
+
22
+ const DB_NAME = "hyperframes-studio-external-conflicts";
23
+ const DB_VERSION = 1;
24
+ const STORE_NAME = "file-conflicts";
25
+
26
+ function key(projectId: string, filePath: string): string {
27
+ return `${projectId}\0${filePath}`;
28
+ }
29
+
30
+ export function createMemoryExternalConflictStorage(): ExternalConflictStorage {
31
+ const snapshots = new Map<string, ExternalConflictSnapshot>();
32
+ return {
33
+ async get(projectId, filePath) {
34
+ const snapshot = snapshots.get(key(projectId, filePath));
35
+ return snapshot ? structuredClone(snapshot) : null;
36
+ },
37
+ async set(snapshot) {
38
+ snapshots.set(key(snapshot.projectId, snapshot.filePath), structuredClone(snapshot));
39
+ },
40
+ async delete(projectId, filePath) {
41
+ snapshots.delete(key(projectId, filePath));
42
+ },
43
+ };
44
+ }
45
+
46
+ function openDb(): Promise<IDBDatabase> {
47
+ return new Promise((resolve, reject) => {
48
+ if (!globalThis.indexedDB) {
49
+ reject(new Error("IndexedDB is unavailable; conflict recovery snapshot was not saved"));
50
+ return;
51
+ }
52
+ const request = globalThis.indexedDB.open(DB_NAME, DB_VERSION);
53
+ request.onupgradeneeded = () => {
54
+ if (!request.result.objectStoreNames.contains(STORE_NAME)) {
55
+ request.result.createObjectStore(STORE_NAME);
56
+ }
57
+ };
58
+ request.onerror = () => reject(request.error ?? new Error("Failed to open conflict storage"));
59
+ request.onsuccess = () => resolve(request.result);
60
+ });
61
+ }
62
+
63
+ function withStore<T>(
64
+ mode: IDBTransactionMode,
65
+ callback: (store: IDBObjectStore) => IDBRequest<T>,
66
+ ): Promise<T> {
67
+ return openDb().then(
68
+ (db) =>
69
+ new Promise<T>((resolve, reject) => {
70
+ const transaction = db.transaction(STORE_NAME, mode);
71
+ const request = callback(transaction.objectStore(STORE_NAME));
72
+ let result!: T;
73
+ request.onerror = () => reject(request.error ?? new Error("Conflict storage failed"));
74
+ request.onsuccess = () => {
75
+ result = request.result;
76
+ };
77
+ transaction.oncomplete = () => {
78
+ db.close();
79
+ resolve(result);
80
+ };
81
+ transaction.onerror = () => {
82
+ db.close();
83
+ reject(transaction.error ?? new Error("Conflict storage transaction failed"));
84
+ };
85
+ transaction.onabort = () => {
86
+ db.close();
87
+ reject(transaction.error ?? new Error("Conflict storage transaction was aborted"));
88
+ };
89
+ }),
90
+ );
91
+ }
92
+
93
+ function createIndexedDbExternalConflictStorage(): ExternalConflictStorage {
94
+ return {
95
+ async get(projectId, filePath) {
96
+ return (
97
+ (await withStore<ExternalConflictSnapshot | undefined>("readonly", (store) =>
98
+ store.get(key(projectId, filePath)),
99
+ )) ?? null
100
+ );
101
+ },
102
+ async set(snapshot) {
103
+ await withStore<IDBValidKey>("readwrite", (store) =>
104
+ store.put(snapshot, key(snapshot.projectId, snapshot.filePath)),
105
+ );
106
+ },
107
+ async delete(projectId, filePath) {
108
+ await withStore<undefined>("readwrite", (store) => store.delete(key(projectId, filePath)));
109
+ },
110
+ };
111
+ }
112
+
113
+ const indexedDbStorage = createIndexedDbExternalConflictStorage();
114
+
115
+ /**
116
+ * Persist a conflict before offering destructive recovery actions.
117
+ * Callers must await this promise: rejection means the Studio draft was not saved durably and
118
+ * must remain available in memory while the storage failure is surfaced to the user.
119
+ */
120
+ export async function persistExternalConflictSnapshot(
121
+ projectId: string,
122
+ conflict: StudioFileConflictError,
123
+ ): Promise<void> {
124
+ await indexedDbStorage.set({
125
+ kind: "conflict",
126
+ projectId,
127
+ filePath: conflict.filePath,
128
+ externalVersion: conflict.currentVersion,
129
+ externalContent: conflict.currentContent,
130
+ studioContent: conflict.attemptedContent,
131
+ createdAt: Date.now(),
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Persist the final Studio candidate after its file write fails.
137
+ * Callers must await this promise: rejection means the Studio draft was not saved durably and
138
+ * must remain available in memory while the storage failure is surfaced to the user.
139
+ */
140
+ export async function persistExternalFailureSnapshot(
141
+ projectId: string,
142
+ filePath: string,
143
+ studioContent: string,
144
+ externalVersion: string | null,
145
+ externalContent: string | null,
146
+ error: unknown,
147
+ ): Promise<void> {
148
+ await indexedDbStorage.set({
149
+ kind: "failed",
150
+ projectId,
151
+ filePath,
152
+ externalVersion,
153
+ externalContent,
154
+ studioContent,
155
+ failureMessage: error instanceof Error ? error.message : String(error),
156
+ createdAt: Date.now(),
157
+ });
158
+ }
159
+
160
+ export async function loadExternalConflictSnapshot(
161
+ projectId: string,
162
+ filePath: string,
163
+ ): Promise<ExternalConflictSnapshot | null> {
164
+ return indexedDbStorage.get(projectId, filePath);
165
+ }
166
+
167
+ export async function deleteExternalConflictSnapshot(
168
+ projectId: string,
169
+ filePath: string,
170
+ ): Promise<void> {
171
+ await indexedDbStorage.delete(projectId, filePath);
172
+ }