@hyperframes/studio 0.7.59 → 0.7.60

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 (45) hide show
  1. package/dist/assets/{hyperframes-player-Bm07FLkl.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-B995FG46.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-FvzmPhfG.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/{index-CrkAdJkb.js → index-cH6NfVV_.js} +198 -198
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +250 -108
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +1 -1
  11. package/src/components/StudioRightPanel.tsx +13 -2
  12. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
  13. package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
  14. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  15. package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
  16. package/src/components/editor/propertyPanelFlatTextSection.tsx +1 -1
  17. package/src/contexts/FileManagerContext.tsx +3 -0
  18. package/src/contexts/PanelLayoutContext.tsx +3 -0
  19. package/src/hooks/timelineEditingHelpers.ts +2 -2
  20. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  21. package/src/hooks/useDomEditCommits.ts +2 -2
  22. package/src/hooks/useDomEditSession.ts +1 -1
  23. package/src/hooks/useEditorSave.ts +1 -1
  24. package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
  25. package/src/hooks/useFileManager.ts +76 -12
  26. package/src/hooks/usePanelLayout.test.ts +118 -0
  27. package/src/hooks/usePanelLayout.ts +24 -1
  28. package/src/hooks/usePreviewPersistence.ts +4 -1
  29. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  30. package/src/hooks/useRazorSplit.test.tsx +88 -0
  31. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  32. package/src/hooks/useRazorSplit.ts +50 -16
  33. package/src/hooks/useTimelineEditing.ts +2 -0
  34. package/src/hooks/useTimelineEditingTypes.ts +2 -1
  35. package/src/hooks/useTimelineGroupEditing.ts +1 -1
  36. package/src/utils/domEditSaveQueue.test.ts +19 -0
  37. package/src/utils/domEditSaveQueue.ts +2 -1
  38. package/src/utils/sdkCutover.test.ts +21 -5
  39. package/src/utils/sdkEditTransaction.ts +5 -4
  40. package/src/utils/studioFileHistory.ts +6 -4
  41. package/src/utils/studioFileVersion.test.ts +36 -0
  42. package/src/utils/studioFileVersion.ts +23 -0
  43. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  44. package/src/utils/studioSaveDiagnostics.ts +21 -0
  45. package/dist/assets/index-Dj5p8U_A.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.59",
3
+ "version": "0.7.60",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.59",
50
- "@hyperframes/studio-server": "0.7.59",
51
- "@hyperframes/sdk": "0.7.59",
52
- "@hyperframes/parsers": "0.7.59",
53
- "@hyperframes/player": "0.7.59"
49
+ "@hyperframes/core": "0.7.60",
50
+ "@hyperframes/player": "0.7.60",
51
+ "@hyperframes/sdk": "0.7.60",
52
+ "@hyperframes/parsers": "0.7.60",
53
+ "@hyperframes/studio-server": "0.7.60"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.59"
68
+ "@hyperframes/producer": "0.7.60"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -64,7 +64,6 @@ import {
64
64
  } from "./utils/studioUrlState";
65
65
  import { trackStudioSessionStart } from "./telemetry/events";
66
66
  import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
67
-
68
67
  type CanvasRect = { left: number; top: number; width: number; height: number };
69
68
  // fallow-ignore-next-line complexity
70
69
  export function StudioApp() {
@@ -166,6 +165,7 @@ export function StudioApp() {
166
165
  timelineElements,
167
166
  showToast,
168
167
  writeProjectFile: fileManager.writeProjectFile,
168
+ observeProjectFileVersion: fileManager.observeProjectFileVersion,
169
169
  recordEdit: editHistory.recordEdit,
170
170
  domEditSaveTimestampRef,
171
171
  reloadPreview,
@@ -12,7 +12,10 @@ import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
12
12
  import type { RenderJob } from "./renders/useRenderQueue";
13
13
  import type { BlockParam } from "@hyperframes/core/registry";
14
14
  import type { IframeWindow } from "../player/lib/playbackTypes";
15
- import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability";
15
+ import {
16
+ STUDIO_FLAT_INSPECTOR_ENABLED,
17
+ STUDIO_INSPECTOR_PANELS_ENABLED,
18
+ } from "./editor/manualEditingAvailability";
16
19
  import type { Composition } from "@hyperframes/sdk";
17
20
  import type { EditHistoryKind } from "../utils/editHistory";
18
21
  import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
@@ -80,6 +83,7 @@ export function StudioRightPanel({
80
83
  setRightPanelTab,
81
84
  rightInspectorPanes,
82
85
  toggleRightInspectorPane,
86
+ setExclusiveRightInspectorPane,
83
87
  handlePanelResizeStart,
84
88
  handlePanelResizeMove,
85
89
  handlePanelResizeEnd,
@@ -223,6 +227,13 @@ export function StudioRightPanel({
223
227
  setRightPanelTab(pane);
224
228
  return;
225
229
  }
230
+ // Flat inspector: Layers always renders full-height by itself (see the
231
+ // render branch below), so the two panes are mutually exclusive here —
232
+ // otherwise both tabs could show "active" while only one actually shows.
233
+ if (STUDIO_FLAT_INSPECTOR_ENABLED) {
234
+ setExclusiveRightInspectorPane(pane);
235
+ return;
236
+ }
226
237
  toggleRightInspectorPane(pane);
227
238
  };
228
239
 
@@ -516,7 +527,7 @@ export function StudioRightPanel({
516
527
  domEditSaveTimestampRef={domEditSaveTimestampRef}
517
528
  recordEdit={recordEdit}
518
529
  />
519
- ) : layersPaneOpen && designPaneOpen ? (
530
+ ) : layersPaneOpen && designPaneOpen && !STUDIO_FLAT_INSPECTOR_ENABLED ? (
520
531
  <div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
521
532
  <div
522
533
  className="min-h-[120px] overflow-hidden"
@@ -377,7 +377,7 @@ export function FlatColorGradingSection({
377
377
  track("select", "Custom LUT");
378
378
  applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
379
379
  }}
380
- className="bg-transparent font-mono text-[10px] text-panel-text-3 outline-none"
380
+ className="border-b border-panel-border-input/50 bg-transparent font-mono text-[10px] text-panel-text-3 outline-none hover:border-panel-border-input"
381
381
  >
382
382
  <option value="">None</option>
383
383
  {lutAssets.map((asset) => (
@@ -545,7 +545,7 @@ export function FlatColorGradingSection({
545
545
  onSetApplyScope(e.target.value as "source-file" | "project");
546
546
  }}
547
547
  disabled={applyBusy}
548
- className="bg-transparent font-mono text-[11px] text-panel-text-0 outline-none disabled:opacity-50"
548
+ className="border-b border-panel-border-input/50 bg-transparent font-mono text-[11px] text-panel-text-0 outline-none hover:border-panel-border-input disabled:opacity-50"
549
549
  >
550
550
  <option value="source-file">Current file media</option>
551
551
  <option value="project">All project media</option>
@@ -85,7 +85,7 @@ export function FlatTimingRow({
85
85
  const cell = (label: string, value: string, onCommit: (next: string) => void) => (
86
86
  <div className="grid gap-px">
87
87
  <span className="text-[9px] text-panel-text-4">{label}</span>
88
- <span className="border-b border-transparent font-mono text-[11px] text-panel-text-0 hover:border-panel-border-input">
88
+ <span className="border-b border-panel-border-input/50 font-mono text-[11px] text-panel-text-0 hover:border-panel-border-input">
89
89
  <CommitField
90
90
  value={value}
91
91
  onCommit={(next) => {
@@ -43,8 +43,8 @@ export function FlatRow({
43
43
  data-flat-row-value="true"
44
44
  className={`min-w-0 border-b pb-px font-mono text-[11px] ${VALUE_TIER_VALUE_CLASS[tier]} ${
45
45
  tier === "explicitCustom"
46
- ? "border-transparent group-hover:border-panel-accent/35"
47
- : "border-transparent group-hover:border-panel-border-input"
46
+ ? "border-panel-accent/30 group-hover:border-panel-accent/70"
47
+ : "border-panel-border-input/50 group-hover:border-panel-border-input"
48
48
  }`}
49
49
  >
50
50
  <CommitField
@@ -53,7 +53,13 @@ export function FlatSelectRow({
53
53
  <div className="group flex min-h-[30px] items-center justify-between">
54
54
  <span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
55
55
  <span className="flex items-center gap-2">
56
- <label className="flex items-center gap-1.5">
56
+ <label
57
+ className={`flex items-center gap-1.5 border-b pb-px ${
58
+ tier === "explicitCustom"
59
+ ? "border-panel-accent/30 group-hover:border-panel-accent/70"
60
+ : "border-panel-border-input/50 group-hover:border-panel-border-input"
61
+ }`}
62
+ >
57
63
  <select
58
64
  value={value}
59
65
  disabled={disabled}
@@ -111,7 +111,7 @@ function FlatTextFieldEditor({
111
111
  >
112
112
  Weight
113
113
  </span>
114
- <label className="flex items-center gap-1.5">
114
+ <label className="flex items-center gap-1.5 border-b border-panel-border-input/50 pb-px hover:border-panel-border-input">
115
115
  <select
116
116
  value={weight}
117
117
  onChange={(e) => {
@@ -30,6 +30,7 @@ export function FileManagerProvider({
30
30
  readProjectFile,
31
31
  writeProjectFile,
32
32
  readOptionalProjectFile,
33
+ observeProjectFileVersion,
33
34
  updateEditingFileContent,
34
35
  revealSourceOffset,
35
36
  openSourceForSelection,
@@ -69,6 +70,7 @@ export function FileManagerProvider({
69
70
  readProjectFile,
70
71
  writeProjectFile,
71
72
  readOptionalProjectFile,
73
+ observeProjectFileVersion,
72
74
  updateEditingFileContent,
73
75
  revealSourceOffset,
74
76
  openSourceForSelection,
@@ -102,6 +104,7 @@ export function FileManagerProvider({
102
104
  readProjectFile,
103
105
  writeProjectFile,
104
106
  readOptionalProjectFile,
107
+ observeProjectFileVersion,
105
108
  updateEditingFileContent,
106
109
  revealSourceOffset,
107
110
  openSourceForSelection,
@@ -25,6 +25,7 @@ export function PanelLayoutProvider({
25
25
  setRightPanelTab,
26
26
  rightInspectorPanes,
27
27
  toggleRightInspectorPane,
28
+ setExclusiveRightInspectorPane,
28
29
  toggleLeftSidebar,
29
30
  handlePanelResizeStart,
30
31
  handlePanelResizeMove,
@@ -49,6 +50,7 @@ export function PanelLayoutProvider({
49
50
  setRightPanelTab,
50
51
  rightInspectorPanes,
51
52
  toggleRightInspectorPane,
53
+ setExclusiveRightInspectorPane,
52
54
  toggleLeftSidebar,
53
55
  handlePanelResizeStart,
54
56
  handlePanelResizeMove,
@@ -67,6 +69,7 @@ export function PanelLayoutProvider({
67
69
  setRightPanelTab,
68
70
  rightInspectorPanes,
69
71
  toggleRightInspectorPane,
72
+ setExclusiveRightInspectorPane,
70
73
  toggleLeftSidebar,
71
74
  handlePanelResizeStart,
72
75
  handlePanelResizeMove,
@@ -262,7 +262,7 @@ export interface PersistTimelineEditInput {
262
262
  activeCompPath: string | null;
263
263
  label: string;
264
264
  buildPatches: (original: string, target: PatchTarget) => string;
265
- writeProjectFile: (path: string, content: string) => Promise<void>;
265
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
266
266
  recordEdit: (input: RecordEditInput) => Promise<void>;
267
267
  domEditSaveTimestampRef: React.MutableRefObject<number>;
268
268
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
@@ -308,7 +308,7 @@ export interface PersistTimelineBatchEditInput {
308
308
  activeCompPath: string | null;
309
309
  label: string;
310
310
  changes: PersistTimelineBatchChange[];
311
- writeProjectFile: (path: string, content: string) => Promise<void>;
311
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
312
312
  recordEdit: (input: RecordEditInput) => Promise<void>;
313
313
  domEditSaveTimestampRef: React.MutableRefObject<number>;
314
314
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
@@ -34,7 +34,7 @@ interface RenderedDomEditCommits {
34
34
 
35
35
  interface RenderDomEditCommitsOptions {
36
36
  importedFontAssets?: ImportedFontAsset[];
37
- writeProjectFile?: (path: string, content: string) => Promise<void>;
37
+ writeProjectFile?: (path: string, content: string, expectedContent?: string) => Promise<void>;
38
38
  }
39
39
 
40
40
  type FetchHandler = (
@@ -1050,6 +1050,46 @@ describe("useDomEditCommits style persist handling", () => {
1050
1050
  }
1051
1051
  });
1052
1052
 
1053
+ it("uses the patched server content as the custom-font write precondition", async () => {
1054
+ const patchedContent =
1055
+ '<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>';
1056
+ stubPatchFetch(
1057
+ { ok: true, changed: true, matched: true, content: patchedContent },
1058
+ patchedContent,
1059
+ );
1060
+ const { iframe, element } = createPreviewElement();
1061
+ const selection = createSelection(element, {
1062
+ textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1063
+ });
1064
+ const writeProjectFile = vi.fn(async () => {});
1065
+ const rendered = renderDomEditCommits(selection, iframe, { writeProjectFile });
1066
+
1067
+ try {
1068
+ await act(async () => {
1069
+ await rendered.hook.commitDomTextFields(
1070
+ selection,
1071
+ [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
1072
+ {
1073
+ importedFont: {
1074
+ family: "Imported",
1075
+ path: "fonts/Imported.woff2",
1076
+ url: "/api/projects/p1/preview/fonts/Imported.woff2",
1077
+ },
1078
+ },
1079
+ );
1080
+ });
1081
+
1082
+ expect(writeProjectFile).toHaveBeenCalledWith(
1083
+ "index.html",
1084
+ expect.stringContaining("@font-face"),
1085
+ patchedContent,
1086
+ );
1087
+ expect(rendered.showToast).not.toHaveBeenCalled();
1088
+ } finally {
1089
+ rendered.cleanup();
1090
+ }
1091
+ });
1092
+
1053
1093
  it("keeps a rejected patch request (HTTP error) to one toast", async () => {
1054
1094
  const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
1055
1095
  const url = requestUrl(input);
@@ -46,7 +46,7 @@ export interface UseDomEditCommitsParams {
46
46
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
47
47
  showToast: (message: string, tone?: "error" | "info") => void;
48
48
  queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
49
- writeProjectFile: (path: string, content: string) => Promise<void>;
49
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
50
50
  domEditSaveTimestampRef: React.MutableRefObject<number>;
51
51
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
52
52
  fileTree: string[];
@@ -245,7 +245,7 @@ export function useDomEditCommits({
245
245
  const preparedContent = options.prepareContent(patchedContent, targetPath);
246
246
  if (preparedContent !== patchedContent) {
247
247
  try {
248
- await writeProjectFile(targetPath, preparedContent);
248
+ await writeProjectFile(targetPath, preparedContent, patchedContent);
249
249
  finalContent = preparedContent;
250
250
  } catch (error) {
251
251
  // The patch above already landed on disk — only the prepareContent
@@ -45,7 +45,7 @@ export interface UseDomEditSessionParams {
45
45
  refreshPreviewDocumentVersion: () => void;
46
46
  queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
47
47
  readProjectFile: (path: string) => Promise<string>;
48
- writeProjectFile: (path: string, content: string) => Promise<void>;
48
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
49
49
  updateEditingFileContent: (path: string, content: string) => void;
50
50
  domEditSaveTimestampRef: React.MutableRefObject<number>;
51
51
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
@@ -14,7 +14,7 @@ interface UseEditorSaveOptions {
14
14
  editingPathRef: React.RefObject<string | undefined>;
15
15
  projectIdRef: React.RefObject<string | null>;
16
16
  readProjectFile: (path: string) => Promise<string>;
17
- writeProjectFile: (path: string, content: string) => Promise<void>;
17
+ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
18
18
  recordEdit: (input: RecordEditInput) => Promise<void>;
19
19
  domEditSaveTimestampRef: React.MutableRefObject<number>;
20
20
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
@@ -38,14 +38,17 @@ describe("useFileManager project ownership", () => {
38
38
  resolveProjectARead = resolve;
39
39
  });
40
40
  const fetchMock = vi.fn((url: string, init?: RequestInit) => {
41
+ if (url.endsWith("/files/missing.html") && !init?.method) {
42
+ return Promise.resolve({ ok: false, status: 404 } as Response);
43
+ }
41
44
  if (url.includes("project-a") && !init?.method) return projectARead;
42
45
  if (!init?.method) {
43
46
  return Promise.resolve({
44
47
  ok: true,
45
- json: async () => ({ content: "PROJECT_B" }),
48
+ json: async () => ({ content: "PROJECT_B", version: "b-v1" }),
46
49
  } as Response);
47
50
  }
48
- return Promise.resolve({ ok: true } as Response);
51
+ return Promise.resolve({ ok: true, json: async () => ({ version: "a-v2" }) } as Response);
49
52
  });
50
53
  vi.stubGlobal("fetch", fetchMock);
51
54
 
@@ -74,10 +77,11 @@ describe("useFileManager project ownership", () => {
74
77
 
75
78
  resolveProjectARead?.({
76
79
  ok: true,
77
- json: async () => ({ content: "PROJECT_A" }),
80
+ json: async () => ({ content: "PROJECT_A", version: "a-v1" }),
78
81
  } as Response);
79
82
  await expect(delayedRead).resolves.toBe("PROJECT_A");
80
83
  await managerA.writeProjectFile("index.html", "A_AFTER");
84
+ await managerA.writeProjectFile("missing.html", "A_NEW");
81
85
  await expect(managerB.readProjectFile("index.html")).resolves.toBe("PROJECT_B");
82
86
  await expect(managerB.readOptionalProjectFile("index.html")).resolves.toBe("PROJECT_B");
83
87
 
@@ -88,6 +92,13 @@ describe("useFileManager project ownership", () => {
88
92
  "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
89
93
  expect.objectContaining({ method: "PUT", body: "A_AFTER" }),
90
94
  );
95
+ expect(fetchMock).toHaveBeenCalledWith(
96
+ "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
97
+ );
98
+ expect(fetchMock).toHaveBeenCalledWith(
99
+ "/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
100
+ expect.objectContaining({ method: "PUT", body: "A_NEW" }),
101
+ );
91
102
  expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
92
103
  expect(fetchMock).toHaveBeenCalledWith(
93
104
  "/api/projects/project-b%23fragment/files/index.html?optional=1",
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef } from "react";
1
+ import { useState, useCallback, useMemo, 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";
@@ -7,8 +7,10 @@ import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
7
7
  import {
8
8
  createStudioSaveHttpError,
9
9
  retryStudioSave,
10
+ StudioFileConflictError,
10
11
  StudioSaveNetworkError,
11
12
  } from "../utils/studioSaveDiagnostics";
13
+ import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
12
14
  import { useFileTree } from "./useFileTree";
13
15
  import { useEditorSave } from "./useEditorSave";
14
16
 
@@ -50,6 +52,17 @@ export function useFileManager({
50
52
  projectIdRef.current = projectId;
51
53
 
52
54
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
55
+ const fileVersionScope = useMemo(
56
+ () => ({ projectId, versions: new Map<string, string | null>() }),
57
+ [projectId],
58
+ );
59
+ const fileVersions = fileVersionScope.versions;
60
+ const observeProjectFileVersion = useCallback(
61
+ (path: string, version: string | null) => {
62
+ fileVersions.set(path, version);
63
+ },
64
+ [fileVersions],
65
+ );
53
66
 
54
67
  // ── File tree ──
55
68
 
@@ -73,17 +86,38 @@ export function useFileManager({
73
86
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
74
87
  );
75
88
  if (!response.ok) throw new Error(`Failed to read ${path}`);
76
- const data = (await response.json()) as { content?: string };
89
+ const data = (await response.json()) as { content?: string; version?: string };
77
90
  if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
91
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
78
92
  return data.content;
79
93
  },
80
- [projectId],
94
+ [fileVersions, projectId],
81
95
  );
82
96
 
83
97
  const writeProjectFile = useCallback(
84
- async (path: string, content: string): Promise<void> => {
98
+ async (path: string, content: string, expectedContent?: string): Promise<void> => {
85
99
  if (!projectId) throw new Error("No active project");
86
100
  const writeProjectId = projectId;
101
+ let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
102
+ if (expectedVersion === undefined) {
103
+ const preflight = await fetch(
104
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
105
+ );
106
+ if (preflight.ok) {
107
+ const data = (await preflight.json()) as { content?: string; version?: string };
108
+ throw new StudioFileConflictError({
109
+ filePath: path,
110
+ currentVersion: data.version ?? preflight.headers.get("etag"),
111
+ currentContent: data.content ?? null,
112
+ attemptedContent: content,
113
+ });
114
+ } else if (preflight.status === 404) {
115
+ expectedVersion = null;
116
+ } else {
117
+ throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
118
+ }
119
+ }
120
+ const writeToken = createStudioWriteToken();
87
121
  await retryStudioSave(async () => {
88
122
  let response: Response;
89
123
  try {
@@ -91,7 +125,11 @@ export function useFileManager({
91
125
  `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
92
126
  {
93
127
  method: "PUT",
94
- headers: { "Content-Type": "text/plain" },
128
+ headers: {
129
+ "Content-Type": "text/plain",
130
+ "X-Hyperframes-Write-Token": writeToken,
131
+ ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
132
+ },
95
133
  body: content,
96
134
  },
97
135
  );
@@ -100,13 +138,35 @@ export function useFileManager({
100
138
  cause: error,
101
139
  });
102
140
  }
141
+ if (response.status === 409) {
142
+ const conflict = (await response.json().catch(() => null)) as {
143
+ currentVersion?: string | null;
144
+ currentContent?: string | null;
145
+ } | null;
146
+ const currentVersion = conflict?.currentVersion ?? null;
147
+ if (currentVersion && conflict?.currentContent === content) {
148
+ fileVersions.set(path, currentVersion);
149
+ return;
150
+ }
151
+ throw new StudioFileConflictError({
152
+ filePath: path,
153
+ currentVersion,
154
+ currentContent: conflict?.currentContent ?? null,
155
+ attemptedContent: content,
156
+ });
157
+ }
103
158
  if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
159
+ const result = (await response.json()) as { version?: string };
160
+ const version = result.version ?? response.headers.get("etag");
161
+ if (!version)
162
+ throw new Error(`Save response for ${path} did not include a content version`);
163
+ fileVersions.set(path, version);
104
164
  });
105
165
  if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
106
166
  setEditingFile({ path, content });
107
167
  }
108
168
  },
109
- [projectId],
169
+ [fileVersions, projectId],
110
170
  );
111
171
 
112
172
  const updateEditingFileContent = useCallback((path: string, content: string) => {
@@ -122,10 +182,11 @@ export function useFileManager({
122
182
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
123
183
  );
124
184
  if (!response.ok) throw new Error(`Failed to read ${path}`);
125
- const data = (await response.json()) as { content?: string };
185
+ const data = (await response.json()) as { content?: string; version?: string };
186
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
126
187
  return typeof data.content === "string" ? data.content : "";
127
188
  },
128
- [projectId],
189
+ [fileVersions, projectId],
129
190
  );
130
191
 
131
192
  // ── Editor save (debounced content change) ──
@@ -163,8 +224,9 @@ export function useFileManager({
163
224
  if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
164
225
  return r.json();
165
226
  })
166
- .then((data: { content?: string }) => {
227
+ .then((data: { content?: string; version?: string }) => {
167
228
  if (data.content != null) {
229
+ fileVersions.set(path, data.version ?? null);
168
230
  setEditingFile({ path, content: data.content });
169
231
  }
170
232
  })
@@ -172,7 +234,7 @@ export function useFileManager({
172
234
  showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
173
235
  });
174
236
  },
175
- [showToast],
237
+ [fileVersions, showToast],
176
238
  );
177
239
 
178
240
  // ── Click-to-source ──
@@ -195,9 +257,10 @@ export function useFileManager({
195
257
  signal: controller.signal,
196
258
  })
197
259
  .then((r) => r.json())
198
- .then((data: { content?: string }) => {
260
+ .then((data: { content?: string; version?: string }) => {
199
261
  if (requestId !== revealRequestIdRef.current) return;
200
262
  if (data.content != null) {
263
+ fileVersions.set(sourceFile, data.version ?? null);
201
264
  setEditingFile({ path: sourceFile, content: data.content });
202
265
  const match = findTagByTarget(data.content, target);
203
266
  setRevealSourceOffset(match ? match.start : null);
@@ -205,7 +268,7 @@ export function useFileManager({
205
268
  })
206
269
  .catch(() => {});
207
270
  },
208
- [editingFile?.content],
271
+ [editingFile?.content, fileVersions],
209
272
  );
210
273
 
211
274
  // ── Upload ──
@@ -434,6 +497,7 @@ export function useFileManager({
434
497
  readProjectFile,
435
498
  writeProjectFile,
436
499
  readOptionalProjectFile,
500
+ observeProjectFileVersion,
437
501
  updateEditingFileContent,
438
502
 
439
503
  // Click-to-source