@hyperframes/studio 0.8.47 → 0.8.49

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 (123) hide show
  1. package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
  2. package/dist/assets/index-B9LfWNyM.css +1 -0
  3. package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
  4. package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
  5. package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
  6. package/dist/index.d.ts +70 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1452 -918
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +17 -26
  12. package/src/components/AskAgentModal.tsx +9 -7
  13. package/src/components/TimelineToolbar.tsx +26 -1
  14. package/src/components/editor/PropertyPanelFlat.tsx +8 -8
  15. package/src/components/editor/domEditing.ts +7 -1
  16. package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
  17. package/src/components/editor/domEditingAgentPrompt.ts +136 -34
  18. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  19. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  20. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  21. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  22. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  23. package/src/components/editor/useVolumeAutomation.ts +23 -2
  24. package/src/components/panels/VariablesPanel.tsx +5 -8
  25. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  27. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  28. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  29. package/src/contexts/FileManagerContext.tsx +0 -3
  30. package/src/hooks/appHotkeysDispatch.ts +256 -0
  31. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  32. package/src/hooks/domEditDeleteMembers.ts +19 -0
  33. package/src/hooks/useActiveComposition.ts +69 -0
  34. package/src/hooks/useAppHotkeys.test.ts +23 -1
  35. package/src/hooks/useAppHotkeys.ts +6 -239
  36. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  37. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -2
  39. package/src/hooks/useDomEditCommits.ts +13 -15
  40. package/src/hooks/useDomEditSession.test.tsx +90 -1
  41. package/src/hooks/useDomEditSession.ts +31 -34
  42. package/src/hooks/useDomSelectionTypes.ts +7 -2
  43. package/src/hooks/useElementLifecycleOps.ts +0 -8
  44. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  45. package/src/hooks/useFileManager.ts +0 -2
  46. package/src/hooks/useFileTree.test.tsx +214 -0
  47. package/src/hooks/useFileTree.ts +66 -24
  48. package/src/hooks/useGroupCommits.test.ts +98 -0
  49. package/src/hooks/useGroupCommits.ts +66 -9
  50. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  51. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  52. package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
  53. package/src/hooks/useTimelineAssetDropOps.ts +24 -18
  54. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  55. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  56. package/src/hooks/useTimelineEditing.ts +1 -0
  57. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  58. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  59. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  60. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  61. package/src/index.ts +10 -0
  62. package/src/player/components/Timeline.test.ts +0 -4
  63. package/src/player/components/Timeline.tsx +0 -1
  64. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  65. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  66. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  67. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  68. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  69. package/src/player/components/automationLaneData.test.ts +11 -3
  70. package/src/player/components/automationLaneData.ts +5 -4
  71. package/src/player/components/automationLaneGeometry.ts +15 -0
  72. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  73. package/src/player/components/timelineClipDragCommit.ts +18 -49
  74. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  75. package/src/player/components/timelineClipDragPreview.ts +8 -1
  76. package/src/player/components/timelineCollision.test.ts +23 -0
  77. package/src/player/components/timelineCollision.ts +27 -0
  78. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  79. package/src/player/components/timelineDragDrop.ts +9 -25
  80. package/src/player/components/timelineDragLanding.ts +108 -0
  81. package/src/player/components/timelineDropReveal.test.ts +34 -0
  82. package/src/player/components/timelineDropReveal.ts +12 -0
  83. package/src/player/components/timelineGapCommit.test.ts +96 -0
  84. package/src/player/components/timelineGapCommit.ts +56 -11
  85. package/src/player/components/timelineGaps.ts +2 -2
  86. package/src/player/components/timelineLayout.test.ts +0 -1
  87. package/src/player/components/timelineLayout.ts +1 -6
  88. package/src/player/components/timelineZones.test.ts +20 -1
  89. package/src/player/components/timelineZones.ts +10 -3
  90. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  91. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  92. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  93. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  94. package/src/player/components/useTimelineGeometry.ts +7 -2
  95. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  96. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  98. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  99. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  100. package/src/player/lib/timelineElementHelpers.ts +8 -0
  101. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  103. package/src/player/store/playerStore.ts +16 -21
  104. package/src/player/store/readinessSlice.ts +4 -3
  105. package/src/utils/blockInstaller.test.ts +4 -0
  106. package/src/utils/blockInstaller.ts +9 -3
  107. package/src/utils/razorSplitTransaction.test.ts +10 -0
  108. package/src/utils/razorSplitTransaction.ts +5 -0
  109. package/src/utils/retryBudget.ts +20 -0
  110. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  111. package/src/utils/sdkCutover.test.ts +51 -0
  112. package/src/utils/sdkCutover.ts +7 -4
  113. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  114. package/src/utils/sdkCutoverEligibility.ts +34 -11
  115. package/src/utils/studioHelpers.test.ts +18 -0
  116. package/src/utils/studioHelpers.ts +41 -5
  117. package/src/utils/studioUiPreferences.test.ts +14 -0
  118. package/src/utils/studioUiPreferences.ts +6 -0
  119. package/src/utils/studioUrlState.ts +15 -7
  120. package/src/utils/timelineCompositionInsert.ts +2 -1
  121. package/src/utils/timelineInspector.ts +10 -0
  122. package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
  123. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import { trackStudioEvent } from "../utils/studioTelemetry";
