@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,131 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
|
|
6
|
+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
type WriteProjectFile = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
async function mountEditorSave(writeProjectFile: WriteProjectFile) {
|
|
13
|
+
const captured: { handle: EditorSaveHandle | null } = { handle: null };
|
|
14
|
+
|
|
15
|
+
function Probe() {
|
|
16
|
+
captured.handle = useEditorSave({
|
|
17
|
+
editingPathRef: { current: "index.html" },
|
|
18
|
+
projectIdRef: { current: "project-a" },
|
|
19
|
+
readProjectFile: vi.fn(async () => "before"),
|
|
20
|
+
writeProjectFile,
|
|
21
|
+
recordEdit: vi.fn(async () => undefined),
|
|
22
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
23
|
+
setRefreshKey: vi.fn(),
|
|
24
|
+
showToast: vi.fn(),
|
|
25
|
+
});
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const root = createRoot(document.createElement("div"));
|
|
30
|
+
await act(async () => root.render(<Probe />));
|
|
31
|
+
if (!captured.handle) throw new Error("Editor save handle was not mounted");
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
handle: captured.handle,
|
|
35
|
+
unmount: () => act(async () => root.unmount()),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("useEditorSave pending work", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.stubGlobal(
|
|
42
|
+
"requestAnimationFrame",
|
|
43
|
+
vi.fn(() => 41),
|
|
44
|
+
);
|
|
45
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
49
|
+
|
|
50
|
+
it("exposes and flushes the latest rAF-buffered source candidate", async () => {
|
|
51
|
+
const writeProjectFile = vi.fn(async () => undefined);
|
|
52
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
53
|
+
act(() => mounted.handle.handleContentChange("studio candidate"));
|
|
54
|
+
|
|
55
|
+
expect(mounted.handle.getPendingCandidate()).toEqual({
|
|
56
|
+
projectId: "project-a",
|
|
57
|
+
path: "index.html",
|
|
58
|
+
content: "studio candidate",
|
|
59
|
+
});
|
|
60
|
+
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({ status: "clean" });
|
|
61
|
+
expect(writeProjectFile).toHaveBeenCalledWith("index.html", "studio candidate", "before");
|
|
62
|
+
|
|
63
|
+
await mounted.unmount();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("joins an in-flight source save instead of writing the frozen candidate twice", async () => {
|
|
67
|
+
let frame: FrameRequestCallback | null = null;
|
|
68
|
+
vi.stubGlobal(
|
|
69
|
+
"requestAnimationFrame",
|
|
70
|
+
vi.fn((callback: FrameRequestCallback) => {
|
|
71
|
+
frame = callback;
|
|
72
|
+
return 42;
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
let finishWrite!: () => void;
|
|
76
|
+
const writeProjectFile = vi
|
|
77
|
+
.fn()
|
|
78
|
+
.mockImplementationOnce(
|
|
79
|
+
() =>
|
|
80
|
+
new Promise<void>((resolve) => {
|
|
81
|
+
finishWrite = resolve;
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
.mockResolvedValue(undefined);
|
|
85
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
86
|
+
act(() => mounted.handle.handleContentChange("candidate"));
|
|
87
|
+
act(() => frame?.(0));
|
|
88
|
+
await vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledOnce());
|
|
89
|
+
|
|
90
|
+
const drained = mounted.handle.flushPendingSave();
|
|
91
|
+
expect(writeProjectFile).toHaveBeenCalledOnce();
|
|
92
|
+
finishWrite();
|
|
93
|
+
await expect(drained).resolves.toEqual({ status: "clean" });
|
|
94
|
+
expect(writeProjectFile).toHaveBeenCalledOnce();
|
|
95
|
+
await mounted.unmount();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("preserves conflict details when flushing a buffered source candidate", async () => {
|
|
99
|
+
const conflict = new StudioFileConflictError({
|
|
100
|
+
filePath: "index.html",
|
|
101
|
+
currentVersion: "external-v2",
|
|
102
|
+
currentContent: "external",
|
|
103
|
+
attemptedContent: "studio candidate",
|
|
104
|
+
});
|
|
105
|
+
const mounted = await mountEditorSave(async () => {
|
|
106
|
+
throw conflict;
|
|
107
|
+
});
|
|
108
|
+
act(() => mounted.handle.handleContentChange("studio candidate"));
|
|
109
|
+
|
|
110
|
+
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({
|
|
111
|
+
status: "conflict",
|
|
112
|
+
error: conflict,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await mounted.unmount();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("discards an rAF-buffered candidate without persisting it", async () => {
|
|
119
|
+
const writeProjectFile = vi.fn(async () => undefined);
|
|
120
|
+
const mounted = await mountEditorSave(writeProjectFile);
|
|
121
|
+
act(() => mounted.handle.handleContentChange("discard me"));
|
|
122
|
+
act(() => mounted.handle.discardPendingSave());
|
|
123
|
+
|
|
124
|
+
expect(mounted.handle.getPendingCandidate()).toBeNull();
|
|
125
|
+
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({ status: "clean" });
|
|
126
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
127
|
+
expect(cancelAnimationFrame).toHaveBeenCalledWith(41);
|
|
128
|
+
|
|
129
|
+
await mounted.unmount();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -2,6 +2,10 @@ import { useCallback, useRef } from "react";
|
|
|
2
2
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
3
3
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
4
4
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
5
|
+
import {
|
|
6
|
+
StudioFileConflictError,
|
|
7
|
+
type StudioSaveDrainResult,
|
|
8
|
+
} from "../utils/studioSaveDiagnostics";
|
|
5
9
|
|
|
6
10
|
interface RecordEditInput {
|
|
7
11
|
label: string;
|
|
@@ -21,6 +25,25 @@ interface UseEditorSaveOptions {
|
|
|
21
25
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
export interface EditorSaveCandidate {
|
|
29
|
+
projectId: string;
|
|
30
|
+
path: string;
|
|
31
|
+
content: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type EditorSaveDrainResult = StudioSaveDrainResult;
|
|
35
|
+
|
|
36
|
+
export interface EditorSaveHandle {
|
|
37
|
+
saveRafRef: React.MutableRefObject<number | null>;
|
|
38
|
+
handleContentChange: (content: string) => void;
|
|
39
|
+
/** Read by the external-reload reconciliation introduced in stack PR #2993. */
|
|
40
|
+
getPendingCandidate: () => EditorSaveCandidate | null;
|
|
41
|
+
/** Wired into the external-reload drain by stack PR #2993. */
|
|
42
|
+
flushPendingSave: () => Promise<EditorSaveDrainResult>;
|
|
43
|
+
/** Used by PR #2993 when the external version wins. */
|
|
44
|
+
discardPendingSave: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
export function useEditorSave({
|
|
25
48
|
editingPathRef,
|
|
26
49
|
projectIdRef,
|
|
@@ -30,12 +53,70 @@ export function useEditorSave({
|
|
|
30
53
|
domEditSaveTimestampRef,
|
|
31
54
|
setRefreshKey,
|
|
32
55
|
showToast,
|
|
33
|
-
}: UseEditorSaveOptions) {
|
|
56
|
+
}: UseEditorSaveOptions): EditorSaveHandle {
|
|
34
57
|
const saveRafRef = useRef<number | null>(null);
|
|
35
58
|
const refreshRafRef = useRef<number | null>(null);
|
|
36
59
|
// One error toast per burst of failures — every keystroke retries the save,
|
|
37
60
|
// and error toasts persist until dismissed, so don't stack duplicates.
|
|
38
61
|
const lastFailureToastAtRef = useRef(0);
|
|
62
|
+
const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
|
|
63
|
+
const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
|
|
64
|
+
const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);
|
|
65
|
+
|
|
66
|
+
const reportFailure = useCallback(
|
|
67
|
+
(path: string, error: unknown) => {
|
|
68
|
+
trackStudioEvent("save_failure", {
|
|
69
|
+
source: "code_editor",
|
|
70
|
+
error_message: error instanceof Error ? error.message : "unknown",
|
|
71
|
+
});
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
if (now - lastFailureToastAtRef.current > 5000) {
|
|
74
|
+
lastFailureToastAtRef.current = now;
|
|
75
|
+
showToast(
|
|
76
|
+
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
|
|
77
|
+
"error",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[showToast],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const persistCandidate = useCallback(
|
|
85
|
+
(candidate: EditorSaveCandidate): Promise<EditorSaveDrainResult> => {
|
|
86
|
+
const task = saveProjectFilesWithHistory({
|
|
87
|
+
projectId: candidate.projectId,
|
|
88
|
+
label: "Edit source",
|
|
89
|
+
kind: "source",
|
|
90
|
+
coalesceKey: `source:${candidate.path}`,
|
|
91
|
+
files: { [candidate.path]: candidate.content },
|
|
92
|
+
readFile: readProjectFile,
|
|
93
|
+
writeFile: writeProjectFile,
|
|
94
|
+
recordEdit,
|
|
95
|
+
})
|
|
96
|
+
.then<EditorSaveDrainResult>(() => {
|
|
97
|
+
if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
|
|
98
|
+
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
|
99
|
+
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
|
100
|
+
return { status: "clean" };
|
|
101
|
+
})
|
|
102
|
+
.catch<EditorSaveDrainResult>((error: unknown) => {
|
|
103
|
+
reportFailure(candidate.path, error);
|
|
104
|
+
return error instanceof StudioFileConflictError
|
|
105
|
+
? { status: "conflict", error }
|
|
106
|
+
: { status: "failed", error };
|
|
107
|
+
})
|
|
108
|
+
.finally(() => {
|
|
109
|
+
if (inFlightRef.current === task) {
|
|
110
|
+
inFlightRef.current = null;
|
|
111
|
+
inFlightCandidateRef.current = null;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
inFlightRef.current = task;
|
|
115
|
+
inFlightCandidateRef.current = candidate;
|
|
116
|
+
return task;
|
|
117
|
+
},
|
|
118
|
+
[readProjectFile, recordEdit, reportFailure, setRefreshKey, writeProjectFile],
|
|
119
|
+
);
|
|
39
120
|
|
|
40
121
|
const handleContentChange = useCallback(
|
|
41
122
|
(content: string) => {
|
|
@@ -44,53 +125,46 @@ export function useEditorSave({
|
|
|
44
125
|
const path = editingPathRef.current;
|
|
45
126
|
if (!path) return;
|
|
46
127
|
|
|
128
|
+
const candidate = { projectId: pid, path, content };
|
|
129
|
+
pendingCandidateRef.current = candidate;
|
|
130
|
+
|
|
47
131
|
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
|
|
48
132
|
saveRafRef.current = requestAnimationFrame(() => {
|
|
133
|
+
saveRafRef.current = null;
|
|
49
134
|
domEditSaveTimestampRef.current = Date.now();
|
|
50
|
-
|
|
51
|
-
projectId: pid,
|
|
52
|
-
label: "Edit source",
|
|
53
|
-
kind: "source",
|
|
54
|
-
coalesceKey: `source:${path}`,
|
|
55
|
-
files: { [path]: content },
|
|
56
|
-
readFile: readProjectFile,
|
|
57
|
-
writeFile: writeProjectFile,
|
|
58
|
-
recordEdit,
|
|
59
|
-
})
|
|
60
|
-
.then(() => {
|
|
61
|
-
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
|
|
62
|
-
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
|
|
63
|
-
})
|
|
64
|
-
.catch((error) => {
|
|
65
|
-
trackStudioEvent("save_failure", {
|
|
66
|
-
source: "code_editor",
|
|
67
|
-
error_message: error instanceof Error ? error.message : "unknown",
|
|
68
|
-
});
|
|
69
|
-
const now = Date.now();
|
|
70
|
-
if (now - lastFailureToastAtRef.current > 5000) {
|
|
71
|
-
lastFailureToastAtRef.current = now;
|
|
72
|
-
showToast(
|
|
73
|
-
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
|
|
74
|
-
"error",
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
135
|
+
void persistCandidate(candidate);
|
|
78
136
|
});
|
|
79
137
|
},
|
|
80
|
-
[
|
|
81
|
-
domEditSaveTimestampRef,
|
|
82
|
-
editingPathRef,
|
|
83
|
-
projectIdRef,
|
|
84
|
-
readProjectFile,
|
|
85
|
-
recordEdit,
|
|
86
|
-
setRefreshKey,
|
|
87
|
-
showToast,
|
|
88
|
-
writeProjectFile,
|
|
89
|
-
],
|
|
138
|
+
[domEditSaveTimestampRef, editingPathRef, projectIdRef, persistCandidate],
|
|
90
139
|
);
|
|
91
140
|
|
|
141
|
+
const flushPendingSave = useCallback(async (): Promise<EditorSaveDrainResult> => {
|
|
142
|
+
if (saveRafRef.current != null) {
|
|
143
|
+
cancelAnimationFrame(saveRafRef.current);
|
|
144
|
+
saveRafRef.current = null;
|
|
145
|
+
}
|
|
146
|
+
const candidate = pendingCandidateRef.current;
|
|
147
|
+
if (candidate && candidate === inFlightCandidateRef.current && inFlightRef.current) {
|
|
148
|
+
return inFlightRef.current;
|
|
149
|
+
}
|
|
150
|
+
if (candidate) {
|
|
151
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
152
|
+
return persistCandidate(candidate);
|
|
153
|
+
}
|
|
154
|
+
return (await inFlightRef.current) ?? { status: "clean" };
|
|
155
|
+
}, [domEditSaveTimestampRef, persistCandidate]);
|
|
156
|
+
|
|
157
|
+
const discardPendingSave = useCallback(() => {
|
|
158
|
+
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
|
|
159
|
+
saveRafRef.current = null;
|
|
160
|
+
pendingCandidateRef.current = null;
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
92
163
|
return {
|
|
93
164
|
saveRafRef,
|
|
94
165
|
handleContentChange,
|
|
166
|
+
getPendingCandidate: () => pendingCandidateRef.current,
|
|
167
|
+
flushPendingSave,
|
|
168
|
+
discardPendingSave,
|
|
95
169
|
};
|
|
96
170
|
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
6
|
+
import { markStudioWriteToken, resetStudioWriteTokens } from "../utils/studioFileVersion";
|
|
7
|
+
import {
|
|
8
|
+
useExternalFileChangeCoordinator,
|
|
9
|
+
type ExternalFileChangeCoordinatorHandle,
|
|
10
|
+
} from "./useExternalFileChangeCoordinator";
|
|
11
|
+
|
|
12
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
type HotHandler = (payload?: unknown) => void;
|
|
15
|
+
type CoordinatorOptions = Parameters<typeof useExternalFileChangeCoordinator>[0];
|
|
16
|
+
const roots: Array<ReturnType<typeof createRoot>> = [];
|
|
17
|
+
let handler: HotHandler | null;
|
|
18
|
+
|
|
19
|
+
async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
|
|
20
|
+
const captured: { handle: ExternalFileChangeCoordinatorHandle | null } = { handle: null };
|
|
21
|
+
const defaults: CoordinatorOptions = {
|
|
22
|
+
projectId: "project-a",
|
|
23
|
+
activeCompPath: "index.html",
|
|
24
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
25
|
+
drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })),
|
|
26
|
+
reloadPreview: vi.fn(),
|
|
27
|
+
reloadSdkSession: vi.fn(),
|
|
28
|
+
persistConflictSnapshot: vi.fn(async () => undefined),
|
|
29
|
+
discardPendingChanges: vi.fn(),
|
|
30
|
+
overwriteConflict: vi.fn(async () => undefined),
|
|
31
|
+
readProjectFile: vi.fn(async () => "external"),
|
|
32
|
+
};
|
|
33
|
+
const options = { ...defaults, ...overrides };
|
|
34
|
+
const root = createRoot(document.createElement("div"));
|
|
35
|
+
roots.push(root);
|
|
36
|
+
function Probe() {
|
|
37
|
+
captured.handle = useExternalFileChangeCoordinator(options);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
await act(async () => root.render(<Probe />));
|
|
41
|
+
return { captured, options };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("external file change coordinator", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
handler = null;
|
|
47
|
+
resetStudioWriteTokens();
|
|
48
|
+
vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", {
|
|
49
|
+
on: (_event: string, next: HotHandler) => {
|
|
50
|
+
handler = next;
|
|
51
|
+
},
|
|
52
|
+
off: () => {
|
|
53
|
+
handler = null;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
while (roots.length > 0) await act(async () => roots.pop()?.unmount());
|
|
60
|
+
vi.unstubAllGlobals();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("drains before reloading Preview and SDK exactly once", async () => {
|
|
64
|
+
const order: string[] = [];
|
|
65
|
+
const { captured } = await mountCoordinator({
|
|
66
|
+
drainPendingChanges: async () => {
|
|
67
|
+
order.push("drain");
|
|
68
|
+
return { status: "clean" };
|
|
69
|
+
},
|
|
70
|
+
reloadPreview: () => order.push("preview"),
|
|
71
|
+
reloadSdkSession: () => order.push("sdk"),
|
|
72
|
+
});
|
|
73
|
+
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
|
74
|
+
expect(order).toEqual(["drain", "preview", "sdk"]);
|
|
75
|
+
expect(captured.handle?.blocked).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("suppresses an exact Studio write receipt", async () => {
|
|
79
|
+
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
|
|
80
|
+
const reloadPreview = vi.fn();
|
|
81
|
+
const reloadSdkSession = vi.fn();
|
|
82
|
+
await mountCoordinator({ drainPendingChanges, reloadPreview, reloadSdkSession });
|
|
83
|
+
markStudioWriteToken("studio-write-1");
|
|
84
|
+
await act(async () =>
|
|
85
|
+
handler?.({ path: "index.html", content: "studio", writeToken: "studio-write-1" }),
|
|
86
|
+
);
|
|
87
|
+
expect(drainPendingChanges).not.toHaveBeenCalled();
|
|
88
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
89
|
+
expect(reloadSdkSession).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not suppress a racing external write by path alone", async () => {
|
|
93
|
+
const pendingTimelineEditPathRef = { current: new Set(["index.html"]) };
|
|
94
|
+
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
|
|
95
|
+
const reloadPreview = vi.fn();
|
|
96
|
+
const reloadSdkSession = vi.fn();
|
|
97
|
+
await mountCoordinator({
|
|
98
|
+
pendingTimelineEditPathRef,
|
|
99
|
+
drainPendingChanges,
|
|
100
|
+
reloadPreview,
|
|
101
|
+
reloadSdkSession,
|
|
102
|
+
});
|
|
103
|
+
await act(async () => handler?.({ path: "index.html", content: "agent edit", version: "v2" }));
|
|
104
|
+
expect(pendingTimelineEditPathRef.current).not.toContain("index.html");
|
|
105
|
+
expect(drainPendingChanges).toHaveBeenCalledOnce();
|
|
106
|
+
expect(reloadPreview).toHaveBeenCalledOnce();
|
|
107
|
+
expect(reloadSdkSession).toHaveBeenCalledOnce();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("blocks both reloads and retains a complete conflict", async () => {
|
|
111
|
+
const conflict = new StudioFileConflictError({
|
|
112
|
+
filePath: "index.html",
|
|
113
|
+
currentVersion: "v2",
|
|
114
|
+
currentContent: "external",
|
|
115
|
+
attemptedContent: "studio",
|
|
116
|
+
});
|
|
117
|
+
const persistConflictSnapshot = vi.fn(async () => undefined);
|
|
118
|
+
const { captured, options } = await mountCoordinator({
|
|
119
|
+
drainPendingChanges: async () => ({ status: "conflict", error: conflict }),
|
|
120
|
+
persistConflictSnapshot,
|
|
121
|
+
});
|
|
122
|
+
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
|
123
|
+
expect(persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict);
|
|
124
|
+
expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
|
|
125
|
+
expect(options.reloadPreview).not.toHaveBeenCalled();
|
|
126
|
+
expect(options.reloadSdkSession).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("ignores stale drain completion after a newer generation", async () => {
|
|
130
|
+
const drains: Array<(result: { status: "clean" }) => void> = [];
|
|
131
|
+
const { options } = await mountCoordinator({
|
|
132
|
+
drainPendingChanges: () => new Promise((resolve) => drains.push(resolve)),
|
|
133
|
+
});
|
|
134
|
+
act(() => {
|
|
135
|
+
handler?.({ path: "index.html", content: "first", version: "v2" });
|
|
136
|
+
handler?.({ path: "index.html", content: "second", version: "v3" });
|
|
137
|
+
});
|
|
138
|
+
await act(async () => drains[0]?.({ status: "clean" }));
|
|
139
|
+
expect(options.reloadPreview).not.toHaveBeenCalled();
|
|
140
|
+
await act(async () => drains[1]?.({ status: "clean" }));
|
|
141
|
+
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
|
142
|
+
expect(options.reloadSdkSession).toHaveBeenCalledOnce();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("restores a durable unresolved conflict after remount", async () => {
|
|
146
|
+
const { captured } = await mountCoordinator({
|
|
147
|
+
recoveryFilePath: "index.html",
|
|
148
|
+
loadConflictSnapshot: vi.fn(async () => ({
|
|
149
|
+
kind: "conflict" as const,
|
|
150
|
+
projectId: "project-a",
|
|
151
|
+
filePath: "index.html",
|
|
152
|
+
externalVersion: "v2",
|
|
153
|
+
externalContent: "external",
|
|
154
|
+
studioContent: "studio",
|
|
155
|
+
createdAt: 100,
|
|
156
|
+
})),
|
|
157
|
+
});
|
|
158
|
+
await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("conflict"));
|
|
159
|
+
expect(captured.handle?.blocked).toMatchObject({
|
|
160
|
+
error: { currentContent: "external", attemptedContent: "studio" },
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("retains the final local candidate when a drain fails", async () => {
|
|
165
|
+
const failure = new Error("network unavailable");
|
|
166
|
+
const persistFailureSnapshot = vi.fn(async () => undefined);
|
|
167
|
+
const deleteConflictSnapshot = vi.fn(async () => undefined);
|
|
168
|
+
const { captured } = await mountCoordinator({
|
|
169
|
+
drainPendingChanges: vi
|
|
170
|
+
.fn()
|
|
171
|
+
.mockResolvedValueOnce({ status: "failed" as const, error: failure })
|
|
172
|
+
.mockResolvedValueOnce({ status: "clean" as const }),
|
|
173
|
+
getPendingCandidate: () => ({ path: "index.html", content: "final local candidate" }),
|
|
174
|
+
persistFailureSnapshot,
|
|
175
|
+
deleteConflictSnapshot,
|
|
176
|
+
});
|
|
177
|
+
await act(async () => handler?.({ path: "index.html" }));
|
|
178
|
+
expect(captured.handle?.blocked).toMatchObject({
|
|
179
|
+
status: "failed",
|
|
180
|
+
error: failure,
|
|
181
|
+
studioContent: "final local candidate",
|
|
182
|
+
});
|
|
183
|
+
expect(persistFailureSnapshot).toHaveBeenCalledWith(
|
|
184
|
+
"project-a",
|
|
185
|
+
"index.html",
|
|
186
|
+
"final local candidate",
|
|
187
|
+
null,
|
|
188
|
+
null,
|
|
189
|
+
failure,
|
|
190
|
+
);
|
|
191
|
+
await act(async () => captured.handle?.retry());
|
|
192
|
+
expect(deleteConflictSnapshot).toHaveBeenCalledWith("project-a", "index.html");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("restores and overwrites from a durable failed draft", async () => {
|
|
196
|
+
const overwriteConflict = vi.fn(async () => undefined);
|
|
197
|
+
const { captured } = await mountCoordinator({
|
|
198
|
+
recoveryFilePath: "index.html",
|
|
199
|
+
overwriteConflict,
|
|
200
|
+
loadConflictSnapshot: vi.fn(async () => ({
|
|
201
|
+
kind: "failed" as const,
|
|
202
|
+
projectId: "project-a",
|
|
203
|
+
filePath: "index.html",
|
|
204
|
+
externalVersion: "v2",
|
|
205
|
+
externalContent: "external",
|
|
206
|
+
studioContent: "recover me",
|
|
207
|
+
failureMessage: "network unavailable",
|
|
208
|
+
createdAt: 100,
|
|
209
|
+
})),
|
|
210
|
+
});
|
|
211
|
+
await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("failed"));
|
|
212
|
+
expect(captured.handle?.blocked).toMatchObject({
|
|
213
|
+
studioContent: "recover me",
|
|
214
|
+
recovered: true,
|
|
215
|
+
});
|
|
216
|
+
await act(async () => captured.handle?.keepStudioFile());
|
|
217
|
+
expect(overwriteConflict).toHaveBeenCalledWith(
|
|
218
|
+
expect.objectContaining({ attemptedContent: "recover me", currentVersion: "v2" }),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
});
|