@hyperframes/studio 0.8.53 → 0.8.54

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 (35) hide show
  1. package/dist/assets/{hyperframes-player-DKWM-Ons.js → hyperframes-player-Cy_AwUa8.js} +1 -1
  2. package/dist/assets/{index-UvW6BEwf.js → index-CTa-6Kso.js} +1 -1
  3. package/dist/assets/{index-HfP3sap2.js → index-DPUJoO8n.js} +191 -191
  4. package/dist/assets/{index-Y4OnpTEM.js → index-DkAzKuPZ.js} +1 -1
  5. package/dist/index.d.ts +440 -10
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1850 -1532
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/nle/TimelinePane.tsx +11 -1
  11. package/src/hooks/timelineAudioGroupCreate.ts +17 -1
  12. package/src/hooks/timelineAudioGroupVolume.ts +35 -2
  13. package/src/hooks/timelineEditPermission.ts +31 -0
  14. package/src/hooks/timelineElementFxAttribute.ts +34 -2
  15. package/src/hooks/useFileManager.ts +21 -123
  16. package/src/hooks/usePersistentEditHistory.ts +1 -1
  17. package/src/hooks/useProjectFileWriter.ts +150 -0
  18. package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
  19. package/src/hooks/useTimelineAssetDropOps.ts +40 -3
  20. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  21. package/src/hooks/useTimelineEditing.test.tsx +481 -4
  22. package/src/hooks/useTimelineEditing.ts +151 -27
  23. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  24. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  25. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  26. package/src/hostMount.test.tsx +222 -0
  27. package/src/index.ts +40 -2
  28. package/src/player/components/Timeline.tsx +6 -6
  29. package/src/player/components/TimelineTypes.ts +3 -0
  30. package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
  31. package/src/player/components/usePublishRangeSelection.ts +42 -0
  32. package/src/player/index.ts +1 -0
  33. package/src/player/store/playerStore.ts +8 -51
  34. package/src/player/store/rangeSelectionSlice.ts +13 -0
  35. package/src/player/store/timelineResetState.ts +51 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.53",
3
+ "version": "0.8.54",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/core": "0.8.53",
55
- "@hyperframes/sdk": "0.8.53",
56
- "@hyperframes/player": "0.8.53",
57
- "@hyperframes/parsers": "0.8.53",
58
- "@hyperframes/studio-server": "0.8.53"
54
+ "@hyperframes/core": "0.8.54",
55
+ "@hyperframes/parsers": "0.8.54",
56
+ "@hyperframes/player": "0.8.54",
57
+ "@hyperframes/studio-server": "0.8.54",
58
+ "@hyperframes/sdk": "0.8.54"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.53"
74
+ "@hyperframes/producer": "0.8.54"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
@@ -1,6 +1,6 @@
1
1
  import { useCallback, type ReactNode } from "react";
2
2
  import { Timeline } from "../../player";
3
- import type { TimelineElement } from "../../player";
3
+ import type { TimelineElement, TimelineTimeRange } from "../../player";
4
4
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
5
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
6
6
  import { trackStudioExpandedClipEdit } from "../../telemetry/events";
