@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.
- package/dist/assets/{hyperframes-player-Bm07FLkl.js → hyperframes-player-3XTTaVNf.js} +1 -1
- package/dist/assets/{index-B995FG46.js → index-D6etaey-.js} +1 -1
- package/dist/assets/index-DXbu6IPT.css +1 -0
- package/dist/assets/{index-FvzmPhfG.js → index-Dh_WhagG.js} +1 -1
- package/dist/assets/{index-CrkAdJkb.js → index-cH6NfVV_.js} +198 -198
- package/dist/index.html +2 -2
- package/dist/index.js +250 -108
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +1 -1
- package/src/components/StudioRightPanel.tsx +13 -2
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
- package/src/components/editor/propertyPanelFlatTextSection.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +2 -2
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
- package/src/hooks/useFileManager.ts +76 -12
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useTimelineEditing.ts +2 -0
- package/src/hooks/useTimelineEditingTypes.ts +2 -1
- package/src/hooks/useTimelineGroupEditing.ts +1 -1
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.test.ts +21 -5
- package/src/utils/sdkEditTransaction.ts +5 -4
- package/src/utils/studioFileHistory.ts +6 -4
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +21 -0
- 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.
|
|
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.
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/sdk": "0.7.
|
|
52
|
-
"@hyperframes/parsers": "0.7.
|
|
53
|
-
"@hyperframes/
|
|
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.
|
|
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 {
|
|
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-
|
|
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-
|
|
47
|
-
: "border-
|
|
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
|
|
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: {
|
|
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
|