@hyperframes/studio 0.8.115 → 0.8.116
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-C-9N8nk0.js → hyperframes-player-mGYvPVpw.js} +1 -1
- package/dist/assets/{index-BoUM6Xqb.js → index-BWHt0-ir.js} +1 -1
- package/dist/assets/{index-CAvZ0AA-.js → index-DYNaXPVf.js} +201 -201
- package/dist/assets/{index-CVuorIMv.js → index-Dr3hNpvS.js} +1 -1
- package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
- package/dist/chunk-3F47EFCT.js.map +1 -0
- package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
- package/dist/index.d.ts +104 -22
- package/dist/index.html +1 -1
- package/dist/index.js +1131 -1044
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -6
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/probeSourceElement.ts +2 -1
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +2 -105
- package/src/hooks/gsapDragPositionCommit.ts +30 -393
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +63 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -169
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +8 -0
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/newTweenTarget.test.ts +41 -76
- package/src/hooks/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -2
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +32 -1
- package/src/hooks/useEnableKeyframes.ts +2 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.ts +3 -2
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +17 -29
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +5 -0
- package/src/hooks/useGsapAwareGroupMove.ts +16 -16
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapScriptCommits.test.tsx +3 -1
- package/src/hooks/useGsapScriptCommits.ts +12 -10
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +277 -1
- package/src/hooks/usePersistentEditHistory.ts +47 -8
- package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.ts +2 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- package/src/player/components/CompositionThumbnail.tsx +2 -1
- package/src/player/components/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipPeakMap.ts +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- package/src/player/sceneSwap.ts +2 -1
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.ts +2 -3
- package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
- package/src/utils/gsapUndoRestore.ts +3 -6
- package/src/utils/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +1 -3
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +4 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.ts +2 -1
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +2 -1
- package/dist/chunk-TXZMMOMA.js.map +0 -1
- /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type BlockCategory,
|
|
6
6
|
resolveBlockCategory,
|
|
7
7
|
} from "../utils/blockCategories";
|
|
8
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
8
9
|
|
|
9
10
|
type CatalogItem = RegistryItem & {
|
|
10
11
|
category: BlockCategory;
|
|
@@ -14,7 +15,7 @@ let catalogCache: CatalogItem[] | null = null;
|
|
|
14
15
|
let catalogRequest: Promise<CatalogItem[]> | null = null;
|
|
15
16
|
|
|
16
17
|
function loadCatalog(): Promise<CatalogItem[]> {
|
|
17
|
-
catalogRequest ??=
|
|
18
|
+
catalogRequest ??= studioApiFetch("/api/registry/blocks")
|
|
18
19
|
.then((response) => {
|
|
19
20
|
if (!response.ok) throw new Error("Failed to load catalog");
|
|
20
21
|
return response.json() as Promise<RegistryItem[]>;
|
|
@@ -5,6 +5,7 @@ import { useCaptionStore } from "../captions/store";
|
|
|
5
5
|
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
|
|
6
6
|
import { useCaptionSync } from "../captions/hooks/useCaptionSync";
|
|
7
7
|
import { parseCaptionComposition } from "../captions/parser";
|
|
8
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
8
9
|
|
|
9
10
|
interface UseCaptionDetectionParams {
|
|
10
11
|
projectId: string | null;
|
|
@@ -110,7 +111,7 @@ export function useCaptionDetection({
|
|
|
110
111
|
|
|
111
112
|
activating = true;
|
|
112
113
|
const srcPath = captionSrcPath;
|
|
113
|
-
|
|
114
|
+
studioApiFetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(srcPath)}`))
|
|
114
115
|
.then((r) => r.json())
|
|
115
116
|
.then((data: { content?: string }) => {
|
|
116
117
|
if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
2
|
import { useCallback } from "react";
|
|
3
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Loads a composition file's content for the source editor when a composition
|
|
@@ -24,7 +25,7 @@ export function useCompositionContentLoader({
|
|
|
24
25
|
if (!projectId) return;
|
|
25
26
|
setActiveCompPath(comp.endsWith(".html") ? comp : null);
|
|
26
27
|
setEditingFile({ path: comp, content: null });
|
|
27
|
-
|
|
28
|
+
studioApiFetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(comp)}`))
|
|
28
29
|
.then(async (r) => {
|
|
29
30
|
if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
|
|
30
31
|
return r.json();
|
|
@@ -7,6 +7,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
|
|
|
7
7
|
import type { DomEditPatchBatch } from "./domEditCommitTypes";
|
|
8
8
|
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
|
9
9
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
10
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
10
11
|
|
|
11
12
|
export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
|
12
13
|
return fields.map((field) => field.path).join(", ");
|
|
@@ -99,7 +100,7 @@ function isAtomicElementPatchFile(value: unknown): value is AtomicElementPatchFi
|
|
|
99
100
|
export async function patchElementBatches(projectId: string, batches: DomEditPatchBatch[]) {
|
|
100
101
|
const body = JSON.stringify({ batches });
|
|
101
102
|
try {
|
|
102
|
-
const response = await
|
|
103
|
+
const response = await studioApiFetch(
|
|
103
104
|
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element-batches`,
|
|
104
105
|
{
|
|
105
106
|
method: "POST",
|
|
@@ -178,7 +179,7 @@ export async function postPatchElement(
|
|
|
178
179
|
body: unknown,
|
|
179
180
|
showToast: ShowToast,
|
|
180
181
|
): Promise<PatchElementResponse> {
|
|
181
|
-
const response = await
|
|
182
|
+
const response = await studioApiFetch(
|
|
182
183
|
buildProjectApiPath(
|
|
183
184
|
projectId,
|
|
184
185
|
`/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
|
@@ -10,6 +10,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
10
10
|
import type { TimelineElement } from "../player";
|
|
11
11
|
import type { UseDomEditSessionParams } from "./useDomEditSession";
|
|
12
12
|
import { makeSelection } from "./domSelectionTestHarness";
|
|
13
|
+
import { readTranslatePx } from "../components/editor/plainTranslate";
|
|
13
14
|
|
|
14
15
|
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
15
16
|
type: "inline-style",
|
|
@@ -66,6 +67,7 @@ const groupSelectionSpy = vi.fn();
|
|
|
66
67
|
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
|
|
67
68
|
const neverParsed = new Promise<{ animations: GsapAnimation[] }>(() => undefined);
|
|
68
69
|
const parsedFile = { current: neverParsed };
|
|
70
|
+
const groupCommitSpy = vi.fn(async () => {});
|
|
69
71
|
|
|
70
72
|
function createSessionParams(
|
|
71
73
|
overrides: Partial<UseDomEditSessionParams> = {},
|
|
@@ -256,7 +258,7 @@ vi.mock("./usePreviewInteraction", () => ({
|
|
|
256
258
|
vi.mock("./useGsapAwareEditing", () => ({
|
|
257
259
|
useGsapAwareEditing: () => ({
|
|
258
260
|
handleGsapAwarePathOffsetCommit: vi.fn(),
|
|
259
|
-
handleGsapAwareGroupPathOffsetCommit:
|
|
261
|
+
handleGsapAwareGroupPathOffsetCommit: groupCommitSpy,
|
|
260
262
|
handleGsapAwareBoxSizeCommit: vi.fn(),
|
|
261
263
|
handleGsapAwareRotationCommit: vi.fn(),
|
|
262
264
|
commitAnimatedProperty: vi.fn(),
|
|
@@ -605,3 +607,30 @@ describe("a shadow reload promoted without a host re-render", () => {
|
|
|
605
607
|
act(() => root.unmount());
|
|
606
608
|
});
|
|
607
609
|
});
|
|
610
|
+
|
|
611
|
+
describe("handleDomGroupMoveBy", () => {
|
|
612
|
+
it("saves through the group drag's commit, leaving a refusal for the host to explain", async () => {
|
|
613
|
+
const { useDomEditSession } = await import("./useDomEditSession");
|
|
614
|
+
const element = document.createElement("div");
|
|
615
|
+
document.body.append(element);
|
|
616
|
+
element.getBoundingClientRect = () => {
|
|
617
|
+
const { x, y } = readTranslatePx(element);
|
|
618
|
+
return new DOMRect(x, y, 40, 20);
|
|
619
|
+
};
|
|
620
|
+
const selection = makeSelection("card", element);
|
|
621
|
+
let moveBy!: ReturnType<typeof useDomEditSession>["handleDomGroupMoveBy"];
|
|
622
|
+
function Probe() {
|
|
623
|
+
moveBy = useDomEditSession(createSessionParams()).handleDomGroupMoveBy;
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
const root = createRoot(document.createElement("div"));
|
|
627
|
+
act(() => root.render(<Probe />));
|
|
628
|
+
await act(() => moveBy([{ selection, delta: { x: 10, y: 0 } }]));
|
|
629
|
+
expect(groupCommitSpy).toHaveBeenCalledWith(
|
|
630
|
+
[{ selection, next: { x: 10, y: 0 }, plainTranslate: true }],
|
|
631
|
+
{ refusalToast: false },
|
|
632
|
+
);
|
|
633
|
+
act(() => root.unmount());
|
|
634
|
+
element.remove();
|
|
635
|
+
});
|
|
636
|
+
});
|
|
@@ -25,6 +25,7 @@ import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMemb
|
|
|
25
25
|
import type { RecordEditInput } from "./domEditDeleteMembers";
|
|
26
26
|
import type { DomEditTimelineParams } from "./useDomSelectionTypes";
|
|
27
27
|
import { useLivePreviewIframe } from "./useLivePreviewIframe";
|
|
28
|
+
import { moveDomGroupBy, type DomGroupMove } from "../components/editor/domGroupMoveBy";
|
|
28
29
|
// Re-exported: the delete rule lives in its own module; callers and its test import it from here.
|
|
29
30
|
export { membersForDelete };
|
|
30
31
|
|
|
@@ -445,6 +446,7 @@ export function useDomEditSession({
|
|
|
445
446
|
domEditSelection,
|
|
446
447
|
selectedGsapAnimations,
|
|
447
448
|
gsapCommitMutation,
|
|
449
|
+
activeCompPath,
|
|
448
450
|
previewIframeRef,
|
|
449
451
|
showToast,
|
|
450
452
|
bumpGsapCache,
|
|
@@ -459,6 +461,13 @@ export function useDomEditSession({
|
|
|
459
461
|
setArcPath,
|
|
460
462
|
updateArcSegment,
|
|
461
463
|
});
|
|
464
|
+
const handleDomGroupMoveBy = useCallback(
|
|
465
|
+
(moves: DomGroupMove[]) =>
|
|
466
|
+
moveDomGroupBy(moves, (updates) =>
|
|
467
|
+
handleGsapAwareGroupPathOffsetCommit(updates, { refusalToast: false }),
|
|
468
|
+
),
|
|
469
|
+
[handleGsapAwareGroupPathOffsetCommit],
|
|
470
|
+
);
|
|
462
471
|
const { handleUpdateSegmentEase, handleUpdateKeyframeEase, handleSetAllKeyframeEases } =
|
|
463
472
|
useKeyframeEaseCommits({ gsapCommitMutation, domEditSelectionRef });
|
|
464
473
|
const committable = useCommitPreflightCapabilities({
|
|
@@ -497,6 +506,7 @@ export function useDomEditSession({
|
|
|
497
506
|
handleDomAttributeBatchCommit,
|
|
498
507
|
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
|
|
499
508
|
handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
|
|
509
|
+
handleDomGroupMoveBy,
|
|
500
510
|
handleDomZIndexReorderCommit,
|
|
501
511
|
handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
|
|
502
512
|
handleDomRotationCommit: handleGsapAwareRotationCommit,
|
|
@@ -350,6 +350,29 @@ describe("useDomEditTextCommits", () => {
|
|
|
350
350
|
expect(outcome).toEqual({ ok: true, persistence });
|
|
351
351
|
});
|
|
352
352
|
|
|
353
|
+
it("saves a style map on a selection as one patch, so it is one undo step", async () => {
|
|
354
|
+
const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
|
|
355
|
+
const persist = vi.fn().mockResolvedValue({ sourceFile: "index.html", changed: true });
|
|
356
|
+
const hook = renderTextCommitHook(
|
|
357
|
+
commitParams({
|
|
358
|
+
previewIframeRef: { current: iframe },
|
|
359
|
+
domEditSelection: null,
|
|
360
|
+
persistDomEditOperations: persist,
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
await act(async () => {
|
|
365
|
+
await hook.handleDomStyleCommitForSelection(selectionFor(element), {
|
|
366
|
+
"border-width": "4px",
|
|
367
|
+
"border-style": "solid",
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
expect(persist).toHaveBeenCalledTimes(1);
|
|
372
|
+
expect(element.style.borderWidth).toBe("4px");
|
|
373
|
+
expect(element.style.borderStyle).toBe("solid");
|
|
374
|
+
});
|
|
375
|
+
|
|
353
376
|
it("declines a style commit with no selection, without reaching the writer", async () => {
|
|
354
377
|
const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
|
|
355
378
|
const hook = renderTextCommitHook(
|
|
@@ -109,7 +109,11 @@ export function useDomEditTextCommits({
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
const handleDomStyleCommitForSelection = useCallback(
|
|
112
|
-
(
|
|
112
|
+
(
|
|
113
|
+
selection: DomEditSelection,
|
|
114
|
+
propertyOrStylesAsOnePatch: string | Record<string, string>,
|
|
115
|
+
value = "",
|
|
116
|
+
): Promise<DomEditCommitOutcome> =>
|
|
113
117
|
commitDomStyles(
|
|
114
118
|
{
|
|
115
119
|
activeCompPath,
|
|
@@ -121,7 +125,9 @@ export function useDomEditTextCommits({
|
|
|
121
125
|
resync: refreshDomEditSelectionFromPreview,
|
|
122
126
|
},
|
|
123
127
|
selection,
|
|
124
|
-
|
|
128
|
+
typeof propertyOrStylesAsOnePatch === "string"
|
|
129
|
+
? { [propertyOrStylesAsOnePatch]: value }
|
|
130
|
+
: propertyOrStylesAsOnePatch,
|
|
125
131
|
),
|
|
126
132
|
[
|
|
127
133
|
activeCompPath,
|
|
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
|
|
|
4
4
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
7
|
+
import { afterEach, beforeEach, expect, it, vi, type Mock } from "vitest";
|
|
8
8
|
import {
|
|
9
9
|
createStudioApi,
|
|
10
10
|
openProjectHistory,
|
|
@@ -168,6 +168,20 @@ it("an undo pressed while a save is still running waits for the server instead o
|
|
|
168
168
|
expect(s.box()).toBe("10px");
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
it("an undo waits for a save queued before its key, not one queued after", async () => {
|
|
172
|
+
const s = await studio();
|
|
173
|
+
await s.edit();
|
|
174
|
+
let land!: () => void;
|
|
175
|
+
void s.persistence().queueDomEditSave(() => new Promise<void>((resolve) => (land = resolve)));
|
|
176
|
+
await vi.waitFor(() => expect(land).toBeTypeOf("function"));
|
|
177
|
+
|
|
178
|
+
const undone = s.actions().undo();
|
|
179
|
+
void s.persistence().queueDomEditSave(() => new Promise<void>(() => {}));
|
|
180
|
+
land();
|
|
181
|
+
await act(() => undone);
|
|
182
|
+
expect(s.file()).toBe(BEFORE);
|
|
183
|
+
});
|
|
184
|
+
|
|
171
185
|
it("an undo after an outside write ends with the preview showing the file the server restored", async () => {
|
|
172
186
|
const s = await studio();
|
|
173
187
|
await s.edit();
|
|
@@ -202,10 +216,8 @@ function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void
|
|
|
202
216
|
return null;
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
await s.edit();
|
|
208
|
-
resetNudgeKeys();
|
|
219
|
+
/** A nudge save that waits for `finish()`, then writes and records the nudge as the real save does. */
|
|
220
|
+
function queuedNudgeSave(s: Awaited<ReturnType<typeof studio>>) {
|
|
209
221
|
let finish!: () => void;
|
|
210
222
|
const save = vi.fn(async () => {
|
|
211
223
|
await new Promise<void>((resolve) => (finish = resolve));
|
|
@@ -215,6 +227,24 @@ it("an undo pressed while a nudge waits for more keys never shows the move befor
|
|
|
215
227
|
files: { "index.html": { before: AFTER, after: NUDGED } },
|
|
216
228
|
});
|
|
217
229
|
});
|
|
230
|
+
return { save, finish: () => finish() };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Mounts the nudge, presses ArrowRight once and waits for its save; returns the nudge's translate reader. */
|
|
234
|
+
async function pressNudge(s: Awaited<ReturnType<typeof studio>>, save: Mock<() => Promise<void>>) {
|
|
235
|
+
s.mount(createElement(Nudge, { target: s.element("box"), save }));
|
|
236
|
+
act(() => {
|
|
237
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
238
|
+
});
|
|
239
|
+
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
|
240
|
+
return () => s.element("box").style.getPropertyValue("translate");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
|
|
244
|
+
const s = await studio();
|
|
245
|
+
await s.edit();
|
|
246
|
+
resetNudgeKeys();
|
|
247
|
+
const { save, finish } = queuedNudgeSave(s);
|
|
218
248
|
s.mount(createElement(Nudge, { target: s.element("box"), save }));
|
|
219
249
|
act(() => {
|
|
220
250
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
@@ -240,21 +270,8 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
|
|
|
240
270
|
const s = await studio();
|
|
241
271
|
await s.edit();
|
|
242
272
|
resetNudgeKeys();
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
await new Promise<void>((resolve) => (finish = resolve));
|
|
246
|
-
writeFileSync(s.path, NUDGED);
|
|
247
|
-
await s.history().recordEdit({
|
|
248
|
-
label: "Move layer",
|
|
249
|
-
files: { "index.html": { before: AFTER, after: NUDGED } },
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
|
-
s.mount(createElement(Nudge, { target: s.element("box"), save }));
|
|
253
|
-
act(() => {
|
|
254
|
-
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
255
|
-
});
|
|
256
|
-
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1));
|
|
257
|
-
const nudge = () => s.element("box").style.getPropertyValue("translate");
|
|
273
|
+
const { save, finish } = queuedNudgeSave(s);
|
|
274
|
+
const nudge = await pressNudge(s, save);
|
|
258
275
|
expect(nudge()).not.toBe("");
|
|
259
276
|
|
|
260
277
|
const undone = s.actions().undo();
|
|
@@ -267,6 +284,51 @@ it("an undo pressed while a nudge's save is queued shows the nudge undone in the
|
|
|
267
284
|
expect(s.box()).toBe("50px");
|
|
268
285
|
});
|
|
269
286
|
|
|
287
|
+
/** A nudge whose save is queued and then rejects, and the history requests the undo sends. */
|
|
288
|
+
async function failingNudge(s: Awaited<ReturnType<typeof studio>>) {
|
|
289
|
+
resetNudgeKeys();
|
|
290
|
+
let fail!: () => void;
|
|
291
|
+
const save = vi.fn(
|
|
292
|
+
() => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
|
|
293
|
+
);
|
|
294
|
+
const nudge = await pressNudge(s, save);
|
|
295
|
+
const steps: string[] = [];
|
|
296
|
+
const real = globalThis.fetch;
|
|
297
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) => {
|
|
298
|
+
if (/\/history\/(step|undo)$/.test(url)) steps.push(url);
|
|
299
|
+
return real(url, init);
|
|
300
|
+
});
|
|
301
|
+
return { fail: () => fail(), steps, nudge };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
it("an undo pressed while a nudge's save fails keeps the nudge undone and steps no older edit", async () => {
|
|
305
|
+
const s = await studio();
|
|
306
|
+
await s.edit();
|
|
307
|
+
const n = await failingNudge(s);
|
|
308
|
+
expect(n.nudge()).not.toBe("");
|
|
309
|
+
|
|
310
|
+
const undone = s.actions().undo();
|
|
311
|
+
expect(n.nudge()).toBe("");
|
|
312
|
+
n.fail();
|
|
313
|
+
await act(() => undone);
|
|
314
|
+
expect(n.steps).toEqual([]);
|
|
315
|
+
expect(s.file()).toBe(AFTER);
|
|
316
|
+
expect(s.box()).toBe("50px");
|
|
317
|
+
expect(n.nudge()).toBe("");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("an undo pressed while the only edit's save fails leaves the screen as the file, nothing put back", async () => {
|
|
321
|
+
const s = await studio();
|
|
322
|
+
s.show(BEFORE);
|
|
323
|
+
const n = await failingNudge(s);
|
|
324
|
+
|
|
325
|
+
const undone = s.actions().undo();
|
|
326
|
+
n.fail();
|
|
327
|
+
await act(() => undone);
|
|
328
|
+
expect(s.file()).toBe(BEFORE);
|
|
329
|
+
expect(n.nudge()).toBe("");
|
|
330
|
+
});
|
|
331
|
+
|
|
270
332
|
it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
|
|
271
333
|
const s = await studio();
|
|
272
334
|
await s.edit();
|
|
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
7
7
|
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
|
|
8
|
+
import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
@@ -30,6 +31,7 @@ function mount(
|
|
|
30
31
|
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
|
|
31
32
|
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
|
|
32
33
|
predict: () => predicted ?? null,
|
|
34
|
+
claims: () => 7,
|
|
33
35
|
};
|
|
34
36
|
const putBack = vi.fn();
|
|
35
37
|
const deps = {
|
|
@@ -60,6 +62,19 @@ const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored:
|
|
|
60
62
|
const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
|
|
61
63
|
|
|
62
64
|
describe("useEditHistoryActions", () => {
|
|
65
|
+
it("asks for the edit claimed after the key's claim count only when an edit was saving at the key", async () => {
|
|
66
|
+
const { deps, actions } = mount({ ok: false, reason: "empty" });
|
|
67
|
+
await act(() => actions.undo());
|
|
68
|
+
expect(deps.editHistory.undo.mock.calls[0]![0].claimedAfter).toBeUndefined();
|
|
69
|
+
|
|
70
|
+
let land!: () => void;
|
|
71
|
+
trackStudioPendingEdit(new Promise<void>((resolve) => (land = resolve)));
|
|
72
|
+
const undone = actions.undo();
|
|
73
|
+
land();
|
|
74
|
+
await act(() => undone);
|
|
75
|
+
expect(deps.editHistory.undo.mock.calls[1]![0].claimedAfter).toBe(7);
|
|
76
|
+
});
|
|
77
|
+
|
|
63
78
|
it("corrects a shown step from the server's restore, diffed from what the preview shows", async () => {
|
|
64
79
|
const { deps, actions } = mount(
|
|
65
80
|
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
|
|
@@ -3,7 +3,10 @@ import { useCallback, useMemo } from "react";
|
|
|
3
3
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
4
4
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
5
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
hasStudioPendingEdits,
|
|
8
|
+
paintBackNewestStudioPendingEdit,
|
|
9
|
+
} from "../utils/studioPendingEdits";
|
|
7
10
|
|
|
8
11
|
interface HistoryResult {
|
|
9
12
|
ok: boolean;
|
|
@@ -18,11 +21,13 @@ interface HistoryResult {
|
|
|
18
21
|
interface HistoryFileCallbacks {
|
|
19
22
|
readFile: (path: string) => Promise<string>;
|
|
20
23
|
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
|
24
|
+
claimedAfter?: number;
|
|
21
25
|
}
|
|
22
26
|
export interface EditHistoryHandle {
|
|
23
27
|
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
24
28
|
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
25
29
|
predict?: (direction: "undo" | "redo") => { id: string; files: RestoreFiles } | null;
|
|
30
|
+
claims?: () => number;
|
|
26
31
|
state: {
|
|
27
32
|
undo: ReadonlyArray<{ createdAt: number }>;
|
|
28
33
|
redo: ReadonlyArray<{ createdAt: number }>;
|
|
@@ -30,7 +35,7 @@ export interface EditHistoryHandle {
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export interface UseEditHistoryActionsOptions {
|
|
33
|
-
editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict">;
|
|
38
|
+
editHistory: Pick<EditHistoryHandle, "undo" | "redo" | "predict" | "claims">;
|
|
34
39
|
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
35
40
|
readProjectFile: (path: string) => Promise<string>;
|
|
36
41
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
@@ -77,22 +82,28 @@ export function useEditHistoryActions({
|
|
|
77
82
|
const predicted = editHistory.predict?.(direction) ?? null;
|
|
78
83
|
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
79
84
|
const pendingEditShown =
|
|
80
|
-
!predictedShown && direction === "undo" ?
|
|
81
|
-
const putBack = predictedShown ?? pendingEditShown;
|
|
85
|
+
!predictedShown && direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
|
|
86
|
+
const putBack = predictedShown ?? pendingEditShown?.showAgain;
|
|
87
|
+
const claimedAfter =
|
|
88
|
+
direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : undefined;
|
|
82
89
|
let result: HistoryResult = { ok: false, reason: "failed" };
|
|
83
90
|
let serverSteppedShown = false;
|
|
91
|
+
let revertIsTheUndo = false;
|
|
84
92
|
try {
|
|
85
93
|
await waitForPendingDomEditSaves();
|
|
94
|
+
revertIsTheUndo = Boolean(pendingEditShown && !(await pendingEditShown.landed()));
|
|
95
|
+
if (revertIsTheUndo) return;
|
|
86
96
|
result = await editHistory[direction]({
|
|
87
97
|
readFile: readHistoryFile,
|
|
88
98
|
serialize: serializeHistoryFiles,
|
|
99
|
+
claimedAfter,
|
|
89
100
|
});
|
|
90
101
|
const stepped = Boolean(result.ok && result.label);
|
|
91
102
|
serverSteppedShown = predictedShown
|
|
92
103
|
? stepped && result.undoes === predicted?.id
|
|
93
104
|
: stepped && Boolean(pendingEditShown);
|
|
94
105
|
} finally {
|
|
95
|
-
if (putBack && !serverSteppedShown) putBack();
|
|
106
|
+
if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
|
|
96
107
|
}
|
|
97
108
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
98
109
|
showToast(
|
|
@@ -24,6 +24,7 @@ import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditComm
|
|
|
24
24
|
import { domEditCommitDeclined, type DomEditCommitOutcome } from "./domEditCommitRunner";
|
|
25
25
|
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
|
26
26
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
27
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
27
28
|
|
|
28
29
|
interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
|
|
29
30
|
/** Route delete through SDK when session resolves the hf-id. */
|
|
@@ -156,7 +157,7 @@ export function useElementLifecycleOps({
|
|
|
156
157
|
writeFile: writeProjectFile,
|
|
157
158
|
recordEdit: editHistory.recordEdit,
|
|
158
159
|
rewrite: async (originalContent) => {
|
|
159
|
-
const removeResponse = await
|
|
160
|
+
const removeResponse = await studioApiFetch(
|
|
160
161
|
buildProjectApiPath(
|
|
161
162
|
pid,
|
|
162
163
|
`/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
|
|
@@ -378,7 +378,10 @@ function makeElementSelection(): DomEditSelection {
|
|
|
378
378
|
} as DomEditSelection;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
function stubFlatTweenConversion(
|
|
381
|
+
function stubFlatTweenConversion(
|
|
382
|
+
id: string,
|
|
383
|
+
easeEach?: string,
|
|
384
|
+
): {
|
|
382
385
|
flat: GsapAnimation;
|
|
383
386
|
converted: GsapAnimation;
|
|
384
387
|
} {
|
|
@@ -393,6 +396,7 @@ function stubFlatTweenConversion(id: string): {
|
|
|
393
396
|
{ percentage: 0, properties: { x: 0 } },
|
|
394
397
|
{ percentage: 100, properties: { x: 10 } },
|
|
395
398
|
],
|
|
399
|
+
...(easeEach && { easeEach }),
|
|
396
400
|
},
|
|
397
401
|
});
|
|
398
402
|
flatTweenResponses(flat, converted);
|
|
@@ -437,6 +441,33 @@ describe("useEnableKeyframes — flat tween transaction", () => {
|
|
|
437
441
|
expect(convertOptions?.coalesceKey).toEqual(expect.any(String));
|
|
438
442
|
});
|
|
439
443
|
|
|
444
|
+
it("keeps the converted tween's easeEach when it extends it to the playhead", async () => {
|
|
445
|
+
window.location.hash = "#/project/test-project";
|
|
446
|
+
usePlayerStore.setState({ currentTime: 3 });
|
|
447
|
+
const { flat } = stubFlatTweenConversion("flat-1", "none");
|
|
448
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
449
|
+
const enable = renderEnableKeyframes({
|
|
450
|
+
domEditSelection: makeElementSelection(),
|
|
451
|
+
selectedGsapAnimations: [flat],
|
|
452
|
+
previewIframeRef: {
|
|
453
|
+
current: {
|
|
454
|
+
contentWindow: { gsap: { getProperty: () => 10 } },
|
|
455
|
+
} as unknown as HTMLIFrameElement,
|
|
456
|
+
},
|
|
457
|
+
handleGsapAddAnimation: vi.fn(),
|
|
458
|
+
handleGsapConvertToKeyframes: vi.fn(async () => undefined),
|
|
459
|
+
handleGsapRemoveKeyframe: vi.fn(),
|
|
460
|
+
commitMutation,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
await act(async () => enable());
|
|
464
|
+
|
|
465
|
+
expect(commitMutation.mock.calls[0]?.[0]).toMatchObject({
|
|
466
|
+
type: "replace-with-keyframes",
|
|
467
|
+
easeEach: "none",
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
440
471
|
it("passes the convert coalesce key to an inside-range batch edit", async () => {
|
|
441
472
|
window.location.hash = "#/project/test-project";
|
|
442
473
|
usePlayerStore.setState({ currentTime: 1.5 });
|
|
@@ -15,6 +15,7 @@ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCa
|
|
|
15
15
|
import {
|
|
16
16
|
existingTweenTargetSelector,
|
|
17
17
|
computeElementPercentage,
|
|
18
|
+
keyframeEases,
|
|
18
19
|
KEYFRAME_PCT_MATCH,
|
|
19
20
|
isInstantHold,
|
|
20
21
|
resolveEditableTweenDuration,
|
|
@@ -266,7 +267,7 @@ async function extendKeyframedTweenToPlayhead(
|
|
|
266
267
|
position: extended.position,
|
|
267
268
|
duration: extended.duration,
|
|
268
269
|
keyframes: extended.keyframes,
|
|
269
|
-
|
|
270
|
+
...keyframeEases(anim),
|
|
270
271
|
},
|
|
271
272
|
{
|
|
272
273
|
label: "Add keyframe",
|
|
@@ -83,6 +83,25 @@ describe("external file change coordinator", () => {
|
|
|
83
83
|
expect(captured.handle?.blocked).toBeNull();
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
it("keeps one file-change subscription across re-renders and delivers to the latest callbacks", async () => {
|
|
87
|
+
const on = vi.fn((_event: string, next: HotHandler) => void (handler = next));
|
|
88
|
+
vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", { on, off: vi.fn() });
|
|
89
|
+
const { options } = await mountCoordinator();
|
|
90
|
+
on.mockClear();
|
|
91
|
+
const root = createRoot(document.createElement("div"));
|
|
92
|
+
roots.push(root);
|
|
93
|
+
function Probe({ onAccepted }: { onAccepted: () => void }) {
|
|
94
|
+
useExternalFileChangeCoordinator({ ...options, onAcceptedPersistedFileChange: onAccepted });
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const latest = vi.fn();
|
|
98
|
+
await act(async () => root.render(<Probe onAccepted={vi.fn()} />));
|
|
99
|
+
await act(async () => root.render(<Probe onAccepted={latest} />));
|
|
100
|
+
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
|
101
|
+
expect(on).toHaveBeenCalledOnce();
|
|
102
|
+
expect(latest).toHaveBeenCalledOnce();
|
|
103
|
+
});
|
|
104
|
+
|
|
86
105
|
// The file tree (useFileTree) is only ever refreshed from Studio's own file
|
|
87
106
|
// operations (create/delete/rename/upload) — never on an external change.
|
|
88
107
|
// Without this call, an agent removing or replacing a composition updates
|
|
@@ -448,9 +448,11 @@ export function useExternalFileChangeCoordinator({
|
|
|
448
448
|
[projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
|
|
449
449
|
);
|
|
450
450
|
|
|
451
|
+
const processChangeRef = useRef(processChange);
|
|
452
|
+
processChangeRef.current = processChange;
|
|
451
453
|
useEffect(() => {
|
|
452
454
|
// One decoder for all three transports; the rungs only choose the channel.
|
|
453
|
-
const handler = (delivery?: unknown) =>
|
|
455
|
+
const handler = (delivery?: unknown) => processChangeRef.current(decodeFileChange(delivery));
|
|
454
456
|
const adapter = testHotAdapter();
|
|
455
457
|
if (adapter) {
|
|
456
458
|
adapter.on("hf:file-change", handler);
|
|
@@ -461,7 +463,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
461
463
|
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
462
464
|
}
|
|
463
465
|
return sseFileChangeChannel(handler);
|
|
464
|
-
}, [
|
|
466
|
+
}, []);
|
|
465
467
|
|
|
466
468
|
const retry = useCallback(async () => {
|
|
467
469
|
const current = blockedRef.current;
|
|
@@ -129,6 +129,7 @@ describe("useFileManager project ownership", () => {
|
|
|
129
129
|
|
|
130
130
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
131
131
|
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
|
|
132
|
+
{ credentials: "omit" },
|
|
132
133
|
);
|
|
133
134
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
134
135
|
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
|
|
@@ -136,14 +137,18 @@ describe("useFileManager project ownership", () => {
|
|
|
136
137
|
);
|
|
137
138
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
138
139
|
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
|
|
140
|
+
{ credentials: "omit" },
|
|
139
141
|
);
|
|
140
142
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
141
143
|
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
|
|
142
144
|
expect.objectContaining({ method: "PUT", body: "A_NEW" }),
|
|
143
145
|
);
|
|
144
|
-
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html"
|
|
146
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html", {
|
|
147
|
+
credentials: "omit",
|
|
148
|
+
});
|
|
145
149
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
146
150
|
"/api/projects/project-b%23fragment/files/index.html?optional=1",
|
|
151
|
+
{ credentials: "omit" },
|
|
147
152
|
);
|
|
148
153
|
|
|
149
154
|
await act(async () => root.unmount());
|