@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.
- package/dist/assets/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CDTN2ZVa.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +20 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7055 -5321
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +9 -3
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +24 -5
- package/src/player/components/Timeline.tsx +40 -40
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +15 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineLanes.test.tsx +64 -6
- package/src/player/components/TimelineLanes.tsx +81 -64
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineDiamondTypes.ts +14 -7
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRowVirtualization.ts +16 -26
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +29 -22
- package/src/player/store/playerStore.ts +24 -29
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-DIntkrQl.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
const rowKey =
|
|
44
|
-
|
|
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
|
-
|
|
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 [
|
|
58
|
+
const [domFocusedRowKey, setDomFocusedRowKey] = useState<number>();
|
|
66
59
|
const onTimelineFocus = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
|
|
67
|
-
|
|
60
|
+
setDomFocusedRowKey(getFocusedTimelineRowKey(event.target));
|
|
68
61
|
}, []);
|
|
69
62
|
const onTimelineBlur = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
|
|
70
|
-
|
|
63
|
+
setDomFocusedRowKey(getFocusedTimelineRowKey(event.relatedTarget));
|
|
71
64
|
}, []);
|
|
72
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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("
|
|
496
|
-
it("
|
|
497
|
-
|
|
498
|
-
usePlayerStore.getState().
|
|
499
|
-
expect(usePlayerStore.getState().
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
usePlayerStore.getState().
|
|
512
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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 {
|
|
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<
|
|
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)
|
|
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
|
+
}
|