@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
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|