@hyperframes/studio 0.8.56 → 0.8.57
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-c23wnIfc.js +464 -0
- package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-DFlILHJe.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-CxR7F2YQ.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-BlkJ_Axe.js → index-gbOCDCuZ.js} +194 -194
- package/dist/index.d.ts +95 -9
- package/dist/index.html +2 -2
- package/dist/index.js +15573 -14835
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -0
- package/src/components/EditorShell.tsx +3 -1
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +0 -15
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/SnapToolbar.test.tsx +25 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.tsx +4 -16
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/previewReadOnly.test.tsx +1 -0
- package/src/hooks/useCompositionDimensions.test.tsx +38 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -572
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
|
@@ -3,6 +3,7 @@ import { act } from "react";
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { expect, it } from "vitest";
|
|
5
5
|
import { useCompositionDimensions } from "./useCompositionDimensions";
|
|
6
|
+
import { usePreviewIframeStore } from "../player/store/previewIframeStore";
|
|
6
7
|
|
|
7
8
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
8
9
|
|
|
@@ -45,3 +46,40 @@ it("accepts only the current preview frame and rejects foreign or missing sender
|
|
|
45
46
|
foreign.remove();
|
|
46
47
|
}
|
|
47
48
|
});
|
|
49
|
+
|
|
50
|
+
it("re-reads the size from the promoted iframe's document", () => {
|
|
51
|
+
const host = document.createElement("div");
|
|
52
|
+
const a = document.createElement("iframe");
|
|
53
|
+
const b = document.createElement("iframe");
|
|
54
|
+
document.body.append(host, a, b);
|
|
55
|
+
const ref = { current: a };
|
|
56
|
+
const root = createRoot(host);
|
|
57
|
+
const setRoot = (frame: HTMLIFrameElement, width: number, height: number) => {
|
|
58
|
+
const el = frame.contentDocument!.createElement("div");
|
|
59
|
+
el.setAttribute("data-composition-id", "main");
|
|
60
|
+
el.setAttribute("data-width", String(width));
|
|
61
|
+
el.setAttribute("data-height", String(height));
|
|
62
|
+
frame.contentDocument!.body.append(el);
|
|
63
|
+
};
|
|
64
|
+
setRoot(a, 1920, 1080);
|
|
65
|
+
setRoot(b, 1080, 1920);
|
|
66
|
+
function Harness() {
|
|
67
|
+
return JSON.stringify(useCompositionDimensions(ref));
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
act(() => usePreviewIframeStore.getState().setIframe(a));
|
|
71
|
+
act(() => root.render(<Harness />));
|
|
72
|
+
expect(host.textContent).toBe('{"width":1920,"height":1080}');
|
|
73
|
+
act(() => {
|
|
74
|
+
ref.current = b;
|
|
75
|
+
usePreviewIframeStore.getState().setIframe(b);
|
|
76
|
+
});
|
|
77
|
+
expect(host.textContent).toBe('{"width":1080,"height":1920}');
|
|
78
|
+
} finally {
|
|
79
|
+
act(() => root.unmount());
|
|
80
|
+
usePreviewIframeStore.setState({ iframe: null });
|
|
81
|
+
host.remove();
|
|
82
|
+
a.remove();
|
|
83
|
+
b.remove();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { useState, type RefObject } from "react";
|
|
1
|
+
import { useEffect, useState, type RefObject } from "react";
|
|
2
|
+
import { useLivePreviewIframe } from "../player/store/previewIframeStore";
|
|
3
|
+
import { readPreviewCompositionSize } from "../utils/previewCompositionSize";
|
|
2
4
|
import { useMountEffect } from "./useMountEffect";
|
|
3
5
|
import type { CompositionDimensions } from "../components/renders/RenderQueue";
|
|
4
6
|
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
|
|
@@ -34,6 +36,20 @@ export function useCompositionDimensions(iframeRef: RefObject<HTMLIFrameElement
|
|
|
34
36
|
null,
|
|
35
37
|
);
|
|
36
38
|
|
|
39
|
+
const livePreviewIframe = useLivePreviewIframe();
|
|
40
|
+
|
|
41
|
+
// The runtime posts stage-size once per document, so a promoted reload's message was sent
|
|
42
|
+
// before this listener could accept it. The size is authored on the document: read it there.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const dimensions = readPreviewCompositionSize(livePreviewIframe);
|
|
45
|
+
if (!dimensions) return;
|
|
46
|
+
setCompositionDimensions((prev) =>
|
|
47
|
+
prev && prev.width === dimensions.width && prev.height === dimensions.height
|
|
48
|
+
? prev
|
|
49
|
+
: dimensions,
|
|
50
|
+
);
|
|
51
|
+
}, [livePreviewIframe]);
|
|
52
|
+
|
|
37
53
|
useMountEffect(() => {
|
|
38
54
|
const handleMessage = (e: MessageEvent) => {
|
|
39
55
|
if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveOptimisticAttributeValue } from "./useDomEditAttributeCommits";
|
|
3
|
+
|
|
4
|
+
describe("resolveOptimisticAttributeValue", () => {
|
|
5
|
+
it("removes an HTML boolean attribute when the value is the literal string false", () => {
|
|
6
|
+
expect(resolveOptimisticAttributeValue("loop", "false")).toBeNull();
|
|
7
|
+
expect(resolveOptimisticAttributeValue("muted", "false")).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("keeps the literal string false for a non-boolean attribute, matching what persist() writes", () => {
|
|
11
|
+
expect(resolveOptimisticAttributeValue("data-example", "false")).toBe("false");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("always removes on null regardless of attribute", () => {
|
|
15
|
+
expect(resolveOptimisticAttributeValue("loop", null)).toBeNull();
|
|
16
|
+
expect(resolveOptimisticAttributeValue("data-example", null)).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("passes any other value through unchanged", () => {
|
|
20
|
+
expect(resolveOptimisticAttributeValue("data-example", "true")).toBe("true");
|
|
21
|
+
expect(resolveOptimisticAttributeValue("loop", "true")).toBe("true");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { HTML_BOOLEAN_ATTRIBUTES, type PatchOperation } from "../utils/sourcePatcher";
|
|
3
3
|
import {
|
|
4
4
|
findElementForSelection,
|
|
5
5
|
getDomEditTargetKey,
|
|
@@ -55,6 +55,14 @@ function resolveFullAttrName(attr: string, prefixData: boolean | undefined): str
|
|
|
55
55
|
return prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Matches sourcePatcher's own boolean handling: "false" means "remove" only
|
|
59
|
+
// for HTML_BOOLEAN_ATTRIBUTES (loop, muted, ...), so the live preview node
|
|
60
|
+
// ends up holding what persist() actually writes to disk.
|
|
61
|
+
export function resolveOptimisticAttributeValue(attr: string, value: string | null): string | null {
|
|
62
|
+
if (value === null) return null;
|
|
63
|
+
return value === "false" && HTML_BOOLEAN_ATTRIBUTES.has(attr) ? null : value;
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
function setOrRemovePreviewAttribute(
|
|
59
67
|
el: HTMLElement,
|
|
60
68
|
fullAttr: string,
|
|
@@ -393,8 +401,11 @@ export function useDomEditAttributeCommits({
|
|
|
393
401
|
},
|
|
394
402
|
apply: () => {
|
|
395
403
|
if (!editedElement) return;
|
|
396
|
-
|
|
397
|
-
|
|
404
|
+
setOrRemovePreviewAttribute(
|
|
405
|
+
editedElement,
|
|
406
|
+
attr,
|
|
407
|
+
resolveOptimisticAttributeValue(attr, value),
|
|
408
|
+
);
|
|
398
409
|
},
|
|
399
410
|
persist: () =>
|
|
400
411
|
persistDomEditOperations(domEditSelection, [op], {
|
|
@@ -5,6 +5,12 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { useFileTree } from "./useFileTree";
|
|
7
7
|
|
|
8
|
+
vi.mock("../components/feedback/projectProvenance", () => ({
|
|
9
|
+
captureProjectProvenance: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { captureProjectProvenance } from "../components/feedback/projectProvenance";
|
|
13
|
+
|
|
8
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
15
|
|
|
10
16
|
let root: Root | null = null;
|
|
@@ -52,6 +58,56 @@ async function renderHarness(projectId: string): Promise<{ current: Handle | nul
|
|
|
52
58
|
return handleRef;
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
describe("useFileTree unresolved project", () => {
|
|
62
|
+
const provenance = vi.mocked(captureProjectProvenance);
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
provenance.mockClear();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Render the tree for `projectId` against one canned response. */
|
|
69
|
+
async function renderWithResponse(
|
|
70
|
+
projectId: string,
|
|
71
|
+
build: () => Response,
|
|
72
|
+
): Promise<{ current: Handle | null }> {
|
|
73
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => build());
|
|
74
|
+
const handleRef = await renderHarness(projectId);
|
|
75
|
+
fetchSpy.mockRestore();
|
|
76
|
+
return handleRef;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const notFound = () =>
|
|
80
|
+
new Response(JSON.stringify({ error: "not found" }), {
|
|
81
|
+
status: 404,
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
});
|
|
84
|
+
const emptyListing = () =>
|
|
85
|
+
new Response(JSON.stringify({ files: [], compositions: [] }), { status: 200 });
|
|
86
|
+
|
|
87
|
+
// An unresolvable project answers 404 with a JSON body. Without an `r.ok`
|
|
88
|
+
// check that parsed cleanly and took the SUCCESS branch, recording an empty
|
|
89
|
+
// tree and an empty provenance snapshot for a project that was never read —
|
|
90
|
+
// so a bug report filed from that tab described a project that does not
|
|
91
|
+
// exist. The tree itself stays empty either way; the provenance lie is the
|
|
92
|
+
// part that leaves the browser.
|
|
93
|
+
it("does not snapshot provenance for a project the server could not resolve", async () => {
|
|
94
|
+
const handleRef = await renderWithResponse("gone-project", notFound);
|
|
95
|
+
|
|
96
|
+
expect(handleRef.current?.compositions).toEqual([]);
|
|
97
|
+
expect(provenance).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// The counterpart, and the reason the check above cannot be collapsed into
|
|
101
|
+
// "skip provenance whenever the tree is empty": a project with no
|
|
102
|
+
// compositions yet is a real, successful read and must still be snapshotted.
|
|
103
|
+
it("still snapshots provenance when a resolved project has no compositions", async () => {
|
|
104
|
+
const handleRef = await renderWithResponse("empty-project", emptyListing);
|
|
105
|
+
|
|
106
|
+
expect(handleRef.current?.compositions).toEqual([]);
|
|
107
|
+
expect(provenance).toHaveBeenCalledWith("empty-project", [], []);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
55
111
|
describe("useFileTree.refreshFileTree", () => {
|
|
56
112
|
it("updates compositions, not just the raw file list, on refresh", async () => {
|
|
57
113
|
let call = 0;
|
package/src/hooks/useFileTree.ts
CHANGED
|
@@ -39,7 +39,14 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
|
|
|
39
39
|
let cancelled = false;
|
|
40
40
|
setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
|
|
41
41
|
fetch(buildProjectApiPath(projectId))
|
|
42
|
-
|
|
42
|
+
// An unresolvable project answers 404 with a JSON body, so without this
|
|
43
|
+
// the error path parsed cleanly and the success branch below recorded an
|
|
44
|
+
// empty tree — and an empty provenance snapshot — for a project that was
|
|
45
|
+
// never read. Throwing hands it to the catch instead.
|
|
46
|
+
.then((r) => {
|
|
47
|
+
if (!r.ok) throw new Error(`tree fetch failed: ${r.status}`);
|
|
48
|
+
return r.json();
|
|
49
|
+
})
|
|
43
50
|
.then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
|
|
44
51
|
if (cancelled) return;
|
|
45
52
|
setFetched({
|
|
@@ -147,6 +147,86 @@ describe("useSdkSession ownership", () => {
|
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
describe("useSdkSession unreachable project", () => {
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
openComposition.mockReset();
|
|
153
|
+
trackMock.mockClear();
|
|
154
|
+
class FakeEventSource {
|
|
155
|
+
addEventListener(): void {}
|
|
156
|
+
close(): void {}
|
|
157
|
+
}
|
|
158
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
vi.unstubAllGlobals();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function probeHandle(projectId: string) {
|
|
166
|
+
const captured: { handle: SdkSessionHandle | null } = { handle: null };
|
|
167
|
+
function HandleProbe() {
|
|
168
|
+
captured.handle = useSdkSession(projectId, "index.html");
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return { captured, HandleProbe };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const failedRead = (status: number) => async () =>
|
|
175
|
+
({ ok: false, status, json: async () => ({}) }) as Response;
|
|
176
|
+
|
|
177
|
+
/** Render the hook for `projectId` against a stubbed read and let it settle. */
|
|
178
|
+
async function readOutcome(
|
|
179
|
+
projectId: string,
|
|
180
|
+
read: () => Promise<Response>,
|
|
181
|
+
): Promise<{ captured: { handle: SdkSessionHandle | null }; unmount: () => Promise<void> }> {
|
|
182
|
+
vi.stubGlobal("fetch", vi.fn(read));
|
|
183
|
+
const { captured, HandleProbe } = probeHandle(projectId);
|
|
184
|
+
const root = createRoot(document.createElement("div"));
|
|
185
|
+
await act(async () => root.render(<HandleProbe />));
|
|
186
|
+
await flushAsyncEffects();
|
|
187
|
+
return { captured, unmount: () => act(async () => root.unmount()) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A 404 on the composition read is the one failure that says something about
|
|
191
|
+
// the PROJECT rather than the request: under the CLI host it means this
|
|
192
|
+
// Studio serves a different one. Every edit then fails silently, so the UI
|
|
193
|
+
// needs the id to explain that.
|
|
194
|
+
it("names the project when the read is a 404", async () => {
|
|
195
|
+
const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
|
|
196
|
+
|
|
197
|
+
expect(captured.handle?.unreachableProject).toBe("gone-project");
|
|
198
|
+
await unmount();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// A 500 says the request failed, not that the project is elsewhere. Claiming
|
|
202
|
+
// otherwise would tell a user their tab is pointed at the wrong project when
|
|
203
|
+
// the server is merely unwell.
|
|
204
|
+
it("stays quiet on a failure that says nothing about the project", async () => {
|
|
205
|
+
const { captured, unmount } = await readOutcome("project-a", failedRead(500));
|
|
206
|
+
|
|
207
|
+
expect(captured.handle?.unreachableProject).toBeNull();
|
|
208
|
+
await unmount();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("clears the state once the project resolves", async () => {
|
|
212
|
+
const { captured, unmount } = await readOutcome("gone-project", failedRead(404));
|
|
213
|
+
expect(captured.handle?.unreachableProject).toBe("gone-project");
|
|
214
|
+
|
|
215
|
+
openComposition.mockResolvedValue(fakeSession());
|
|
216
|
+
vi.stubGlobal(
|
|
217
|
+
"fetch",
|
|
218
|
+
vi.fn(async () => response("PROJECT_A")),
|
|
219
|
+
);
|
|
220
|
+
await act(async () => {
|
|
221
|
+
captured.handle?.forceReload();
|
|
222
|
+
});
|
|
223
|
+
await flushAsyncEffects();
|
|
224
|
+
|
|
225
|
+
expect(captured.handle?.unreachableProject).toBeNull();
|
|
226
|
+
await unmount();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
150
230
|
describe("useSdkSession unavailable telemetry", () => {
|
|
151
231
|
beforeEach(() => {
|
|
152
232
|
openComposition.mockReset();
|
|
@@ -25,6 +25,34 @@ type ProjectFileReadFailure =
|
|
|
25
25
|
|
|
26
26
|
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Record a read that produced no usable content, and answer which project — if
|
|
30
|
+
* any — the failure identifies as unreachable.
|
|
31
|
+
*
|
|
32
|
+
* No SDK session follows a failed read, so EVERY cutover chokepoint takes the
|
|
33
|
+
* server path and emits nothing — the shadow never runs either. A broken read
|
|
34
|
+
* would otherwise be a silent, total SDK bypass, which is why this is recorded
|
|
35
|
+
* at all.
|
|
36
|
+
*
|
|
37
|
+
* Only a 404 identifies the *project*: the server answered, and its answer was
|
|
38
|
+
* that it does not serve this id. A 5xx, a dropped request, an unexpected body
|
|
39
|
+
* and an empty file all say nothing about which project the server serves, so
|
|
40
|
+
* none of them claim it. See `unreachableProject` on the handle.
|
|
41
|
+
*
|
|
42
|
+
* A function rather than three more conditions inline: the read callback it is
|
|
43
|
+
* called from is a long pre-existing async body already near the complexity
|
|
44
|
+
* threshold, and this branch is one coherent unit.
|
|
45
|
+
*/
|
|
46
|
+
function reportReadFailure(read: ProjectFileReadFailure, projectId: string): string | null {
|
|
47
|
+
trackStudioEvent("sdk_session_unavailable", {
|
|
48
|
+
stage: "read",
|
|
49
|
+
reason: read.reason,
|
|
50
|
+
...(read.reason === "http_error" ? { status: read.status } : {}),
|
|
51
|
+
});
|
|
52
|
+
if (read.reason !== "http_error") return null;
|
|
53
|
+
return read.status === 404 ? projectId : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
/**
|
|
29
57
|
* Read a project file's content (optional read — a missing file is not an
|
|
30
58
|
* error). Replaces the removed SDK http adapter's `read()` — the only thing
|
|
@@ -96,6 +124,19 @@ export interface SdkSessionHandle {
|
|
|
96
124
|
* side of that path is covered by usePersistentEditHistory.test.ts.
|
|
97
125
|
*/
|
|
98
126
|
forceReload: () => void;
|
|
127
|
+
/**
|
|
128
|
+
* Set when this server answered the composition read with a 404 for the
|
|
129
|
+
* project it was asked to open. In the CLI-embedded host — the only host
|
|
130
|
+
* that reports telemetry at all (`telemetry/policy.ts` suppresses Vite dev)
|
|
131
|
+
* — that does not mean the project is gone. It means this Studio is serving
|
|
132
|
+
* a *different* one; the project is untouched on disk. Either way every edit
|
|
133
|
+
* in this tab fails, and until now it failed silently.
|
|
134
|
+
*
|
|
135
|
+
* `null` for every other failure: a 500 or a dropped request says nothing
|
|
136
|
+
* about which project the server serves, and `absent_or_empty` /
|
|
137
|
+
* `missing_content` say nothing about the project at all.
|
|
138
|
+
*/
|
|
139
|
+
unreachableProject: string | null;
|
|
99
140
|
}
|
|
100
141
|
|
|
101
142
|
interface SdkSessionOwner {
|
|
@@ -173,6 +214,7 @@ export function useSdkSession(
|
|
|
173
214
|
const [reloadToken, setReloadToken] = useState(0);
|
|
174
215
|
const reloadTokenRef = useRef(reloadToken);
|
|
175
216
|
reloadTokenRef.current = reloadToken;
|
|
217
|
+
const [unreachableProject, setUnreachableProject] = useState<string | null>(null);
|
|
176
218
|
|
|
177
219
|
useEffect(
|
|
178
220
|
() =>
|
|
@@ -195,6 +237,7 @@ export function useSdkSession(
|
|
|
195
237
|
if (previous) disposeSdkSession(previous.session);
|
|
196
238
|
|
|
197
239
|
if (!projectId || !activeCompPath) {
|
|
240
|
+
setUnreachableProject(null);
|
|
198
241
|
return () => {
|
|
199
242
|
cancelled = true;
|
|
200
243
|
};
|
|
@@ -211,17 +254,10 @@ export function useSdkSession(
|
|
|
211
254
|
.then(async (read) => {
|
|
212
255
|
if (cancelled) return;
|
|
213
256
|
if (!read.ok) {
|
|
214
|
-
|
|
215
|
-
// server path and emits nothing — the shadow never runs either. This
|
|
216
|
-
// is the only place a missing session can originate, so a broken read
|
|
217
|
-
// would otherwise be a silent, total SDK bypass.
|
|
218
|
-
trackStudioEvent("sdk_session_unavailable", {
|
|
219
|
-
stage: "read",
|
|
220
|
-
reason: read.reason,
|
|
221
|
-
...(read.reason === "http_error" ? { status: read.status } : {}),
|
|
222
|
-
});
|
|
257
|
+
setUnreachableProject(reportReadFailure(read, projectId));
|
|
223
258
|
return;
|
|
224
259
|
}
|
|
260
|
+
setUnreachableProject(null);
|
|
225
261
|
const content = read.content;
|
|
226
262
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
227
263
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
@@ -316,5 +352,5 @@ export function useSdkSession(
|
|
|
316
352
|
ownedSession.reloadToken === reloadToken
|
|
317
353
|
? ownedSession.session
|
|
318
354
|
: null;
|
|
319
|
-
return { session, publish, forceReload };
|
|
355
|
+
return { session, publish, forceReload, unreachableProject };
|
|
320
356
|
}
|