3
3
  import { isAudioDomElement } from "../utils/timelineInspector";
4
- import type { SelectElementOptions, TimelineElement } from "../player";
4
+ import type { TimelineElement } from "../player";
5
5
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
6
6
  import type { RightPanelTab } from "../utils/studioHelpers";
7
7
  import type { PatchTarget } from "../utils/sourcePatcher";
@@ -21,25 +21,15 @@ import { useGsapAwareEditing } from "./useGsapAwareEditing";
21
21
  import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
22
22
  import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
23
23
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
24
- import { membersForDelete } from "./domEditDeleteMembers";
24
+ import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
25
25
  import type { RecordEditInput } from "./domEditDeleteMembers";
26
+ import type { DomEditTimelineParams } from "./useDomSelectionTypes";
26
27
  // Re-exported: the delete rule lives in its own module now, and callers (and its
27
28
  // own test) have always imported it from here.
28
29
  export { membersForDelete };
29
30
 
30
- export interface UseDomEditSessionParams {
31
- projectId: string | null;
32
- activeCompPath: string | null;
33
- compIdToSrc: Map<string, string>;
34
- captionEditMode: boolean;
31
+ export interface UseDomEditSessionParams extends DomEditTimelineParams {
35
32
  compositionLoading: boolean;
36
- previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
37
- timelineElements: TimelineElement[];
38
- getTimelineSelectionSet: () => ReadonlySet<string>;
39
- setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
40
- setTimelineSelectionSet: (ids: Set<string>) => void;
41
- setRightCollapsed: (collapsed: boolean) => void;
42
- setRightPanelTab: (tab: RightPanelTab) => void;
43
33
  showToast: (message: string, tone?: "error" | "info") => void;
44
34
  isRecordingRef?: React.RefObject<boolean>;
45
35
  refreshPreviewDocumentVersion: () => void;
@@ -68,6 +58,9 @@ export interface UseDomEditSessionParams {
68
58
  sdkSession?: Composition | null;
69
59
  publishSdkSession?: PublishSdkSession;
70
60
  forceReloadSdkSession?: () => void;
61
+ /** The timeline context menu's delete op — a canvas selection that IS a
62
+ * timeline row hands off here instead of the REST remove-elements path. */
63
+ handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
71
64
  }
72
65
 
73
66
  export function useDomEditSession({
@@ -109,6 +102,7 @@ export function useDomEditSession({
109
102
  sdkSession,
110
103
  publishSdkSession,
111
104
  forceReloadSdkSession,
105
+ handleTimelineElementsDelete,
112
106
  }: UseDomEditSessionParams) {
113
107
  const isMasterView = !activeCompPath || activeCompPath === "index.html";
114
108
  void _setRefreshKey;
@@ -260,11 +254,9 @@ export function useDomEditSession({
260
254
  forceReloadSdkSession,
261
255
  onTrySdkPersist: sdkSession
262
256
  ? (selection, operations, originalContent, targetPath, options) => {
263
- // Resolver shadow runs regardless of the cutover flag — decoupled tripwire.
264
- // Pass originalContent so the runtime-node filter can suppress hf-ids
265
- // absent from source (script-created nodes the SDK can't model), and
266
- // the paths so cross-file edits (session models only the active comp)
267
- // skip instead of emitting structural element_not_found noise.
257
+ // Decoupled tripwire, runs regardless of the cutover flag. originalContent lets
258
+ // the runtime-node filter suppress hf-ids absent from source (script-created
259
+ // nodes); the paths let a cross-file edit skip instead of a false not-found.
268
260
  runResolverShadow(sdkSession, selection.hfId, operations, originalContent, {
269
261
  targetPath,
270
262
  compositionPath: activeCompPath,
@@ -298,9 +290,8 @@ export function useDomEditSession({
298
290
  publishSession: publishSdkSession,
299
291
  })
300
292
  : undefined,
301
- // Resolver shadow for the z-index reorder edit: it takes the server path (no
302
- // SDK persist), but the tripwire is decoupled from cutover — record whether
303
- // the SDK resolves each reordered element (the reorderElements op's targets).
293
+ // Z-index reorder takes the server path (no SDK persist); this decoupled
294
+ // tripwire still records whether the SDK resolves each reordered target.
304
295
  onReorderShadow: sdkSession
305
296
  ? (targets: string[]) => {
306
297
  // Single-flight: every target in one reorder batch shares the same file, so
@@ -326,21 +317,33 @@ export function useDomEditSession({
326
317
  reloadPreview,
327
318
  clearDomSelection,
328
319
  forceReloadSdkSession,
320
+ timelineElements,
329
321
  });
330
322
 
331
323
  const handleDomEditElementDelete = useCallback(
332
324
  async (selection: DomEditSelection, options?: { expandGroup?: boolean }) => {
333
- // Same structural edit the timeline delete refuses mid-recording, so it
334
- // refuses here too — this is now the path a Delete press takes whenever
335
- // the canvas holds a selection.
325
+ // Same structural edit the timeline delete refuses mid-recording.
336
326
  if (isRecordingRef?.current) {
337
327
  showToast("Cannot edit timeline while recording", "error");
338
328
  return;
339
329
  }
340
330
  const members = membersForDelete(selection, domEditGroupSelectionsRef.current, options);
331
+ // A selection that is itself timeline rows shares the context menu's delete op.
332
+ const timelineTargets = timelineElementsForDelete(members, timelineElements);
333
+ if (timelineTargets) {
334
+ await handleTimelineElementsDelete(timelineTargets);
335
+ return;
336
+ }
341
337
  await handleDomEditElementsDelete(members);
342
338
  },
343
- [domEditGroupSelectionsRef, handleDomEditElementsDelete, isRecordingRef, showToast],
339
+ [
340
+ domEditGroupSelectionsRef,
341
+ handleDomEditElementsDelete,
342
+ handleTimelineElementsDelete,
343
+ isRecordingRef,
344
+ showToast,
345
+ timelineElements,
346
+ ],
344
347
  );
345
348
 
346
349
  const handleGroupSelection = useCallback(() => {
@@ -351,14 +354,8 @@ export function useDomEditSession({
351
354
  showToast("Select at least 2 elements to group", "info");
352
355
  return;
353
356
  }
354
- // A layout group is a positioned wrapper: it takes the members' bounding
355
- // box, rebases each child's left/top against it, and adopts the topmost
356
- // z-index. An <audio> clip has no box — offsetWidth/Height are 0 — so
357
- // grouping audio produced a 0x0 div with inline left/top written onto
358
- // elements that have never been laid out, and the timeline gained a
359
- // wrapper standing for nothing audible. The audio answer to "these clips
360
- // belong together" is an <hf-audio-group> bus, which the timeline's own FX
361
- // pointer creates, so the refusal names it rather than just declining.
357
+ // A layout group takes the members' bounding box; audio has no box (0x0),
358
+ // so it groups into an <hf-audio-group> bus via the track's FX pointer instead.
362
359
  if (members.some((m) => isAudioDomElement(m.element))) {
363
360
  showToast(
364
361
  members.every((m) => isAudioDomElement(m.element))
@@ -20,10 +20,11 @@ export interface ResolveDomSelectionOptions {
20
20
  exactTarget?: boolean;
21
21
  }
22
22
 
23
- export interface UseDomSelectionParams {
23
+ // Shared by every hook that needs the project/timeline/panel context a DOM
24
+ // edit session is built around — useDomEditSession has its own superset.
25
+ export interface DomEditTimelineParams {
24
26
  projectId: string | null;
25
27
  activeCompPath: string | null;
26
- isMasterView: boolean;
27
28
  compIdToSrc: Map<string, string>;
28
29
  captionEditMode: boolean;
29
30
  previewIframeRef: MutableRefObject<HTMLIFrameElement | null>;
@@ -34,6 +35,10 @@ export interface UseDomSelectionParams {
34
35
  setTimelineSelectionSet: (ids: Set<string>) => void;
35
36
  setRightCollapsed: (collapsed: boolean) => void;
36
37
  setRightPanelTab: (tab: RightPanelTab) => void;
38
+ }
39
+
40
+ export interface UseDomSelectionParams extends DomEditTimelineParams {
41
+ isMasterView: boolean;
37
42
  previewIframe: HTMLIFrameElement | null;
38
43
  refreshKey: number;
39
44
  rightPanelTab: RightPanelTab;
@@ -389,15 +389,7 @@ export function useElementLifecycleOps({
389
389
  [commitDomEditPatchBatches, onReorderShadow],
390
390
  );
391
391
 
392
- const handleDomEditElementDelete = useCallback(
393
- async (selection: DomEditSelection) => {
394
- await handleDomEditElementsDelete([selection]);
395
- },
396
- [handleDomEditElementsDelete],
397
- );
398
-
399
392
  return {
400
- handleDomEditElementDelete,
401
393
  handleDomEditElementsDelete,
402
394
  handleDomZIndexReorderCommit,
403
395
  };
@@ -7,7 +7,6 @@ vi.mock("./useFileTree", () => ({
7
7
  useFileTree: () => ({
8
8
  projectDir: "",
9
9
  fileTree: [],
10
- setFileTree: vi.fn(),
11
10
  fileTreeLoaded: true,
12
11
  refreshFileTree: vi.fn(async () => {}),
13
12
  compositions: [],
@@ -67,7 +67,6 @@ export function useFileManager({
67
67
  const {
68
68
  projectDir,
69
69
  fileTree,
70
- setFileTree,
71
70
  fileTreeLoaded,
72
71
  refreshFileTree,
73
72
  compositions,
@@ -501,7 +500,6 @@ export function useFileManager({
501
500
  projectDir,
502
501
  fileTree,
503
502
  fileTreeLoaded,
504
- setFileTree,
505
503
 
506
504
  // Refs
507
505
  editingPathRef,
@@ -0,0 +1,214 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act, useImperativeHandle, useRef, forwardRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useFileTree } from "./useFileTree";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ let root: Root | null = null;
11
+
12
+ afterEach(() => {
13
+ if (root) act(() => root?.unmount());
14
+ root = null;
15
+ });
16
+
17
+ interface Handle {
18
+ compositions: string[];
19
+ refresh: () => Promise<void>;
20
+ }
21
+
22
+ const Harness = forwardRef<Handle, { projectId: string }>(function Harness({ projectId }, ref) {
23
+ const projectIdRef = useRef(projectId);
24
+ projectIdRef.current = projectId;
25
+ const { compositions, refreshFileTree } = useFileTree({ projectId, projectIdRef });
26
+ useImperativeHandle(ref, () => ({ compositions, refresh: refreshFileTree }), [
27
+ compositions,
28
+ refreshFileTree,
29
+ ]);
30
+ return null;
31
+ });
32
+
33
+ async function renderHarness(projectId: string): Promise<{ current: Handle | null }> {
34
+ const handleRef = { current: null as Handle | null };
35
+ const host = document.createElement("div");
36
+ document.body.append(host);
37
+ root = createRoot(host);
38
+
39
+ await act(async () => {
40
+ root?.render(
41
+ <Harness
42
+ ref={(h) => {
43
+ handleRef.current = h;
44
+ }}
45
+ projectId={projectId}
46
+ />,
47
+ );
48
+ await Promise.resolve();
49
+ await Promise.resolve();
50
+ });
51
+
52
+ return handleRef;
53
+ }
54
+
55
+ describe("useFileTree.refreshFileTree", () => {
56
+ it("updates compositions, not just the raw file list, on refresh", async () => {
57
+ let call = 0;
58
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
59
+ call += 1;
60
+ // Initial load: one composition. After a refresh (e.g. a new file created),
61
+ // the server now reports a second one — this is the exact shape a refresh
62
+ // after creating/duplicating a composition produces.
63
+ const body =
64
+ call === 1
65
+ ? { files: ["index.html"], compositions: ["index.html"] }
66
+ : { files: ["index.html", "hero.html"], compositions: ["index.html", "hero.html"] };
67
+ return new Response(JSON.stringify(body), { status: 200 });
68
+ });
69
+
70
+ const handleRef = await renderHarness("project-a");
71
+ expect(handleRef.current?.compositions).toEqual(["index.html"]);
72
+
73
+ await act(async () => {
74
+ await handleRef.current?.refresh();
75
+ });
76
+ expect(handleRef.current?.compositions).toEqual(["index.html", "hero.html"]);
77
+
78
+ fetchSpy.mockRestore();
79
+ });
80
+
81
+ it("keeps the most-recently-issued refresh's data when an earlier one resolves later", async () => {
82
+ let call = 0;
83
+ const deferred: Array<(value: Response) => void> = [];
84
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
85
+ call += 1;
86
+ if (call === 1) {
87
+ return new Response(
88
+ JSON.stringify({ files: ["index.html"], compositions: ["index.html"] }),
89
+ {
90
+ status: 200,
91
+ },
92
+ );
93
+ }
94
+ return new Promise<Response>((resolve) => {
95
+ deferred.push(resolve);
96
+ });
97
+ });
98
+
99
+ const handleRef = await renderHarness("project-a");
100
+
101
+ let firstDone = false;
102
+ let secondDone = false;
103
+ act(() => {
104
+ void handleRef.current?.refresh().then(() => {
105
+ firstDone = true;
106
+ });
107
+ void handleRef.current?.refresh().then(() => {
108
+ secondDone = true;
109
+ });
110
+ });
111
+
112
+ // The second (later-issued) request resolves first, with its own data.
113
+ await act(async () => {
114
+ deferred[1](
115
+ new Response(
116
+ JSON.stringify({
117
+ files: ["index.html", "second.html"],
118
+ compositions: ["index.html", "second.html"],
119
+ }),
120
+ {
121
+ status: 200,
122
+ },
123
+ ),
124
+ );
125
+ await vi.waitFor(() => secondDone);
126
+ });
127
+ expect(handleRef.current?.compositions).toEqual(["index.html", "second.html"]);
128
+
129
+ // The first (earlier-issued, now stale) request resolves after — it must not win.
130
+ await act(async () => {
131
+ deferred[0](
132
+ new Response(
133
+ JSON.stringify({
134
+ files: ["index.html", "stale.html"],
135
+ compositions: ["index.html", "stale.html"],
136
+ }),
137
+ {
138
+ status: 200,
139
+ },
140
+ ),
141
+ );
142
+ await vi.waitFor(() => firstDone);
143
+ });
144
+ expect(handleRef.current?.compositions).toEqual(["index.html", "second.html"]);
145
+
146
+ fetchSpy.mockRestore();
147
+ });
148
+
149
+ it("aborts the superseded request's fetch when a newer refresh is issued", async () => {
150
+ let call = 0;
151
+ const signals: AbortSignal[] = [];
152
+ const fetchSpy = vi
153
+ .spyOn(globalThis, "fetch")
154
+ .mockImplementation(async (_url, init?: RequestInit) => {
155
+ call += 1;
156
+ if (call === 1) {
157
+ return new Response(
158
+ JSON.stringify({ files: ["index.html"], compositions: ["index.html"] }),
159
+ { status: 200 },
160
+ );
161
+ }
162
+ if (init?.signal) signals.push(init.signal);
163
+ return new Promise<Response>(() => {});
164
+ });
165
+
166
+ const handleRef = await renderHarness("project-a");
167
+
168
+ act(() => {
169
+ void handleRef.current?.refresh();
170
+ });
171
+ await act(async () => {
172
+ await Promise.resolve();
173
+ });
174
+ expect(signals).toHaveLength(1);
175
+ expect(signals[0].aborted).toBe(false);
176
+
177
+ act(() => {
178
+ void handleRef.current?.refresh();
179
+ });
180
+ await act(async () => {
181
+ await Promise.resolve();
182
+ });
183
+
184
+ expect(signals).toHaveLength(2);
185
+ expect(signals[0].aborted).toBe(true);
186
+ expect(signals[1].aborted).toBe(false);
187
+
188
+ fetchSpy.mockRestore();
189
+ });
190
+
191
+ it("keeps the prior compositions list when a refresh response omits the field", async () => {
192
+ let call = 0;
193
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
194
+ call += 1;
195
+ const body =
196
+ call === 1
197
+ ? { files: ["index.html"], compositions: ["index.html"] }
198
+ : { files: ["index.html", "hero.html"] };
199
+ return new Response(JSON.stringify(body), { status: 200 });
200
+ });
201
+
202
+ const handleRef = await renderHarness("project-a");
203
+ expect(handleRef.current?.compositions).toEqual(["index.html"]);
204
+
205
+ await act(async () => {
206
+ await handleRef.current?.refresh();
207
+ });
208
+ // `files` grew but the response carried no `compositions` field — the known-good
209
+ // list must survive, not collapse to empty.
210
+ expect(handleRef.current?.compositions).toEqual(["index.html"]);
211
+
212
+ fetchSpy.mockRestore();
213
+ });
214
+ });
@@ -1,5 +1,5 @@
1
1
  import { buildProjectApiPath } from "../utils/projectRouting";
2
- import { useState, useCallback, useEffect, useMemo } from "react";
2
+ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
3
3
  import { FONT_EXT } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import { captureProjectProvenance } from "../components/feedback/projectProvenance";
@@ -9,52 +9,95 @@ interface UseFileTreeOptions {
9
9
  projectIdRef: React.RefObject<string | null>;
10
10
  }
11
11
 
12
+ interface FetchedFileTree {
13
+ projectId: string;
14
+ loaded: boolean;
15
+ fileTree: string[];
16
+ compositionPaths: string[];
17
+ projectDir: string | null;
18
+ }
19
+
20
+ // Stable references so a mismatched projectId doesn't create a new array every render
21
+ // and defeat useMemo/useCallback deps downstream (e.g. the `assets` memo below).
22
+ const EMPTY_FILE_LIST: string[] = [];
23
+
24
+ /**
25
+ * Holds the tree with the `projectId` it was fetched for; exposes empty/not-loaded
26
+ * whenever that id doesn't match the current one, so a switch can't read another project's tree.
27
+ */
12
28
  export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
13
- const [projectDir, setProjectDir] = useState<string | null>(null);
14
- const [fileTree, setFileTree] = useState<string[]>([]);
15
- const [compositionPaths, setCompositionPaths] = useState<string[]>([]);
16
- const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
29
+ const [fetched, setFetched] = useState<FetchedFileTree | null>(null);
30
+ const current = fetched?.projectId === projectId ? fetched : null;
31
+ const fileTree = current?.fileTree ?? EMPTY_FILE_LIST;
32
+ const compositionPaths = current?.compositionPaths ?? EMPTY_FILE_LIST;
33
+ const projectDir = current?.projectDir ?? null;
34
+ const fileTreeLoaded = current?.loaded ?? false;
17
35
 
18
36
  // eslint-disable-next-line no-restricted-syntax
19
37
  useEffect(() => {
20
- if (!projectId) {
21
- setFileTreeLoaded(false);
22
- return;
23
- }
38
+ if (!projectId) return;
24
39
  let cancelled = false;
25
- setFileTreeLoaded(false);
40
+ setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
26
41
  fetch(buildProjectApiPath(projectId))
27
42
  .then((r) => r.json())
28
43
  .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
29
44
  if (cancelled) return;
30
- if (data.files) setFileTree(data.files);
31
- if (data.compositions) setCompositionPaths(data.compositions);
32
- setProjectDir(typeof data.dir === "string" ? data.dir : null);
45
+ setFetched({
46
+ projectId,
47
+ loaded: true,
48
+ fileTree: data.files ?? [],
49
+ compositionPaths: data.compositions ?? [],
50
+ projectDir: typeof data.dir === "string" ? data.dir : null,
51
+ });
33
52
  // Snapshot how this project was made, while the listing is in hand and
34
53
  // the app is still alive. A crash later has no other way to learn it.
35
54
  void captureProjectProvenance(projectId, data.files ?? [], data.compositions ?? []);
36
55
  })
37
56
  .catch(() => {
38
- if (!cancelled) setProjectDir(null);
39
- })
40
- .finally(() => {
41
- if (!cancelled) setFileTreeLoaded(true);
57
+ if (!cancelled) {
58
+ setFetched((prev) => (prev?.projectId === projectId ? { ...prev, loaded: true } : prev));
59
+ }
42
60
  });
43
61
  return () => {
44
62
  cancelled = true;
45
63
  };
46
64
  }, [projectId]);
47
65
 
66
+ const refreshRequestRef = useRef(0);
67
+ const refreshAbortRef = useRef<AbortController | null>(null);
68
+
48
69
  const refreshFileTree = useCallback(async () => {
49
70
  const pid = projectIdRef.current;
50
71
  if (!pid) return;
51
- const res = await fetch(buildProjectApiPath(pid));
52
- const data = await res.json();
53
- if (data.files) setFileTree(data.files);
72
+ // Same id+abort pair as useFileManager.ts's openSourceForSelection: cancel the
73
+ // superseded request instead of letting it complete and discarding the result.
74
+ refreshAbortRef.current?.abort();
75
+ const requestId = ++refreshRequestRef.current;
76
+ const controller = new AbortController();
77
+ refreshAbortRef.current = controller;
78
+ let data: { files?: string[]; compositions?: string[] };
79
+ try {
80
+ const res = await fetch(buildProjectApiPath(pid), { signal: controller.signal });
81
+ data = await res.json();
82
+ } catch (err) {
83
+ if (err instanceof DOMException && err.name === "AbortError") return;
84
+ throw err;
85
+ }
86
+ if (data.files && requestId === refreshRequestRef.current) {
87
+ setFetched((prev) =>
88
+ prev?.projectId === pid
89
+ ? {
90
+ ...prev,
91
+ fileTree: data.files ?? prev.fileTree,
92
+ // A response that omits `compositions` must not wipe out a
93
+ // known-good list — only replace it when the field is present.
94
+ compositionPaths: data.compositions ?? prev.compositionPaths,
95
+ }
96
+ : prev,
97
+ );
98
+ }
54
99
  }, [projectIdRef]);
55
100
 
56
- const compositions = compositionPaths;
57
-
58
101
  const assets = useMemo(
59
102
  () =>
60
103
  fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
@@ -76,10 +119,9 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
76
119
  return {
77
120
  projectDir,
78
121
  fileTree,
79
- setFileTree,
80
122
  fileTreeLoaded,
81
123
  refreshFileTree,
82
- compositions,
124
+ compositions: compositionPaths,
83
125
  assets,
84
126
  fontAssets,
85
127
  };
@@ -0,0 +1,98 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import type { TimelineElement } from "../player";
4
+ import { computeGroupGeometry, resolveGroupChildTracks } from "./useGroupCommits";
5
+ import { makeSelection } from "./domSelectionTestHarness";
6
+
7
+ const timelineElement = (over: Partial<TimelineElement> = {}): TimelineElement => ({
8
+ id: "clip",
9
+ domId: "clip",
10
+ tag: "div",
11
+ start: 0,
12
+ duration: 4,
13
+ track: 0,
14
+ timingSource: "authored",
15
+ sourceFile: "index.html",
16
+ ...over,
17
+ });
18
+
19
+ describe("computeGroupGeometry — track threading", () => {
20
+ it("carries each member's authored track onto its rebase entry, so the server can pin it through the wrap", () => {
21
+ const elA = document.createElement("div");
22
+ elA.id = "clip-a";
23
+ const elB = document.createElement("div");
24
+ elB.id = "clip-b";
25
+ const members = [makeSelection("A", elA), makeSelection("B", elB)];
26
+ const timelineElements = [
27
+ timelineElement({ id: "clip-a", domId: "clip-a", authoredTrack: 2 }),
28
+ timelineElement({ id: "clip-b", domId: "clip-b" }),
29
+ ];
30
+
31
+ const { rebases } = computeGroupGeometry(members, timelineElements);
32
+
33
+ expect(rebases[0]?.track).toBe(2);
34
+ expect(rebases[1]?.track).toBe(0);
35
+ });
36
+ });
37
+
38
+ describe("resolveGroupChildTracks", () => {
39
+ it("resolves each child's authored track by DOM id, so ungroup can pin it through the unwrap", () => {
40
+ const group = document.createElement("div");
41
+ group.id = "group-1";
42
+ const childA = document.createElement("div");
43
+ childA.id = "child-a";
44
+ const childB = document.createElement("div");
45
+ childB.id = "child-b";
46
+ group.append(childA, childB);
47
+
48
+ const timelineElements = [
49
+ timelineElement({ id: "child-a", domId: "child-a", authoredTrack: 1 }),
50
+ timelineElement({ id: "child-b", domId: "child-b" }),
51
+ ];
52
+
53
+ const result = resolveGroupChildTracks(makeSelection("Group 1", group), timelineElements);
54
+
55
+ expect(result).toEqual([
56
+ { target: { id: "child-a", hfId: undefined }, track: 1 },
57
+ { target: { id: "child-b", hfId: undefined }, track: 0 },
58
+ ]);
59
+ });
60
+
61
+ it("resolves a child's track by hfId when it has no DOM id, so an unauthored ungroup child can't drift to a new row", () => {
62
+ const group = document.createElement("div");
63
+ group.id = "group-1";
64
+ const child = document.createElement("div");
65
+ child.setAttribute("data-hf-id", "hf-child-a");
66
+ group.append(child);
67
+
68
+ const timelineElements = [timelineElement({ hfId: "hf-child-a", authoredTrack: 3 })];
69
+
70
+ const result = resolveGroupChildTracks(makeSelection("Group 1", group), timelineElements);
71
+
72
+ expect(result).toEqual([{ target: { id: undefined, hfId: "hf-child-a" }, track: 3 }]);
73
+ });
74
+
75
+ it("skips a child with neither a DOM id nor an hfId", () => {
76
+ const group = document.createElement("div");
77
+ const anonymous = document.createElement("div");
78
+ group.append(anonymous);
79
+
80
+ const result = resolveGroupChildTracks(makeSelection("Group 1", group), []);
81
+
82
+ expect(result).toEqual([]);
83
+ });
84
+
85
+ it("falls back to the runtime's resolved track for an implicit child with no authoredTrack", () => {
86
+ const group = document.createElement("div");
87
+ group.id = "group-1";
88
+ const child = document.createElement("div");
89
+ child.id = "child-a";
90
+ group.append(child);
91
+
92
+ const timelineElements = [timelineElement({ id: "child-a", domId: "child-a", track: 3 })];
93
+
94
+ const result = resolveGroupChildTracks(makeSelection("Group 1", group), timelineElements);
95
+
96
+ expect(result).toEqual([{ target: { id: "child-a", hfId: undefined }, track: 3 }]);
97
+ });
98
+ });