@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.
- package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.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-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- 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/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- 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 +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- 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/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- 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/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- 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/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- 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/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- 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/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -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/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -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/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- 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 =
|
|
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 {
|
|
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
|
|
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,
|