@hyperframes/studio 0.8.115 → 0.8.117
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-5fcgSstJ.js} +1 -1
- package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
- package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
- package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.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 +132 -41
- package/dist/index.html +1 -1
- package/dist/index.js +1184 -1084
- 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/DomEditOverlay.tsx +1 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- 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 +8 -9
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/plainTranslate.ts +5 -3
- package/src/components/editor/probeSourceElement.ts +2 -1
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/propertyPanelHelpers.ts +2 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
- package/src/components/editor/rotationDraft.ts +2 -1
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
- 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/gsapRuntimeKeyframes.ts +16 -3
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +9 -1
- 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/plainRotation.ts +5 -1
- 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/useDomEditPersist.test.tsx +335 -0
- 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.test.tsx +5 -1
- package/src/hooks/useDomGeometryCommit.ts +7 -9
- 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.test.ts +39 -3
- package/src/hooks/useFreezeFrame.ts +7 -3
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +17 -8
- package/src/hooks/useGsapAwareGroupMove.ts +18 -18
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
- package/src/hooks/useGsapScriptCommits.ts +21 -17
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +302 -3
- package/src/hooks/usePersistentEditHistory.ts +50 -17
- 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.test.ts +39 -0
- package/src/utils/gsapSoftReload.ts +9 -14
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
- 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 +5 -6
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +5 -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
|
@@ -30,7 +30,11 @@ vi.mock("../utils/studioTelemetry", () => ({
|
|
|
30
30
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
31
31
|
|
|
32
32
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
33
|
-
import type {
|
|
33
|
+
import type {
|
|
34
|
+
CommitMutationCall,
|
|
35
|
+
CommitMutationOptions,
|
|
36
|
+
MutationResult,
|
|
37
|
+
} from "./gsapScriptCommitTypes";
|
|
34
38
|
import { persistSdkSerialize } from "../utils/sdkCutover";
|
|
35
39
|
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
36
40
|
import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
|
|
@@ -211,6 +215,34 @@ describe("applyPreviewSync", () => {
|
|
|
211
215
|
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
212
216
|
});
|
|
213
217
|
|
|
218
|
+
it("carries a deferred write with no instant patch into the final batch render", () => {
|
|
219
|
+
const previewFallbackLatch = { pending: false };
|
|
220
|
+
applySoftReload.mockReturnValue("applied");
|
|
221
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
222
|
+
const group = {
|
|
223
|
+
label: "Move animated layer (group)",
|
|
224
|
+
softReload: true,
|
|
225
|
+
previewFallbackLatch,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
applyPreviewSync(
|
|
229
|
+
FAKE_IFRAME,
|
|
230
|
+
result({ scriptText: "SCRIPT" }),
|
|
231
|
+
{ ...group, deferPreviewSync: true },
|
|
232
|
+
vi.fn(),
|
|
233
|
+
);
|
|
234
|
+
expect(previewFallbackLatch.pending).toBe(true);
|
|
235
|
+
expect(applySoftReload).not.toHaveBeenCalled();
|
|
236
|
+
|
|
237
|
+
applyPreviewSync(
|
|
238
|
+
FAKE_IFRAME,
|
|
239
|
+
result({ scriptText: "SCRIPT" }),
|
|
240
|
+
{ ...group, instantPatch: { selector: "#final", change: { kind: "set", props: { x: 2 } } } },
|
|
241
|
+
vi.fn(),
|
|
242
|
+
);
|
|
243
|
+
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
214
246
|
it("falls back immediately when a deferred patch miss has no final-render latch", () => {
|
|
215
247
|
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
216
248
|
applySoftReload.mockReturnValue("applied");
|
|
@@ -416,6 +448,16 @@ function renderCommitHook(
|
|
|
416
448
|
|
|
417
449
|
const selection: DomEditSelection = { id: "a", selector: "#a" } as DomEditSelection;
|
|
418
450
|
|
|
451
|
+
async function commitBatch(calls: CommitMutationCall[], options: CommitMutationOptions) {
|
|
452
|
+
const deps = renderCommitHook();
|
|
453
|
+
const batch = deps.api.commitMutation.batch;
|
|
454
|
+
if (!batch) throw new Error("batch capability missing");
|
|
455
|
+
await act(async () => {
|
|
456
|
+
await batch(calls, options);
|
|
457
|
+
});
|
|
458
|
+
return deps;
|
|
459
|
+
}
|
|
460
|
+
|
|
419
461
|
function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
|
420
462
|
const body: MutationResult = {
|
|
421
463
|
ok: true,
|
|
@@ -553,6 +595,32 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
553
595
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
554
596
|
});
|
|
555
597
|
|
|
598
|
+
it("batch where a write without an instant patch soft-reloads instead of patching the rest", async () => {
|
|
599
|
+
// A corner resize saves size (no in-place patch) and position (patched) in one batch.
|
|
600
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
601
|
+
applySoftReload.mockReturnValue("applied");
|
|
602
|
+
mockFetchResult({ changed: true });
|
|
603
|
+
const position = { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } };
|
|
604
|
+
const deps = await commitBatch(
|
|
605
|
+
[
|
|
606
|
+
{
|
|
607
|
+
selection,
|
|
608
|
+
mutation: { type: "update-property", property: "width", value: 300 },
|
|
609
|
+
options: { label: "Resize layer" },
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
selection,
|
|
613
|
+
mutation: { type: "update-property", property: "x", value: 10 },
|
|
614
|
+
options: { label: "Resize layer", instantPatch: position },
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
{ label: "Resize layer", softReload: true },
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
621
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
622
|
+
});
|
|
623
|
+
|
|
556
624
|
it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
|
|
557
625
|
// Server contract: gsap-mutations returns scriptText on EVERY response,
|
|
558
626
|
// including changed:false — so the fallback re-runs the identical script
|
|
@@ -698,7 +766,9 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
698
766
|
await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
|
|
699
767
|
});
|
|
700
768
|
|
|
701
|
-
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html")
|
|
769
|
+
expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"), {
|
|
770
|
+
credentials: "omit",
|
|
771
|
+
});
|
|
702
772
|
expect(applySoftReload).toHaveBeenCalledWith(
|
|
703
773
|
expect.anything(),
|
|
704
774
|
NESTED_SCRIPT,
|
|
@@ -28,12 +28,13 @@ import {
|
|
|
28
28
|
formatGsapMutationRejectionToast,
|
|
29
29
|
readJsonResponseBody,
|
|
30
30
|
} from "./gsapScriptCommitHelpers";
|
|
31
|
-
import
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
import {
|
|
32
|
+
gsapWriteFile,
|
|
33
|
+
type CommitMutation,
|
|
34
|
+
type CommitMutationCall,
|
|
35
|
+
type CommitMutationOptions,
|
|
36
|
+
type GsapScriptCommitsParams,
|
|
37
|
+
type MutationResult,
|
|
37
38
|
} from "./gsapScriptCommitTypes";
|
|
38
39
|
import { useGsapAnimationOps } from "./useGsapAnimationOps";
|
|
39
40
|
import { useGsapArcPathOps } from "./useGsapArcPathOps";
|
|
@@ -44,13 +45,14 @@ import {
|
|
|
44
45
|
useSafeGsapCommitMutation,
|
|
45
46
|
} from "./useSafeGsapCommitMutation";
|
|
46
47
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
48
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
47
49
|
|
|
48
50
|
async function mutateGsapScript(
|
|
49
51
|
projectId: string,
|
|
50
52
|
sourceFile: string,
|
|
51
53
|
mutation: Record<string, unknown>,
|
|
52
54
|
): Promise<MutationResult> {
|
|
53
|
-
const res = await
|
|
55
|
+
const res = await studioApiFetch(
|
|
54
56
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
|
|
55
57
|
{
|
|
56
58
|
method: "POST",
|
|
@@ -69,7 +71,7 @@ async function mutateGsapScriptBatch(
|
|
|
69
71
|
sourceFile: string,
|
|
70
72
|
mutations: Record<string, unknown>[],
|
|
71
73
|
): Promise<MutationResult> {
|
|
72
|
-
const res = await
|
|
74
|
+
const res = await studioApiFetch(
|
|
73
75
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
|
|
74
76
|
{
|
|
75
77
|
method: "POST",
|
|
@@ -266,7 +268,8 @@ export function applyPreviewSync(
|
|
|
266
268
|
nestedFiles?: Map<string, string> | null,
|
|
267
269
|
): void {
|
|
268
270
|
const patches = instantPatchesFor(options);
|
|
269
|
-
|
|
271
|
+
const writtenWithoutPatch = patches.length === 0;
|
|
272
|
+
let needsFallback = options.previewFallbackLatch?.pending === true || writtenWithoutPatch;
|
|
270
273
|
if (patches.length > 0) {
|
|
271
274
|
const deferSeek = options.deferPreviewSync === true;
|
|
272
275
|
const missed = patches.find(
|
|
@@ -408,13 +411,14 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
408
411
|
if (!result) return;
|
|
409
412
|
trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options));
|
|
410
413
|
options.onResult?.(result);
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
const instantPatches = calls
|
|
414
|
-
.
|
|
415
|
-
|
|
414
|
+
// The batch wrote every call, so patch in place only when every call brought a patch; one
|
|
415
|
+
// without (a resize's size write) needs the reload, or the preview keeps the old value.
|
|
416
|
+
const instantPatches = calls.flatMap(({ options: callOptions }) =>
|
|
417
|
+
callOptions.instantPatch ? [callOptions.instantPatch] : [],
|
|
418
|
+
);
|
|
416
419
|
const { instantPatch: _instantPatch, ...batchOptions } = options;
|
|
417
|
-
|
|
420
|
+
const allPatched = instantPatches.length === calls.length;
|
|
421
|
+
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, allPatched ? { ...batchOptions, instantPatches } : batchOptions);
|
|
418
422
|
}, [showToast, finalizeSuccessfulMutation]);
|
|
419
423
|
|
|
420
424
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
@@ -442,14 +446,14 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
442
446
|
};
|
|
443
447
|
const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => {
|
|
444
448
|
if (!activeProjectId) return Promise.resolve();
|
|
445
|
-
const file = selection
|
|
449
|
+
const file = gsapWriteFile(selection, activeCompPath);
|
|
446
450
|
return serializeCommit(file, options.serializeKey, () =>
|
|
447
451
|
runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
|
|
448
452
|
);
|
|
449
453
|
}) as CommitMutation;
|
|
450
454
|
commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => {
|
|
451
455
|
if (!activeProjectId) return Promise.resolve();
|
|
452
|
-
const file = calls[0]
|
|
456
|
+
const file = calls[0] ? gsapWriteFile(calls[0].selection, activeCompPath) : "index.html";
|
|
453
457
|
return serializeCommit(file, options.serializeKey, () =>
|
|
454
458
|
runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
|
|
455
459
|
);
|
|
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
|
3
3
|
import type { LintFinding } from "../components/LintModal";
|
|
4
4
|
import { usePlayerStore } from "../player";
|
|
5
5
|
import { isPreviewBooted, whenPreviewBooted } from "../player/store/playerStore";
|
|
6
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
6
7
|
|
|
7
8
|
interface RawFinding {
|
|
8
9
|
severity?: string;
|
|
@@ -25,7 +26,7 @@ function parseFinding(f: RawFinding): LintFinding & { elementId?: string; file?:
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
async function fetchLintFindings(projectId: string) {
|
|
28
|
-
const res = await
|
|
29
|
+
const res = await studioApiFetch(buildProjectApiPath(projectId, `/lint`));
|
|
29
30
|
const data = await res.json();
|
|
30
31
|
return ((data.findings ?? []) as RawFinding[]).map(parseFinding);
|
|
31
32
|
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// fallow-ignore-file code-duplication
|
|
3
3
|
import { act, createElement } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
8
|
import { afterEach, expect, it, vi } from "vitest";
|
|
9
9
|
import {
|
|
10
10
|
createStudioApi,
|
|
@@ -88,6 +88,257 @@ it("an edit Studio saved is undone and redone by the project's history, with the
|
|
|
88
88
|
expect(file()).toBe("B");
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
it("undoes the edit claimed since the key, not a later edit the server took in first", async () => {
|
|
92
|
+
const { dir, hook, file, save, readFile } = await studio();
|
|
93
|
+
const atKey = hook().claims();
|
|
94
|
+
save("B");
|
|
95
|
+
await act(() =>
|
|
96
|
+
hook().recordEdit({
|
|
97
|
+
label: "Moved Title",
|
|
98
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
writeFileSync(join(dir, "card.html"), "Y");
|
|
102
|
+
const later = hook().recordEdit({
|
|
103
|
+
label: "Added Card",
|
|
104
|
+
files: { "card.html": { before: "", after: "Y" } },
|
|
105
|
+
});
|
|
106
|
+
const afterLater = async <T>(_paths: readonly string[], task: () => Promise<T>) => {
|
|
107
|
+
await later;
|
|
108
|
+
return task();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const undone = await act(() =>
|
|
112
|
+
hook().undo({ readFile, serialize: afterLater, claimedAfter: atKey }),
|
|
113
|
+
);
|
|
114
|
+
expect(undone).toMatchObject({ ok: true, label: "Undid: Moved Title" });
|
|
115
|
+
expect(file()).toBe("A");
|
|
116
|
+
expect(readFileSync(join(dir, "card.html"), "utf8")).toBe("Y");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("a second undo pressed while the same edit saves undoes the edit before it", async () => {
|
|
120
|
+
const { hook, file, save, readFile } = await studio();
|
|
121
|
+
save("B");
|
|
122
|
+
await act(() =>
|
|
123
|
+
hook().recordEdit({
|
|
124
|
+
label: "Moved Title",
|
|
125
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
129
|
+
const atKey = hook().claims();
|
|
130
|
+
save("C");
|
|
131
|
+
await act(() =>
|
|
132
|
+
hook().recordEdit({
|
|
133
|
+
label: "Moved Card",
|
|
134
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
139
|
+
const serialize = <T>(_paths: readonly string[], task: () => Promise<T>) => {
|
|
140
|
+
const run = queue.then(task);
|
|
141
|
+
queue = run.catch(() => undefined);
|
|
142
|
+
return run;
|
|
143
|
+
};
|
|
144
|
+
const press = () => hook().undo({ readFile, claimedAfter: atKey, serialize });
|
|
145
|
+
|
|
146
|
+
const [first, second] = await act(() => Promise.all([press(), press()]));
|
|
147
|
+
|
|
148
|
+
expect([first, second]).toMatchObject([
|
|
149
|
+
{ ok: true, label: "Undid: Moved Card" },
|
|
150
|
+
{ ok: true, label: "Undid: Moved Title" },
|
|
151
|
+
]);
|
|
152
|
+
expect(file()).toBe("A");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps the claim when its undo fails, so the next press targets that edit again", async () => {
|
|
156
|
+
const { hook, save, readFile } = await studio();
|
|
157
|
+
save("B");
|
|
158
|
+
await act(() =>
|
|
159
|
+
hook().recordEdit({
|
|
160
|
+
label: "Moved Title",
|
|
161
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
165
|
+
const atKey = hook().claims();
|
|
166
|
+
save("C");
|
|
167
|
+
await act(() =>
|
|
168
|
+
hook().recordEdit({
|
|
169
|
+
label: "Moved Card",
|
|
170
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
const posted: string[] = [];
|
|
174
|
+
const fetch = globalThis.fetch;
|
|
175
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) => {
|
|
176
|
+
if (init?.method === "POST") posted.push(url.slice(url.lastIndexOf("/") + 1));
|
|
177
|
+
return fetch(url, init);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
save("edited elsewhere");
|
|
181
|
+
const failed = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
|
|
182
|
+
save("C");
|
|
183
|
+
const retried = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
|
|
184
|
+
|
|
185
|
+
expect(failed.ok).toBe(false);
|
|
186
|
+
expect(retried).toMatchObject({ ok: true, label: "Undid: Moved Card" });
|
|
187
|
+
expect(posted.filter((p) => p === "undo" || p === "step")).toEqual(["undo", "undo"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("does not undo again an edit another press already undid", async () => {
|
|
191
|
+
const { hook, file, save, readFile } = await studio();
|
|
192
|
+
save("B");
|
|
193
|
+
await act(() =>
|
|
194
|
+
hook().recordEdit({
|
|
195
|
+
label: "Moved Title",
|
|
196
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
200
|
+
const atKey = hook().claims();
|
|
201
|
+
save("C");
|
|
202
|
+
await act(() =>
|
|
203
|
+
hook().recordEdit({
|
|
204
|
+
label: "Moved Card",
|
|
205
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
let calls = 0;
|
|
209
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
210
|
+
const serialize = <T>(_paths: readonly string[], task: () => Promise<T>) => {
|
|
211
|
+
calls += 1;
|
|
212
|
+
if (calls === 1) return Promise.reject(new Error("save failed"));
|
|
213
|
+
const run = queue.then(task);
|
|
214
|
+
queue = run.catch(() => undefined);
|
|
215
|
+
return run;
|
|
216
|
+
};
|
|
217
|
+
const press = () => hook().undo({ readFile, claimedAfter: atKey, serialize });
|
|
218
|
+
|
|
219
|
+
const [first, second] = await act(() => Promise.allSettled([press(), press()]));
|
|
220
|
+
const third = await act(() => press());
|
|
221
|
+
|
|
222
|
+
expect(first.status).toBe("rejected");
|
|
223
|
+
expect(second).toMatchObject({
|
|
224
|
+
status: "fulfilled",
|
|
225
|
+
value: { ok: true, label: "Undid: Moved Card" },
|
|
226
|
+
});
|
|
227
|
+
expect(third).toMatchObject({ ok: true, label: "Undid: Moved Title" });
|
|
228
|
+
expect(file()).toBe("A");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not undo again an edit a later press undid first", async () => {
|
|
232
|
+
const { hook, file, save, readFile } = await studio();
|
|
233
|
+
save("B");
|
|
234
|
+
await act(() =>
|
|
235
|
+
hook().recordEdit({
|
|
236
|
+
label: "Moved Title",
|
|
237
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
241
|
+
const atKey = hook().claims();
|
|
242
|
+
save("C");
|
|
243
|
+
await act(() =>
|
|
244
|
+
hook().recordEdit({
|
|
245
|
+
label: "Moved Card",
|
|
246
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
let fail!: (error: Error) => void;
|
|
250
|
+
const held = <T>(_paths: readonly string[], _task: () => Promise<T>) =>
|
|
251
|
+
new Promise<T>((_resolve, reject) => {
|
|
252
|
+
fail = reject;
|
|
253
|
+
});
|
|
254
|
+
const failed = hook()
|
|
255
|
+
.undo({ readFile, claimedAfter: atKey, serialize: held })
|
|
256
|
+
.catch(() => undefined);
|
|
257
|
+
|
|
258
|
+
const second = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
|
|
259
|
+
fail(new Error("save failed"));
|
|
260
|
+
await failed;
|
|
261
|
+
const third = await act(() => hook().undo({ readFile, claimedAfter: atKey }));
|
|
262
|
+
|
|
263
|
+
expect(second).toMatchObject({ ok: true, label: "Undid: Moved Card" });
|
|
264
|
+
expect(third).toMatchObject({ ok: true, label: "Undid: Moved Title" });
|
|
265
|
+
expect(file()).toBe("A");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("refuses a second press whose edit is undone once an edit was made after the press", async () => {
|
|
269
|
+
const { dir, hook, file, save, readFile } = await studio();
|
|
270
|
+
save("B");
|
|
271
|
+
await act(() =>
|
|
272
|
+
hook().recordEdit({
|
|
273
|
+
label: "Moved Title",
|
|
274
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Moved Title"));
|
|
278
|
+
const atKey = hook().claims();
|
|
279
|
+
save("C");
|
|
280
|
+
await act(() =>
|
|
281
|
+
hook().recordEdit({
|
|
282
|
+
label: "Moved Card",
|
|
283
|
+
files: { "index.html": { before: "B", after: "C" } },
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
let open!: () => void;
|
|
287
|
+
const opened = new Promise<void>((resolve) => {
|
|
288
|
+
open = resolve;
|
|
289
|
+
});
|
|
290
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
291
|
+
let queued = 0;
|
|
292
|
+
const serialize = <T>(_paths: readonly string[], task: () => Promise<T>) => {
|
|
293
|
+
queued += 1;
|
|
294
|
+
const wait = queued === 2 ? opened : Promise.resolve();
|
|
295
|
+
const run = queue.then(() => wait).then(task);
|
|
296
|
+
queue = run.catch(() => undefined);
|
|
297
|
+
return run;
|
|
298
|
+
};
|
|
299
|
+
const press = () => hook().undo({ readFile, claimedAfter: atKey, serialize });
|
|
300
|
+
const presses = Promise.all([press(), press()]);
|
|
301
|
+
await vi.waitFor(() => expect(file()).toBe("B"));
|
|
302
|
+
writeFileSync(join(dir, "card.html"), "Y");
|
|
303
|
+
await act(() =>
|
|
304
|
+
hook().recordEdit({
|
|
305
|
+
label: "Added Card",
|
|
306
|
+
files: { "card.html": { before: "", after: "Y" } },
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
open();
|
|
311
|
+
const [first, second] = await act(() => presses);
|
|
312
|
+
|
|
313
|
+
expect(first).toMatchObject({ ok: true, label: "Undid: Moved Card" });
|
|
314
|
+
expect(second).toMatchObject({ ok: false, reason: "content-mismatch" });
|
|
315
|
+
expect(readFileSync(join(dir, "card.html"), "utf8")).toBe("Y");
|
|
316
|
+
expect(file()).toBe("B");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("a file the edit made (a freeze's still) is deleted by its Undo and put back by its Redo", async () => {
|
|
320
|
+
const { dir, hook, save, readFile } = await studio();
|
|
321
|
+
const still = join(dir, "assets", "freeze", "talk.png");
|
|
322
|
+
mkdirSync(dirname(still), { recursive: true });
|
|
323
|
+
writeFileSync(still, "png");
|
|
324
|
+
save("B");
|
|
325
|
+
await act(() =>
|
|
326
|
+
hook().recordEdit({
|
|
327
|
+
label: "Freeze frame",
|
|
328
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
329
|
+
created: ["assets/freeze/talk.png"],
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
await vi.waitFor(() => expect(hook().undoLabel).toBe("Freeze frame"));
|
|
333
|
+
|
|
334
|
+
await act(() => hook().undo({ readFile }));
|
|
335
|
+
expect(existsSync(still)).toBe(false);
|
|
336
|
+
|
|
337
|
+
await vi.waitFor(() => expect(hook().canRedo).toBe(true));
|
|
338
|
+
await act(() => hook().redo({ readFile }));
|
|
339
|
+
expect(readFileSync(still, "utf8")).toBe("png");
|
|
340
|
+
});
|
|
341
|
+
|
|
91
342
|
it("a drag's edits under one key undo as one step, even before the drag goes idle", async () => {
|
|
92
343
|
const { hook, file, save, readFile } = await studio();
|
|
93
344
|
const writes: Array<[before: string, after: string]> = [
|
|
@@ -115,6 +366,32 @@ it("a drag's edits under one key undo as one step, even before the drag goes idl
|
|
|
115
366
|
expect(file()).toBe("A");
|
|
116
367
|
});
|
|
117
368
|
|
|
369
|
+
it("an undo before the history view shows a drag's held claim still waits on the files it wrote", async () => {
|
|
370
|
+
const { hook, save, readFile } = await studio();
|
|
371
|
+
const server = globalThis.fetch;
|
|
372
|
+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
|
|
373
|
+
init?.method === "POST"
|
|
374
|
+
? server(url, init)
|
|
375
|
+
: Promise.resolve(new Response(null, { status: 503 })),
|
|
376
|
+
);
|
|
377
|
+
save("B");
|
|
378
|
+
await act(() =>
|
|
379
|
+
hook().recordEdit({
|
|
380
|
+
label: "Dragged Title",
|
|
381
|
+
coalesceKey: "drag",
|
|
382
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
let waitedOn: readonly string[] = [];
|
|
386
|
+
const serialize = <T>(paths: readonly string[], task: () => Promise<T>) => {
|
|
387
|
+
waitedOn = paths;
|
|
388
|
+
return task();
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
await act(() => hook().undo({ readFile, serialize }));
|
|
392
|
+
expect(waitedOn).toEqual(["index.html"]);
|
|
393
|
+
});
|
|
394
|
+
|
|
118
395
|
it("an agent's edit made seconds before Studio's stays the agent's: Cmd+Z undoes only Studio's", async () => {
|
|
119
396
|
const { hook, file, save, readFile } = await studio();
|
|
120
397
|
save("B");
|
|
@@ -159,7 +436,11 @@ it("without a history on the server an edit still saves, and there is nothing to
|
|
|
159
436
|
});
|
|
160
437
|
|
|
161
438
|
/** Answers requests to `route` with `reply` instead of the engine, every other request as before; returns the undo. */
|
|
162
|
-
function answer(
|
|
439
|
+
function answer(
|
|
440
|
+
route: "/history" | "/history/step" | "/history/claim",
|
|
441
|
+
status: number,
|
|
442
|
+
reply: object | string,
|
|
443
|
+
) {
|
|
163
444
|
const real = globalThis.fetch;
|
|
164
445
|
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
|
|
165
446
|
url.endsWith(route)
|
|
@@ -230,6 +511,24 @@ it("a step whose reply cannot be read says so, instead of throwing", async () =>
|
|
|
230
511
|
});
|
|
231
512
|
});
|
|
232
513
|
|
|
514
|
+
it("an edit whose history reply cannot be read is still saved, and the reply is its own error", async () => {
|
|
515
|
+
const { hook, file, save } = await studio();
|
|
516
|
+
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
517
|
+
cleanup.push(() => logged.mockRestore());
|
|
518
|
+
answer("/history", 200, "<html>");
|
|
519
|
+
save("B");
|
|
520
|
+
await act(() =>
|
|
521
|
+
hook().recordEdit({
|
|
522
|
+
label: "Moved Title",
|
|
523
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
await vi.waitFor(() =>
|
|
527
|
+
expect(logged).toHaveBeenCalledWith("The history's reply was unreadable."),
|
|
528
|
+
);
|
|
529
|
+
expect(file()).toBe("B");
|
|
530
|
+
});
|
|
531
|
+
|
|
233
532
|
it("a step that cannot reach the server says so", async () => {
|
|
234
533
|
const { hook, readFile } = await studio();
|
|
235
534
|
const real = globalThis.fetch;
|