@hyperframes/studio 0.7.59 → 0.7.61
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-Xvx2hkrc.js +459 -0
- package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
- package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
- package/dist/assets/index-BhWig0mx.css +1 -0
- package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1441 -770
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -9
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +47 -28
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +44 -42
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
- package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- 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/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineEditing.ts +2 -0
- package/src/hooks/useTimelineEditingTypes.ts +2 -1
- package/src/hooks/useTimelineGroupEditing.ts +1 -1
- package/src/player/lib/playbackTypes.ts +7 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.ts +8 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- 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/hyperframes-player-Bm07FLkl.js +0 -459
- package/dist/assets/index-Dj5p8U_A.css +0 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import type { TimelineElement } from "../store/playerStore";
|
|
14
14
|
import type { IframeWindow } from "./playbackTypes";
|
|
15
|
+
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
|
15
16
|
import {
|
|
16
17
|
getTimelineElementSelector,
|
|
17
18
|
getTimelineElementSourceFile,
|
|
@@ -38,6 +39,9 @@ export function normalizePreviewViewport(doc: Document, win: Window): void {
|
|
|
38
39
|
win.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// Legacy recovery retained until versioned composition manifests complete
|
|
43
|
+
// their compatibility soak across published CDN runtimes.
|
|
44
|
+
// fallow-ignore-next-line complexity
|
|
41
45
|
export function autoHealMissingCompositionIds(doc: Document): void {
|
|
42
46
|
const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
|
|
43
47
|
const referencedIds = new Set<string>();
|
|
@@ -258,6 +262,141 @@ export function stopScrubPreviewAudio(): void {
|
|
|
258
262
|
// Enrich missing compositions from DOM
|
|
259
263
|
// ---------------------------------------------------------------------------
|
|
260
264
|
|
|
265
|
+
function timelineDuration(iframeWin: IframeWindow, compositionId: string): number {
|
|
266
|
+
return (
|
|
267
|
+
(
|
|
268
|
+
iframeWin.__timelines?.[compositionId] as { duration?: () => number } | undefined
|
|
269
|
+
)?.duration?.() ?? 0
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function createTimedElementLookup(doc: Document): Map<string, Element> {
|
|
274
|
+
const timedById = new Map<string, Element>();
|
|
275
|
+
for (const timed of doc.querySelectorAll("[data-start]")) {
|
|
276
|
+
for (const id of [
|
|
277
|
+
timed.id,
|
|
278
|
+
timed.getAttribute("data-hf-id"),
|
|
279
|
+
timed.getAttribute("data-composition-id"),
|
|
280
|
+
]) {
|
|
281
|
+
if (id) timedById.set(id, timed);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return timedById;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function createReferenceEndResolver(
|
|
288
|
+
timedById: ReadonlyMap<string, Element>,
|
|
289
|
+
iframeWin: IframeWindow,
|
|
290
|
+
): (refId: string, visiting: ReadonlySet<string>) => number | null {
|
|
291
|
+
const resolveEnd = (refId: string, visiting: ReadonlySet<string>): number | null => {
|
|
292
|
+
if (visiting.has(refId)) return null;
|
|
293
|
+
const referenced = timedById.get(refId);
|
|
294
|
+
if (!referenced) return null;
|
|
295
|
+
const next = new Set(visiting).add(refId);
|
|
296
|
+
const timing = readClipTiming(referenced, {
|
|
297
|
+
resolveReferenceEnd: (nestedId) => resolveEnd(nestedId, next),
|
|
298
|
+
});
|
|
299
|
+
if (timing.end != null) return timing.end;
|
|
300
|
+
const compositionId = referenced.getAttribute("data-composition-id");
|
|
301
|
+
const duration = compositionId ? timelineDuration(iframeWin, compositionId) : 0;
|
|
302
|
+
return timing.start == null || duration <= 0 ? null : timing.start + duration;
|
|
303
|
+
};
|
|
304
|
+
return resolveEnd;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function clampCompositionWindow(
|
|
308
|
+
start: number,
|
|
309
|
+
duration: number,
|
|
310
|
+
rootDuration: number,
|
|
311
|
+
): { start: number; duration: number } | null {
|
|
312
|
+
if (!Number.isFinite(duration) || duration <= 0) return null;
|
|
313
|
+
const safeStart = Number.isFinite(start) ? start : 0;
|
|
314
|
+
if (!Number.isFinite(rootDuration) || rootDuration <= 0) {
|
|
315
|
+
return { start: safeStart, duration };
|
|
316
|
+
}
|
|
317
|
+
if (safeStart >= rootDuration) return null;
|
|
318
|
+
const clamped = Math.min(duration, Math.max(0, rootDuration - safeStart));
|
|
319
|
+
return clamped > 0 ? { start: safeStart, duration: clamped } : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function nonEmpty(value: string, fallback: string): string {
|
|
323
|
+
return value || fallback;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function optionalNonEmpty(value: string | null): string | undefined {
|
|
327
|
+
return value || undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function attachCompositionSource(
|
|
331
|
+
entry: TimelineElement,
|
|
332
|
+
element: HTMLElement,
|
|
333
|
+
compositionSrc: string | null,
|
|
334
|
+
): TimelineElement {
|
|
335
|
+
if (compositionSrc) return { ...entry, compositionSrc };
|
|
336
|
+
const innerVideo = element.querySelector("video[src]");
|
|
337
|
+
if (!innerVideo) return entry;
|
|
338
|
+
return { ...entry, src: optionalNonEmpty(innerVideo.getAttribute("src")), tag: "video" };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function buildMissingCompositionEntry(params: {
|
|
342
|
+
doc: Document;
|
|
343
|
+
iframeWin: IframeWindow;
|
|
344
|
+
element: HTMLElement;
|
|
345
|
+
compositionId: string;
|
|
346
|
+
rootDuration: number;
|
|
347
|
+
fallbackIndex: number;
|
|
348
|
+
resolveEnd: (refId: string, visiting: ReadonlySet<string>) => number | null;
|
|
349
|
+
}): TimelineElement | null {
|
|
350
|
+
const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex, resolveEnd } =
|
|
351
|
+
params;
|
|
352
|
+
const timing = readClipTiming(element, {
|
|
353
|
+
resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])),
|
|
354
|
+
});
|
|
355
|
+
const window = clampCompositionWindow(
|
|
356
|
+
timing.start ?? 0,
|
|
357
|
+
timing.duration ?? timelineDuration(iframeWin, compositionId),
|
|
358
|
+
rootDuration,
|
|
359
|
+
);
|
|
360
|
+
if (!window) return null;
|
|
361
|
+
|
|
362
|
+
const preferredId = nonEmpty(element.id, compositionId);
|
|
363
|
+
const compositionSrc =
|
|
364
|
+
element.getAttribute("data-composition-src") ?? element.getAttribute("data-composition-file");
|
|
365
|
+
const selector = getTimelineElementSelector(element);
|
|
366
|
+
const sourceFile = getTimelineElementSourceFile(element);
|
|
367
|
+
const selectorIndex = getTimelineElementSelectorIndex(doc, element, selector);
|
|
368
|
+
const label = getTimelineElementDisplayLabel({
|
|
369
|
+
id: preferredId,
|
|
370
|
+
label: element.getAttribute("data-timeline-label") ?? element.getAttribute("data-label"),
|
|
371
|
+
tag: element.tagName,
|
|
372
|
+
});
|
|
373
|
+
const identity = buildTimelineElementIdentity({
|
|
374
|
+
preferredId,
|
|
375
|
+
label,
|
|
376
|
+
fallbackIndex,
|
|
377
|
+
domId: optionalNonEmpty(element.id),
|
|
378
|
+
selector,
|
|
379
|
+
selectorIndex,
|
|
380
|
+
sourceFile,
|
|
381
|
+
});
|
|
382
|
+
const entry: TimelineElement = {
|
|
383
|
+
id: identity.id,
|
|
384
|
+
label,
|
|
385
|
+
key: identity.key,
|
|
386
|
+
tag: element.tagName.toLowerCase(),
|
|
387
|
+
start: window.start,
|
|
388
|
+
duration: window.duration,
|
|
389
|
+
track: timing.trackIndex,
|
|
390
|
+
domId: optionalNonEmpty(element.id),
|
|
391
|
+
hfId: optionalNonEmpty(element.getAttribute("data-hf-id")),
|
|
392
|
+
selector,
|
|
393
|
+
selectorIndex,
|
|
394
|
+
sourceFile,
|
|
395
|
+
zIndex: readTimelineElementZIndex(element),
|
|
396
|
+
};
|
|
397
|
+
return attachCompositionSource(entry, element, compositionSrc);
|
|
398
|
+
}
|
|
399
|
+
|
|
261
400
|
/**
|
|
262
401
|
* Scan the iframe DOM for composition hosts missing from the current
|
|
263
402
|
* timeline elements and add them. The CDN runtime often fails to resolve
|
|
@@ -279,117 +418,24 @@ export function buildMissingCompositionElements(
|
|
|
279
418
|
const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
|
|
280
419
|
const missing: TimelineElement[] = [];
|
|
281
420
|
|
|
282
|
-
|
|
421
|
+
const resolveEnd = createReferenceEndResolver(createTimedElementLookup(doc), iframeWin);
|
|
422
|
+
|
|
423
|
+
for (const host of hosts) {
|
|
283
424
|
const el = host as HTMLElement;
|
|
284
425
|
const compId = el.getAttribute("data-composition-id");
|
|
285
|
-
if (!compId || compId === rootCompId)
|
|
286
|
-
if (existingIds.has(el.id) || existingIds.has(compId))
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
doc.getElementById(startAttr) ||
|
|
294
|
-
doc.querySelector(`[data-composition-id="${CSS.escape(startAttr)}"]`);
|
|
295
|
-
if (ref) {
|
|
296
|
-
const refStartAttr = ref.getAttribute("data-start") ?? "0";
|
|
297
|
-
let refStart = parseFloat(refStartAttr);
|
|
298
|
-
// Recursively resolve one level of reference for the ref's own start
|
|
299
|
-
if (isNaN(refStart)) {
|
|
300
|
-
const refRef =
|
|
301
|
-
doc.getElementById(refStartAttr) ||
|
|
302
|
-
doc.querySelector(`[data-composition-id="${CSS.escape(refStartAttr)}"]`);
|
|
303
|
-
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
|
|
304
|
-
const rrCompId = refRef?.getAttribute("data-composition-id");
|
|
305
|
-
const rrDur =
|
|
306
|
-
parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
|
|
307
|
-
(rrCompId
|
|
308
|
-
? ((
|
|
309
|
-
iframeWin.__timelines?.[rrCompId] as { duration?: () => number } | undefined
|
|
310
|
-
)?.duration?.() ?? 0)
|
|
311
|
-
: 0);
|
|
312
|
-
refStart = rrStart + rrDur;
|
|
313
|
-
}
|
|
314
|
-
const refCompId = ref.getAttribute("data-composition-id");
|
|
315
|
-
const refDur =
|
|
316
|
-
parseFloat(ref.getAttribute("data-duration") ?? "") ||
|
|
317
|
-
(refCompId
|
|
318
|
-
? ((
|
|
319
|
-
iframeWin.__timelines?.[refCompId] as { duration?: () => number } | undefined
|
|
320
|
-
)?.duration?.() ?? 0)
|
|
321
|
-
: 0);
|
|
322
|
-
start = refStart + refDur;
|
|
323
|
-
} else {
|
|
324
|
-
start = 0;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Resolve duration from data-duration or GSAP timeline
|
|
329
|
-
let dur = parseFloat(el.getAttribute("data-duration") ?? "");
|
|
330
|
-
if (isNaN(dur) || dur <= 0) {
|
|
331
|
-
dur =
|
|
332
|
-
(
|
|
333
|
-
iframeWin.__timelines?.[compId] as { duration?: () => number } | undefined
|
|
334
|
-
)?.duration?.() ?? 0;
|
|
335
|
-
}
|
|
336
|
-
if (!Number.isFinite(dur) || dur <= 0) return;
|
|
337
|
-
if (!Number.isFinite(start)) start = 0;
|
|
338
|
-
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
|
339
|
-
if (start >= rootDuration) return;
|
|
340
|
-
dur = Math.min(dur, Math.max(0, rootDuration - start));
|
|
341
|
-
if (dur <= 0) return;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const trackStr = el.getAttribute("data-track-index");
|
|
345
|
-
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
|
346
|
-
// fallow-ignore-next-line code-duplication
|
|
347
|
-
const compSrc =
|
|
348
|
-
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
|
349
|
-
const selector = getTimelineElementSelector(el);
|
|
350
|
-
const sourceFile = getTimelineElementSourceFile(el);
|
|
351
|
-
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
|
352
|
-
const label = getTimelineElementDisplayLabel({
|
|
353
|
-
id: el.id || compId || null,
|
|
354
|
-
label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
|
|
355
|
-
tag: el.tagName,
|
|
356
|
-
});
|
|
357
|
-
const identity = buildTimelineElementIdentity({
|
|
358
|
-
preferredId: el.id || compId || null,
|
|
359
|
-
label,
|
|
426
|
+
if (!compId || compId === rootCompId) continue;
|
|
427
|
+
if (existingIds.has(el.id) || existingIds.has(compId)) continue;
|
|
428
|
+
const entry = buildMissingCompositionEntry({
|
|
429
|
+
doc,
|
|
430
|
+
iframeWin,
|
|
431
|
+
element: el,
|
|
432
|
+
compositionId: compId,
|
|
433
|
+
rootDuration,
|
|
360
434
|
fallbackIndex: missing.length,
|
|
361
|
-
|
|
362
|
-
selector,
|
|
363
|
-
selectorIndex,
|
|
364
|
-
sourceFile,
|
|
435
|
+
resolveEnd,
|
|
365
436
|
});
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
label,
|
|
369
|
-
key: identity.key,
|
|
370
|
-
tag: el.tagName.toLowerCase(),
|
|
371
|
-
start,
|
|
372
|
-
duration: dur,
|
|
373
|
-
track: isNaN(track) ? 0 : track,
|
|
374
|
-
domId: el.id || undefined,
|
|
375
|
-
hfId: el.getAttribute("data-hf-id") || undefined,
|
|
376
|
-
selector,
|
|
377
|
-
selectorIndex,
|
|
378
|
-
sourceFile,
|
|
379
|
-
zIndex: readTimelineElementZIndex(el),
|
|
380
|
-
};
|
|
381
|
-
if (compSrc) {
|
|
382
|
-
entry.compositionSrc = compSrc;
|
|
383
|
-
} else {
|
|
384
|
-
// Inline composition — expose inner video for thumbnails
|
|
385
|
-
const innerVideo = el.querySelector("video[src]");
|
|
386
|
-
if (innerVideo) {
|
|
387
|
-
entry.src = innerVideo.getAttribute("src") || undefined;
|
|
388
|
-
entry.tag = "video";
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
missing.push(entry);
|
|
392
|
-
});
|
|
437
|
+
if (entry) missing.push(entry);
|
|
438
|
+
}
|
|
393
439
|
|
|
394
440
|
// Patch existing elements that are missing compositionSrc
|
|
395
441
|
let patched = false;
|
|
@@ -114,4 +114,23 @@ describe("dom edit save queue", () => {
|
|
|
114
114
|
expect(onOpen).not.toHaveBeenCalled();
|
|
115
115
|
queue.destroy();
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
it("pauses immediately on a file conflict instead of retrying stale work", async () => {
|
|
119
|
+
const onOpen = vi.fn();
|
|
120
|
+
const queue = createDomEditSaveQueue({ failureThreshold: 5, onOpen });
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
queue.enqueue(async () => {
|
|
124
|
+
throw new StudioSaveHttpError("File changed elsewhere", 409);
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow("File changed elsewhere");
|
|
127
|
+
|
|
128
|
+
expect(onOpen).toHaveBeenCalledWith({
|
|
129
|
+
consecutiveFailures: 1,
|
|
130
|
+
errorMessage: "File changed elsewhere",
|
|
131
|
+
statusCode: 409,
|
|
132
|
+
});
|
|
133
|
+
await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
|
|
134
|
+
queue.destroy();
|
|
135
|
+
});
|
|
117
136
|
});
|
|
@@ -59,7 +59,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
59
59
|
return result;
|
|
60
60
|
} catch (error) {
|
|
61
61
|
consecutiveFailures += 1;
|
|
62
|
-
if (consecutiveFailures >= failureThreshold)
|
|
62
|
+
if (getStudioSaveStatusCode(error) === 409 || consecutiveFailures >= failureThreshold)
|
|
63
|
+
open(error);
|
|
63
64
|
throw error;
|
|
64
65
|
}
|
|
65
66
|
};
|
|
@@ -223,7 +223,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
223
223
|
target: "hf-abc",
|
|
224
224
|
styles: { color: "red", opacity: "0.5" },
|
|
225
225
|
});
|
|
226
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>");
|
|
226
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>", "before");
|
|
227
227
|
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
228
228
|
});
|
|
229
229
|
|
|
@@ -852,7 +852,11 @@ describe("sdkDeletePersist", () => {
|
|
|
852
852
|
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
|
853
853
|
expect(result.status).toBe("committed");
|
|
854
854
|
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
|
|
855
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
855
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
856
|
+
"/comp.html",
|
|
857
|
+
"<html>after</html>",
|
|
858
|
+
"before",
|
|
859
|
+
);
|
|
856
860
|
});
|
|
857
861
|
|
|
858
862
|
it("records edit history with before/after diff", async () => {
|
|
@@ -937,7 +941,11 @@ describe("sdkTimingPersist", () => {
|
|
|
937
941
|
duration: 5,
|
|
938
942
|
trackIndex: 1,
|
|
939
943
|
});
|
|
940
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
944
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
945
|
+
"/comp.html",
|
|
946
|
+
"<html>after</html>",
|
|
947
|
+
"<html>before</html>",
|
|
948
|
+
);
|
|
941
949
|
});
|
|
942
950
|
|
|
943
951
|
it("captures before-state before setTiming dispatch", async () => {
|
|
@@ -1158,7 +1166,11 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
1158
1166
|
"hf-box",
|
|
1159
1167
|
expect.objectContaining({ method: "to" }),
|
|
1160
1168
|
);
|
|
1161
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1169
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1170
|
+
"/comp.html",
|
|
1171
|
+
"<html>after</html>",
|
|
1172
|
+
"<html>before</html>",
|
|
1173
|
+
);
|
|
1162
1174
|
});
|
|
1163
1175
|
|
|
1164
1176
|
it("returns false for kind=add when element not found", async () => {
|
|
@@ -1262,7 +1274,11 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
1262
1274
|
position: 50,
|
|
1263
1275
|
value: { opacity: 0.5 },
|
|
1264
1276
|
});
|
|
1265
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1277
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1278
|
+
"/comp.html",
|
|
1279
|
+
"<html>after</html>",
|
|
1280
|
+
"<html>before</html>",
|
|
1281
|
+
);
|
|
1266
1282
|
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
1267
1283
|
});
|
|
1268
1284
|
|
|
@@ -37,7 +37,7 @@ export interface CutoverDeps {
|
|
|
37
37
|
* Must be bound to one project. Its identity plus path scopes the shared
|
|
38
38
|
* mutation queue used by every whole-file writer in that project.
|
|
39
39
|
*/
|
|
40
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
40
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
41
41
|
reloadPreview: () => void;
|
|
42
42
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
43
43
|
refresh?: (after: string) => void;
|
|
@@ -148,13 +148,14 @@ function isCutoverResult(value: CandidateEdit | CutoverResult): value is Cutover
|
|
|
148
148
|
async function rollbackWrite(
|
|
149
149
|
targetPath: string,
|
|
150
150
|
originalContent: string,
|
|
151
|
+
expectedCurrentContent: string,
|
|
151
152
|
deps: CutoverDeps,
|
|
152
153
|
cause: Error,
|
|
153
154
|
): Promise<Error> {
|
|
154
155
|
try {
|
|
155
156
|
deps.domEditSaveTimestampRef.current = Date.now();
|
|
156
157
|
markSelfWrite(targetPath, originalContent);
|
|
157
|
-
await deps.writeProjectFile(targetPath, originalContent);
|
|
158
|
+
await deps.writeProjectFile(targetPath, originalContent, expectedCurrentContent);
|
|
158
159
|
return cause;
|
|
159
160
|
} catch (rollbackError) {
|
|
160
161
|
return new AggregateError(
|
|
@@ -174,7 +175,7 @@ async function writeAndRecord(
|
|
|
174
175
|
deps.domEditSaveTimestampRef.current = Date.now();
|
|
175
176
|
markSelfWrite(targetPath, after);
|
|
176
177
|
try {
|
|
177
|
-
await deps.writeProjectFile(targetPath, after);
|
|
178
|
+
await deps.writeProjectFile(targetPath, after, originalContent);
|
|
178
179
|
} catch (error) {
|
|
179
180
|
return asCutoverError(error);
|
|
180
181
|
}
|
|
@@ -188,7 +189,7 @@ async function writeAndRecord(
|
|
|
188
189
|
});
|
|
189
190
|
return null;
|
|
190
191
|
} catch (error) {
|
|
191
|
-
return rollbackWrite(targetPath, originalContent, deps, asCutoverError(error));
|
|
192
|
+
return rollbackWrite(targetPath, originalContent, after, deps, asCutoverError(error));
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
|
|
@@ -14,7 +14,7 @@ export interface RecordEditInput {
|
|
|
14
14
|
export interface DomEditCommitBaseParams {
|
|
15
15
|
activeCompPath: string | null;
|
|
16
16
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
17
|
-
writeProjectFile:
|
|
17
|
+
writeProjectFile: ProjectFileWriter;
|
|
18
18
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
19
19
|
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
|
20
20
|
projectIdRef: MutableRefObject<string | null>;
|
|
@@ -22,6 +22,8 @@ export interface DomEditCommitBaseParams {
|
|
|
22
22
|
clearDomSelection: () => void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
26
|
+
|
|
25
27
|
interface SaveProjectFilesWithHistoryInput {
|
|
26
28
|
projectId: string;
|
|
27
29
|
label: string;
|
|
@@ -30,7 +32,7 @@ interface SaveProjectFilesWithHistoryInput {
|
|
|
30
32
|
coalesceMs?: number;
|
|
31
33
|
files: Record<string, string>;
|
|
32
34
|
readFile: (path: string) => Promise<string>;
|
|
33
|
-
writeFile:
|
|
35
|
+
writeFile: ProjectFileWriter;
|
|
34
36
|
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -71,7 +73,7 @@ export async function saveProjectFilesWithHistory({
|
|
|
71
73
|
const writtenPaths: string[] = [];
|
|
72
74
|
try {
|
|
73
75
|
for (const path of changedPaths) {
|
|
74
|
-
await writeFile(path, snapshots[path].after);
|
|
76
|
+
await writeFile(path, snapshots[path].after, snapshots[path].before);
|
|
75
77
|
writtenPaths.push(path);
|
|
76
78
|
}
|
|
77
79
|
|
|
@@ -79,7 +81,7 @@ export async function saveProjectFilesWithHistory({
|
|
|
79
81
|
} catch (error) {
|
|
80
82
|
try {
|
|
81
83
|
for (const path of writtenPaths.reverse()) {
|
|
82
|
-
await writeFile(path, snapshots[path].before);
|
|
84
|
+
await writeFile(path, snapshots[path].before, snapshots[path].after);
|
|
83
85
|
}
|
|
84
86
|
} catch (rollbackError) {
|
|
85
87
|
throw new AggregateError(
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { studioExpectedFileVersion, studioFileContentVersion } from "./studioFileVersion";
|
|
3
|
+
|
|
4
|
+
describe("studioFileContentVersion", () => {
|
|
5
|
+
it("matches the strong SHA-256 ETag format used by studio-server", async () => {
|
|
6
|
+
await expect(studioFileContentVersion("abc")).resolves.toBe(
|
|
7
|
+
'"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("keeps an explicit content precondition authoritative over cached state", async () => {
|
|
12
|
+
const versions = new Map<string, string | null>([
|
|
13
|
+
["stale.html", await studioFileContentVersion("stale")],
|
|
14
|
+
["newer.html", await studioFileContentVersion("newer")],
|
|
15
|
+
["missing.html", null],
|
|
16
|
+
]);
|
|
17
|
+
const expectedVersion = await studioFileContentVersion("expected");
|
|
18
|
+
|
|
19
|
+
expect(await studioExpectedFileVersion(versions, "stale.html", "expected")).toBe(
|
|
20
|
+
expectedVersion,
|
|
21
|
+
);
|
|
22
|
+
expect(await studioExpectedFileVersion(versions, "newer.html", "expected")).toBe(
|
|
23
|
+
expectedVersion,
|
|
24
|
+
);
|
|
25
|
+
expect(await studioExpectedFileVersion(versions, "missing.html", "expected")).toBe(
|
|
26
|
+
expectedVersion,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps known-missing and untracked files distinct without explicit content", async () => {
|
|
31
|
+
const versions = new Map<string, string | null>([["missing.html", null]]);
|
|
32
|
+
|
|
33
|
+
expect(await studioExpectedFileVersion(versions, "missing.html")).toBeNull();
|
|
34
|
+
expect(await studioExpectedFileVersion(versions, "untracked.html")).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Browser-safe SHA-256 version matching studio-server's strong ETag format. */
|
|
2
|
+
export async function studioFileContentVersion(content: string): Promise<string> {
|
|
3
|
+
const bytes = new TextEncoder().encode(content);
|
|
4
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
5
|
+
const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
|
6
|
+
"",
|
|
7
|
+
);
|
|
8
|
+
return `"sha256:${hex}"`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Prefer an explicit content precondition, then the version observed during the read. */
|
|
12
|
+
export async function studioExpectedFileVersion(
|
|
13
|
+
versions: ReadonlyMap<string, string | null>,
|
|
14
|
+
path: string,
|
|
15
|
+
expectedContent?: string,
|
|
16
|
+
): Promise<string | null | undefined> {
|
|
17
|
+
if (expectedContent !== undefined) return studioFileContentVersion(expectedContent);
|
|
18
|
+
return versions.get(path);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createStudioWriteToken(): string {
|
|
22
|
+
return globalThis.crypto.randomUUID();
|
|
23
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
StudioFileConflictError,
|
|
3
4
|
StudioSaveHttpError,
|
|
4
5
|
StudioSaveNetworkError,
|
|
5
6
|
buildStudioSaveFailureProperties,
|
|
@@ -8,6 +9,23 @@ import {
|
|
|
8
9
|
} from "./studioSaveDiagnostics";
|
|
9
10
|
|
|
10
11
|
describe("studio save diagnostics", () => {
|
|
12
|
+
it("preserves conflict versions and both sides for explicit recovery UI", () => {
|
|
13
|
+
const error = new StudioFileConflictError({
|
|
14
|
+
filePath: "index.html",
|
|
15
|
+
currentVersion: '"sha256:new"',
|
|
16
|
+
currentContent: "external",
|
|
17
|
+
attemptedContent: "local",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(getStudioSaveStatusCode(error)).toBe(409);
|
|
21
|
+
expect(error).toMatchObject({
|
|
22
|
+
filePath: "index.html",
|
|
23
|
+
currentVersion: '"sha256:new"',
|
|
24
|
+
currentContent: "external",
|
|
25
|
+
attemptedContent: "local",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
11
29
|
it("builds save_failure properties with stable diagnostics", () => {
|
|
12
30
|
const error = new StudioSaveHttpError("Failed to save index.html (503)", 503);
|
|
13
31
|
|
|
@@ -35,6 +35,27 @@ export class StudioSaveNetworkError extends Error {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export class StudioFileConflictError extends StudioSaveHttpError {
|
|
39
|
+
readonly filePath: string;
|
|
40
|
+
readonly currentVersion: string | null;
|
|
41
|
+
readonly currentContent: string | null;
|
|
42
|
+
readonly attemptedContent: string;
|
|
43
|
+
|
|
44
|
+
constructor(input: {
|
|
45
|
+
filePath: string;
|
|
46
|
+
currentVersion: string | null;
|
|
47
|
+
currentContent: string | null;
|
|
48
|
+
attemptedContent: string;
|
|
49
|
+
}) {
|
|
50
|
+
super(`Save conflict: ${input.filePath} changed outside this Studio session`, 409);
|
|
51
|
+
this.name = "StudioFileConflictError";
|
|
52
|
+
this.filePath = input.filePath;
|
|
53
|
+
this.currentVersion = input.currentVersion;
|
|
54
|
+
this.currentContent = input.currentContent;
|
|
55
|
+
this.attemptedContent = input.attemptedContent;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
function readNumericProperty(value: object, key: string): number | undefined {
|
|
39
60
|
const record = value as Record<string, unknown>;
|
|
40
61
|
const property = record[key];
|