@@ -77,6 +77,9 @@ export interface TimelinePaneProps {
77
77
  timelineToolbar?: ReactNode;
78
78
  /** Slot rendered below the timeline tracks */
79
79
  timelineFooter?: ReactNode;
80
+ /** Slot for a host's own overlay spanning the whole timeline area (e.g. a draft/ghost
81
+ * clip), positioned absolutely the same way PreviewPane's previewOverlay is. */
82
+ timelineOverlay?: ReactNode;
80
83
  /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */
81
84
  renderClipContent?: (
82
85
  element: TimelineElement,
@@ -101,6 +104,7 @@ export interface TimelinePaneProps {
101
104
  ) => Promise<void> | void;
102
105
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
103
106
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
107
+ onRangeSelect?: (range: TimelineTimeRange | null) => void;
104
108
  /** Copy/paste/duplicate act on the store's own selection, not a passed
105
109
  * element, so unlike onDeleteElement they need no composition-basis wrapper. */
106
110
  onCopyClip?: () => boolean;
@@ -113,6 +117,7 @@ export interface TimelinePaneProps {
113
117
  export function TimelinePane({
114
118
  timelineToolbar,
115
119
  timelineFooter,
120
+ timelineOverlay,
116
121
  renderClipContent,
117
122
  onFileDrop,
118
123
  onDeleteElement,
@@ -121,6 +126,7 @@ export function TimelinePane({
121
126
  onCompositionDrop,
122
127
  onBlockedEditAttempt,
123
128
  onSelectTimelineElement,
129
+ onRangeSelect,
124
130
  onCopyClip,
125
131
  onPasteClip,
126
132
  onDuplicateClip,
@@ -278,6 +284,7 @@ export function TimelinePane({
278
284
  onBlockedEditAttempt={onBlockedEditAttempt}
279
285
  onSplitElement={handleSplitElement}
280
286
  onSelectElement={onSelectTimelineElement}
287
+ onRangeSelect={onRangeSelect}
281
288
  onCopyClip={onCopyClip}
282
289
  onPasteClip={onPasteClip}
283
290
  onDuplicateClip={onDuplicateClip}
@@ -285,6 +292,9 @@ export function TimelinePane({
285
292
  />
286
293
  </div>
287
294
  {timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
295
+ {timelineOverlay && (
296
+ <div className="pointer-events-none absolute inset-0 z-20">{timelineOverlay}</div>
297
+ )}
288
298
  {timelineDisabled && (
289
299
  <div
290
300
  className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
@@ -28,6 +28,19 @@ import {
28
28
  type UseTimelineElementVisibilityEditingInput,
29
29
  } from "./timelineTrackVisibility";
30
30
 
31
+ interface AudioGroupCarveInput extends UseTimelineElementVisibilityEditingInput {
32
+ checkEditable?: (elements: readonly TimelineElement[]) => boolean;
33
+ }
34
+
35
+ function assertAudioGroupEditable(
36
+ checkEditable: AudioGroupCarveInput["checkEditable"],
37
+ elements: readonly TimelineElement[],
38
+ ): void {
39
+ if (checkEditable && !checkEditable(elements)) {
40
+ throw new Error("Timeline edit blocked");
41
+ }
42
+ }
43
+
31
44
  /**
32
45
  * Assign (or restore) `data-audio-group` across a set of members.
33
46
  *
@@ -267,7 +280,8 @@ export function useAudioGroupCarveAssignment({
267
280
  previewIframeRef,
268
281
  pendingTimelineEditPathRef,
269
282
  isRecordingRef,
270
- }: UseTimelineElementVisibilityEditingInput): (
283
+ checkEditable,
284
+ }: AudioGroupCarveInput): (
271
285
  clipIds: readonly string[],
272
286
  groupId: string,
273
287
  groupLabel?: string,
@@ -289,6 +303,7 @@ export function useAudioGroupCarveAssignment({
289
303
  const domId = runtimeAudioId(item);
290
304
  return domId !== null && wanted.has(domId);
291
305
  });
306
+ if (elements.length === wanted.size) assertAudioGroupEditable(checkEditable, elements);
292
307
  try {
293
308
  // Loud, not silent: an unresolved id used to leave `elements` short,
294
309
  // `createAudioGroupAndAssignMembers` returning early with no write, and
@@ -333,6 +348,7 @@ export function useAudioGroupCarveAssignment({
333
348
  isRecordingRef,
334
349
  showToast,
335
350
  projectIdRef,
351
+ checkEditable,
336
352
  ],
337
353
  );
338
354
  }
@@ -1,4 +1,4 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
3
3
  import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
4
4
  import { usePlayerStore } from "../player";
@@ -29,6 +29,10 @@ function patchLiveGroupAttribute(
29
29
  invalidateGroupInfoCache(iframe?.contentDocument);
30
30
  }
31
31
 
32
+ function audioGroupAttributeLiveKey(groupId: string, attr: string): string {
33
+ return `${groupId}\0${attr}`;
34
+ }
35
+
32
36
  /**
33
37
  * `data-volume` exactly as core reads it (`readAudioGroupVolume`): an absent or
34
38
  * empty attribute is UNITY, not zero.
@@ -210,9 +214,16 @@ export function useSetAudioGroupAttribute({
210
214
  }: UseTimelineElementVisibilityEditingInput): {
211
215
  setLive: (groupId: string, attr: string, value: string | null) => void;
212
216
  setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
217
+ revertLive: (groupId: string, attr: string) => void;
213
218
  } {
219
+ const liveBeforeRef = useRef(new Map<string, string | null>());
214
220
  const setLive = useCallback(
215
221
  (groupId: string, attr: string, value: string | null) => {
222
+ const key = audioGroupAttributeLiveKey(groupId, attr);
223
+ const target = previewIframeRef.current?.contentDocument?.getElementById(groupId);
224
+ if (!liveBeforeRef.current.has(key)) {
225
+ liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
226
+ }
216
227
  patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
217
228
  // Live too, not just on commit: a fader drag is `setLive` per frame and
218
229
  // `setQuiet` once on release, so without this the strip's own readout
@@ -221,6 +232,26 @@ export function useSetAudioGroupAttribute({
221
232
  },
222
233
  [previewIframeRef],
223
234
  );
235
+ const revertLive = useCallback(
236
+ (groupId: string, attr: string) => {
237
+ const key = audioGroupAttributeLiveKey(groupId, attr);
238
+ if (!liveBeforeRef.current.has(key)) return;
239
+ patchLiveGroupAttribute(
240
+ previewIframeRef.current,
241
+ groupId,
242
+ attr,
243
+ liveBeforeRef.current.get(key) ?? null,
244
+ );
245
+ syncStoredGroupAttribute(
246
+ groupId,
247
+ attr,
248
+ previewIframeRef.current?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ??
249
+ null,
250
+ );
251
+ liveBeforeRef.current.delete(key);
252
+ },
253
+ [previewIframeRef],
254
+ );
224
255
  const setQuiet = useCallback(
225
256
  async (groupId: string, attr: string, value: string | null, label: string) => {
226
257
  if (isRecordingRef?.current) {
@@ -242,6 +273,7 @@ export function useSetAudioGroupAttribute({
242
273
  recordEdit,
243
274
  pendingTimelineEditPathRef,
244
275
  });
276
+ liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
245
277
  syncStoredGroupAttribute(groupId, attr, value);
246
278
  } catch (error) {
247
279
  // `persistElementAttribute` leaves the live DOM at the previous value
@@ -257,6 +289,7 @@ export function useSetAudioGroupAttribute({
257
289
  console.error("[Timeline] Failed to set group attribute", error);
258
290
  const message = error instanceof Error ? error.message : "Failed to update group";
259
291
  showToast(message);
292
+ liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
260
293
  }
261
294
  },
262
295
  [
@@ -270,5 +303,5 @@ export function useSetAudioGroupAttribute({
270
303
  projectIdRef,
271
304
  ],
272
305
  );
273
- return { setLive, setQuiet };
306
+ return { setLive, setQuiet, revertLive };
274
307
  }
@@ -0,0 +1,31 @@
1
+ import { useCallback } from "react";
2
+ import type { TimelineElement } from "../player";
3
+
4
+ /** A host's verdict on one element: editable, or blocked with a reason to show. */
5
+ export type TimelineEditPermission = true | { blocked: true; reason: string };
6
+
7
+ export type CanEditTimelineElement = (element: TimelineElement) => TimelineEditPermission;
8
+
9
+ /**
10
+ * Refuses a write when any target element is blocked, toasting the host's
11
+ * reason. Absent `canEdit` never refuses, so Studio itself is unchanged.
12
+ */
13
+ export function useTimelineEditGate(
14
+ canEdit: CanEditTimelineElement | undefined,
15
+ showToast: (message: string, tone?: "error" | "info") => void,
16
+ ) {
17
+ return useCallback(
18
+ (targets: readonly TimelineElement[]): boolean => {
19
+ if (!canEdit) return true;
20
+ for (const element of targets) {
21
+ const verdict = canEdit(element);
22
+ if (verdict !== true) {
23
+ showToast(verdict.reason, "error");
24
+ return false;
25
+ }
26
+ }
27
+ return true;
28
+ },
29
+ [canEdit, showToast],
30
+ );
31
+ }
@@ -7,7 +7,7 @@
7
7
  * shared core `setAudioGroupAttribute` also uses.
8
8
  */
9
9
 
10
- import { useCallback } from "react";
10
+ import { useCallback, useRef } from "react";
11
11
  import type { TimelineElement } from "../player";
12
12
  import {
13
13
  buildPatchTarget,
@@ -32,6 +32,14 @@ function patchLiveElementAttribute(
32
32
  else target.setAttribute(attr, value);
33
33
  }
34
34
 
35
+ function elementAttributeLiveKey(
36
+ element: TimelineElement,
37
+ activeCompPath: string | null,
38
+ attr: string,
39
+ ): string {
40
+ return `${element.sourceFile || activeCompPath || "index.html"}\0${element.key ?? element.domId ?? element.id}\0${attr}`;
41
+ }
42
+
35
43
  interface SetElementAttributeInput {
36
44
  projectId: string;
37
45
  activeCompPath: string | null;
@@ -92,13 +100,35 @@ export function useSetElementAttribute({
92
100
  value: string | null,
93
101
  label: string,
94
102
  ) => Promise<void>;
103
+ revertLive: (element: TimelineElement, attr: string) => void;
95
104
  } {
105
+ const liveBeforeRef = useRef(new Map<string, string | null>());
96
106
  const setLive = useCallback(
97
107
  (element: TimelineElement, attr: string, value: string | null) => {
108
+ const key = elementAttributeLiveKey(element, activeCompPath, attr);
109
+ const target = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
110
+ if (!liveBeforeRef.current.has(key)) {
111
+ liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
112
+ }
98
113
  patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
99
114
  },
100
115
  [previewIframeRef, activeCompPath],
101
116
  );
117
+ const revertLive = useCallback(
118
+ (element: TimelineElement, attr: string) => {
119
+ const key = elementAttributeLiveKey(element, activeCompPath, attr);
120
+ if (!liveBeforeRef.current.has(key)) return;
121
+ patchLiveElementAttribute(
122
+ previewIframeRef.current,
123
+ element,
124
+ attr,
125
+ liveBeforeRef.current.get(key) ?? null,
126
+ activeCompPath,
127
+ );
128
+ liveBeforeRef.current.delete(key);
129
+ },
130
+ [previewIframeRef, activeCompPath],
131
+ );
102
132
  const setQuiet = useCallback(
103
133
  async (element: TimelineElement, attr: string, value: string | null, label: string) => {
104
134
  if (isRecordingRef?.current) {
@@ -120,10 +150,12 @@ export function useSetElementAttribute({
120
150
  recordEdit,
121
151
  pendingTimelineEditPathRef,
122
152
  });
153
+ liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
123
154
  } catch (error) {
124
155
  console.error("[Timeline] Failed to set element attribute", error);
125
156
  const message = error instanceof Error ? error.message : "Failed to update effect";
126
157
  showToast(message);
158
+ liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
127
159
  }
128
160
  },
129
161
  [
@@ -137,5 +169,5 @@ export function useSetElementAttribute({
137
169
  projectIdRef,
138
170
  ],
139
171
  );
140
- return { setLive, setQuiet };
172
+ return { setLive, setQuiet, revertLive };
141
173
  }
@@ -1,18 +1,13 @@
1
- import { useState, useCallback, useMemo, useRef } from "react";
1
+ import { useState, useCallback, useRef } from "react";
2
2
  import type { EditingFile } from "../utils/studioHelpers";
3
3
  import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import type { EditHistoryKind } from "../utils/editHistory";
6
6
  import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
7
- import {
8
- createStudioSaveHttpError,
9
- retryStudioSave,
10
- StudioFileConflictError,
11
- StudioSaveNetworkError,
12
- } from "../utils/studioSaveDiagnostics";
13
- import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
7
+ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
14
8
  import { useFileTree } from "./useFileTree";
15
9
  import { useEditorSave } from "./useEditorSave";
10
+ import { useProjectFileWriter } from "./useProjectFileWriter";
16
11
 
17
12
  // ── Types ──
18
13
 
@@ -50,17 +45,13 @@ export function useFileManager({
50
45
  projectIdRef.current = projectId;
51
46
 
52
47
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
53
- const fileVersionScope = useMemo(
54
- () => ({ projectId, versions: new Map<string, string | null>() }),
55
- [projectId],
56
- );
57
- const fileVersions = fileVersionScope.versions;
58
- const observeProjectFileVersion = useCallback(
59
- (path: string, version: string | null) => {
60
- fileVersions.set(path, version);
61
- },
62
- [fileVersions],
63
- );
48
+ const {
49
+ fileVersions,
50
+ readProjectFile,
51
+ writeProjectFile: writeProjectFileRaw,
52
+ readOptionalProjectFile,
53
+ observeProjectFileVersion,
54
+ } = useProjectFileWriter({ projectId });
64
55
 
65
56
  // ── File tree ──
66
57
 
@@ -74,117 +65,24 @@ export function useFileManager({
74
65
  fontAssets,
75
66
  } = useFileTree({ projectId, projectIdRef });
76
67
 
77
- // ── Core file I/O ──
78
-
79
- const readProjectFile = useCallback(
80
- async (path: string): Promise<string> => {
81
- if (!projectId) throw new Error("No active project");
82
- const response = await fetch(
83
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
84
- );
85
- if (!response.ok) throw new Error(`Failed to read ${path}`);
86
- const data = (await response.json()) as { content?: string; version?: string };
87
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
88
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
89
- return data.content;
90
- },
91
- [fileVersions, projectId],
92
- );
93
-
94
- const writeProjectFile = useCallback(
95
- async (path: string, content: string, expectedContent?: string): Promise<void> => {
96
- if (!projectId) throw new Error("No active project");
97
- const writeProjectId = projectId;
98
- let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
99
- if (expectedVersion === undefined) {
100
- const preflight = await fetch(
101
- `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
102
- );
103
- if (preflight.ok) {
104
- const data = (await preflight.json()) as { content?: string; version?: string };
105
- throw new StudioFileConflictError({
106
- filePath: path,
107
- currentVersion: data.version ?? preflight.headers.get("etag"),
108
- currentContent: data.content ?? null,
109
- attemptedContent: content,
110
- });
111
- } else if (preflight.status === 404) {
112
- expectedVersion = null;
113
- } else {
114
- throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
115
- }
116
- }
117
- await retryStudioSave(async () => {
118
- // Each request gets its own receipt identity. If a committed request loses its response,
119
- // the retry can produce a second filesystem receipt that must be suppressed independently.
120
- let response: Response;
121
- try {
122
- response = await fetch(
123
- `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
124
- {
125
- method: "PUT",
126
- headers: {
127
- "Content-Type": "text/plain",
128
- ...studioWriteHeaders(),
129
- ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
130
- },
131
- body: content,
132
- },
133
- );
134
- } catch (error) {
135
- throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
136
- cause: error,
137
- });
138
- }
139
- if (response.status === 409) {
140
- const conflict = (await response.json().catch(() => null)) as {
141
- currentVersion?: string | null;
142
- currentContent?: string | null;
143
- } | null;
144
- const currentVersion = conflict?.currentVersion ?? null;
145
- if (currentVersion && conflict?.currentContent === content) {
146
- fileVersions.set(path, currentVersion);
147
- return;
148
- }
149
- throw new StudioFileConflictError({
150
- filePath: path,
151
- currentVersion,
152
- currentContent: conflict?.currentContent ?? null,
153
- attemptedContent: content,
154
- });
155
- }
156
- if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
157
- const result = (await response.json()) as { version?: string };
158
- const version = result.version ?? response.headers.get("etag");
159
- if (!version)
160
- throw new Error(`Save response for ${path} did not include a content version`);
161
- fileVersions.set(path, version);
162
- });
163
- if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
164
- setEditingFile({ path, content });
165
- }
166
- },
167
- [fileVersions, projectId],
168
- );
169
-
170
68
  const updateEditingFileContent = useCallback((path: string, content: string) => {
171
69
  if (editingPathRef.current === path) {
172
70
  setEditingFile({ path, content });
173
71
  }
174
72
  }, []);
175
73
 
176
- const readOptionalProjectFile = useCallback(
177
- async (path: string): Promise<string> => {
178
- if (!projectId) throw new Error("No active project");
179
- const response = await fetch(
180
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
181
- );
182
- if (!response.ok) throw new Error(`Failed to read ${path}`);
183
- const data = (await response.json()) as { content?: string; version?: string };
184
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
185
- return typeof data.content === "string" ? data.content : "";
74
+ // Studio's own editor tab mirrors whatever it just wrote, on top of the
75
+ // shared writer — a host mounting useProjectFileWriter directly has no
76
+ // editor tab and does not need this.
77
+ const writeProjectFile = useCallback(
78
+ async (path: string, content: string, expectedContent?: string): Promise<void> => {
79
+ const writeProjectId = projectIdRef.current;
80
+ await writeProjectFileRaw(path, content, expectedContent);
81
+ if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
82
+ setEditingFile({ path, content });
83
+ }
186
84
  },
187
- [fileVersions, projectId],
85
+ [writeProjectFileRaw],
188
86
  );
189
87
 
190
88
  // ── Editor save (debounced content change) ──
@@ -33,7 +33,7 @@ interface ApplyCallbacks {
33
33
  serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
34
34
  }
35
35
 
36
- interface UsePersistentEditHistoryOptions {
36
+ export interface UsePersistentEditHistoryOptions {
37
37
  projectId: string | null;
38
38
  storage?: EditHistoryStorageAdapter;
39
39
  now?: () => number;
@@ -0,0 +1,150 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import {
3
+ createStudioSaveHttpError,
4
+ retryStudioSave,
5
+ StudioFileConflictError,
6
+ StudioSaveNetworkError,
7
+ } from "../utils/studioSaveDiagnostics";
8
+ import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
9
+
10
+ export interface UseProjectFileWriterOptions {
11
+ projectId: string | null;
12
+ }
13
+
14
+ // The etag-guarded project-file read/write pair every Studio save path
15
+ // shares. A host mounting hand editing outside Studio needs only this, not
16
+ // the full file manager (file tree, editor tabs, uploads, font assets).
17
+ export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions) {
18
+ const fileVersionScope = useMemo(
19
+ () => ({ projectId, versions: new Map<string, string | null>() }),
20
+ [projectId],
21
+ );
22
+ const fileVersions = fileVersionScope.versions;
23
+
24
+ const observeProjectFileVersion = useCallback(
25
+ (path: string, version: string | null) => {
26
+ fileVersions.set(path, version);
27
+ },
28
+ [fileVersions],
29
+ );
30
+
31
+ const readProjectFile = useCallback(
32
+ async (path: string): Promise<string> => {
33
+ if (!projectId) throw new Error("No active project");
34
+ const response = await fetch(
35
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
36
+ );
37
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
38
+ const data = (await response.json()) as { content?: string; version?: string };
39
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
40
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
41
+ return data.content;
42
+ },
43
+ [fileVersions, projectId],
44
+ );
45
+
46
+ const readOptionalProjectFile = useCallback(
47
+ async (path: string): Promise<string> => {
48
+ if (!projectId) throw new Error("No active project");
49
+ const response = await fetch(
50
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
51
+ );
52
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
53
+ const data = (await response.json()) as { content?: string; version?: string };
54
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
55
+ return typeof data.content === "string" ? data.content : "";
56
+ },
57
+ [fileVersions, projectId],
58
+ );
59
+
60
+ const writeProjectFile = useCallback(
61
+ async (path: string, content: string, expectedContent?: string): Promise<void> => {
62
+ if (!projectId) throw new Error("No active project");
63
+ const writeProjectId = projectId;
64
+ let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
65
+ if (expectedVersion === undefined) {
66
+ const preflight = await fetch(
67
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
68
+ );
69
+ if (preflight.ok) {
70
+ const data = (await preflight.json()) as { content?: string; version?: string };
71
+ throw new StudioFileConflictError({
72
+ filePath: path,
73
+ currentVersion: data.version ?? preflight.headers.get("etag"),
74
+ currentContent: data.content ?? null,
75
+ attemptedContent: content,
76
+ });
77
+ } else if (preflight.status === 404) {
78
+ expectedVersion = null;
79
+ } else {
80
+ throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
81
+ }
82
+ }
83
+ // fallow-ignore-next-line complexity
84
+ await retryStudioSave(async () => {
85
+ // Each request gets its own receipt identity. If a committed request loses its response,
86
+ // the retry can produce a second filesystem receipt that must be suppressed independently.
87
+ let response: Response;
88
+ try {
89
+ response = await fetch(
90
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
91
+ {
92
+ method: "PUT",
93
+ headers: {
94
+ "Content-Type": "text/plain",
95
+ ...studioWriteHeaders(),
96
+ ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
97
+ },
98
+ body: content,
99
+ },
100
+ );
101
+ } catch (error) {
102
+ throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
103
+ cause: error,
104
+ });
105
+ }
106
+ if (response.status === 409) {
107
+ const conflict = (await response.json().catch(() => null)) as {
108
+ currentVersion?: string | null;
109
+ currentContent?: string | null;
110
+ } | null;
111
+ const currentVersion = conflict?.currentVersion ?? null;
112
+ if (currentVersion && conflict?.currentContent === content) {
113
+ fileVersions.set(path, currentVersion);
114
+ return;
115
+ }
116
+ throw new StudioFileConflictError({
117
+ filePath: path,
118
+ currentVersion,
119
+ currentContent: conflict?.currentContent ?? null,
120
+ attemptedContent: content,
121
+ });
122
+ }
123
+ if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
124
+ const result = (await response.json()) as { version?: string };
125
+ const version = result.version ?? response.headers.get("etag");
126
+ if (!version)
127
+ throw new Error(`Save response for ${path} did not include a content version`);
128
+ fileVersions.set(path, version);
129
+ });
130
+ },
131
+ [fileVersions, projectId],
132
+ );
133
+
134
+ return useMemo(
135
+ () => ({
136
+ fileVersions,
137
+ readProjectFile,
138
+ writeProjectFile,
139
+ readOptionalProjectFile,
140
+ observeProjectFileVersion,
141
+ }),
142
+ [
143
+ fileVersions,
144
+ readProjectFile,
145
+ writeProjectFile,
146
+ readOptionalProjectFile,
147
+ observeProjectFileVersion,
148
+ ],
149
+ );
150
+ }