@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.
Files changed (163) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
  3. package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +132 -41
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1184 -1084
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/DomEditOverlay.tsx +1 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  17. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  18. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  19. package/src/components/editor/domGroupMoveBy.ts +95 -0
  20. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  21. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  22. package/src/components/editor/manualEditingAvailability.ts +8 -0
  23. package/src/components/editor/manualOffsetDrag.ts +8 -9
  24. package/src/components/editor/motionPathSelection.test.ts +6 -31
  25. package/src/components/editor/plainTranslate.ts +5 -3
  26. package/src/components/editor/probeSourceElement.ts +2 -1
  27. package/src/components/editor/propertyPanelFont.tsx +3 -2
  28. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  29. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  30. package/src/components/editor/rotationDraft.ts +2 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -1
  32. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  33. package/src/components/editor/useFxCarveNodes.ts +2 -1
  34. package/src/components/editor/useFxLevelling.ts +2 -1
  35. package/src/components/feedback/projectProvenance.ts +2 -1
  36. package/src/components/nle/NLEContext.test.ts +1 -0
  37. package/src/components/nle/NLEContext.tsx +2 -1
  38. package/src/components/nle/NLEPreview.tsx +12 -10
  39. package/src/components/nle/PreviewPoster.tsx +2 -1
  40. package/src/components/renders/useFfmpegStatus.ts +2 -1
  41. package/src/components/renders/useRenderQueue.ts +5 -4
  42. package/src/components/sidebar/AssetsTab.tsx +2 -1
  43. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  44. package/src/contexts/DomEditContext.tsx +4 -0
  45. package/src/hooks/domSelectionTestHarness.ts +23 -0
  46. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  47. package/src/hooks/domStyleCommit.ts +8 -1
  48. package/src/hooks/draggedGsapPosition.ts +10 -0
  49. package/src/hooks/gsapDragCommit.test.ts +75 -164
  50. package/src/hooks/gsapDragCommit.ts +2 -105
  51. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  52. package/src/hooks/gsapEditOutcome.ts +22 -2
  53. package/src/hooks/gsapGroupPreflight.ts +34 -0
  54. package/src/hooks/gsapMutationClient.ts +4 -3
  55. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  56. package/src/hooks/gsapParsedTween.test.ts +62 -0
  57. package/src/hooks/gsapParsedTween.ts +243 -0
  58. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  59. package/src/hooks/gsapPositionDetection.ts +52 -33
  60. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  61. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  62. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  63. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  64. package/src/hooks/gsapResizeIntercept.ts +79 -169
  65. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  66. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  67. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  68. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  69. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  70. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  71. package/src/hooks/gsapScriptCommitTypes.ts +9 -1
  72. package/src/hooks/gsapShared.ts +7 -0
  73. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  74. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  75. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  76. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  77. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  78. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  79. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  80. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  81. package/src/hooks/newTweenTarget.test.ts +41 -76
  82. package/src/hooks/plainRotation.ts +5 -1
  83. package/src/hooks/timelineTimingSync.test.ts +1 -0
  84. package/src/hooks/timelineTimingSync.ts +2 -1
  85. package/src/hooks/useAskAgentModal.ts +2 -1
  86. package/src/hooks/useBlockCatalog.ts +2 -1
  87. package/src/hooks/useCaptionDetection.ts +2 -1
  88. package/src/hooks/useCompositionContentLoader.ts +2 -1
  89. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  90. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  91. package/src/hooks/useDomEditSession.test.tsx +30 -1
  92. package/src/hooks/useDomEditSession.ts +10 -0
  93. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  94. package/src/hooks/useDomEditTextCommits.ts +8 -2
  95. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  96. package/src/hooks/useDomGeometryCommit.ts +7 -9
  97. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  98. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  99. package/src/hooks/useEditHistoryActions.ts +16 -5
  100. package/src/hooks/useElementLifecycleOps.ts +2 -1
  101. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  102. package/src/hooks/useEnableKeyframes.ts +2 -1
  103. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  104. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  105. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  106. package/src/hooks/useFileManager.ts +14 -10
  107. package/src/hooks/useFileTree.ts +3 -2
  108. package/src/hooks/useFrameCapture.ts +5 -1
  109. package/src/hooks/useFreezeFrame.test.ts +39 -3
  110. package/src/hooks/useFreezeFrame.ts +7 -3
  111. package/src/hooks/useGroupCommits.ts +2 -1
  112. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  113. package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
  114. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  115. package/src/hooks/useGsapAwareEditing.ts +17 -8
  116. package/src/hooks/useGsapAwareGroupMove.ts +18 -18
  117. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  118. package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
  119. package/src/hooks/useGsapScriptCommits.ts +21 -17
  120. package/src/hooks/useLintModal.ts +2 -1
  121. package/src/hooks/usePersistentEditHistory.test.ts +302 -3
  122. package/src/hooks/usePersistentEditHistory.ts +50 -17
  123. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  124. package/src/hooks/usePreviewPersistence.ts +13 -11
  125. package/src/hooks/useProjectFileWriter.ts +20 -19
  126. package/src/hooks/useRemoveBackground.ts +2 -1
  127. package/src/hooks/useSdkSession.ts +2 -1
  128. package/src/hooks/useServerConnection.ts +4 -3
  129. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  130. package/src/hooks/useThumbnailStill.test.ts +100 -0
  131. package/src/hooks/useThumbnailStill.ts +37 -0
  132. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  133. package/src/index.ts +4 -0
  134. package/src/player/components/AudioWaveform.tsx +3 -2
  135. package/src/player/components/CompositionThumbnail.tsx +2 -1
  136. package/src/player/components/clipMenuNormalize.ts +2 -1
  137. package/src/player/components/clipPeakMap.ts +2 -1
  138. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  139. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  140. package/src/player/lib/thumbnailScheduler.ts +10 -2
  141. package/src/player/sceneSwap.ts +2 -1
  142. package/src/telemetry/client.ts +2 -1
  143. package/src/utils/blockInstaller.ts +2 -1
  144. package/src/utils/elementGsap.test.ts +9 -0
  145. package/src/utils/elementGsap.ts +7 -0
  146. package/src/utils/gsapSoftReload.test.ts +39 -0
  147. package/src/utils/gsapSoftReload.ts +9 -14
  148. package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
  149. package/src/utils/gsapUndoRestore.ts +3 -6
  150. package/src/utils/previewPrefetch.ts +2 -1
  151. package/src/utils/razorSplitTransaction.ts +12 -8
  152. package/src/utils/softReloadTargets.ts +5 -6
  153. package/src/utils/studioApiFetch.test.ts +75 -0
  154. package/src/utils/studioApiFetch.ts +13 -0
  155. package/src/utils/studioFileHistory.ts +5 -1
  156. package/src/utils/studioHelpers.ts +7 -1
  157. package/src/utils/studioPendingEdits.test.ts +23 -6
  158. package/src/utils/studioPendingEdits.ts +32 -8
  159. package/src/utils/studioTelemetry.ts +2 -1
  160. package/src/utils/timelineCompositionInsert.ts +3 -2
  161. package/src/webmcp/StudioAgentTools.tsx +2 -1
  162. package/dist/chunk-TXZMMOMA.js.map +0 -1
  163. /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 { MutationResult } from "./gsapScriptCommitTypes";
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 type {
32
- CommitMutation,
33
- CommitMutationCall,
34
- CommitMutationOptions,
35
- GsapScriptCommitsParams,
36
- MutationResult,
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 fetch(
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 fetch(
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
- let needsFallback = options.previewFallbackLatch?.pending === true;
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
- // Each call brings its own fast-path patch; the batch wrote them all, so the
412
- // preview sync applies them all rather than just the last call's.
413
- const instantPatches = calls
414
- .map(({ options: callOptions }) => callOptions.instantPatch)
415
- .filter((patch) => patch !== undefined);
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
- await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, instantPatches.length > 0 ? { ...batchOptions, instantPatches } : batchOptions);
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.sourceFile || activeCompPath || "index.html";
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]?.selection.sourceFile || activeCompPath || "index.html";
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 fetch(buildProjectApiPath(projectId, `/lint`));
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(route: "/history/step" | "/history/claim", status: number, reply: object | string) {
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;