Files
hyperframes/packages/studio/src/utils/studioFileHistory.ts
T
Miguel Ángel 1a53681de1 fix(studio): an automation lane edit is refused on a locked clip and reports how it saved (#4533)
* fix(studio): an automation lane edit is refused on a locked clip and reports how it saved

Lane drags, Delete and paste save through the timeline's gated clip or group
save instead of the property panel's commit, so the host's canEdit applies and
the commit resolves saved, refused or failed. A landed save refreshes the lane
store and the panel's selection, as the panel's commit did.

* fix(studio): a refused automation save puts the preview and the lane back

A save that does not land (recording, no id, a failed write) reverts the live
preview it was given, the lane drops a draft whose save came back refused or
failed, the no-id and no-project failures toast like the others, and the panel
refresh reads the current selection. The panel commit's unused coalesce option
goes, and the lane value reuses automationAttrValue.

* fix(studio): a failed save restores the preview it was given, not an older one

Each timeline save claims the preview's before-value when it starts, so a
gesture begun while it is in flight records its own and a later failed save
cannot put back a value from before an earlier save landed.

* chore(repo): allow the automation gesture key files this branch removes

* fix(studio): a refused automation save leaves the lane store and paste mark as saved

Reverting a live value re-syncs the lane store from the restored preview, so
an overlapping save that landed first cannot leave the store holding a later
value that failed. A paste whose save is refused or fails gives back the
selection and paste chain it replaced.

* fix(studio): a failed save settles on what the file holds and a refused paste marks nothing

A save that does not land settles the preview and store on the file's value
(the claimed before-value only when the file cannot be read), and leaves a
gesture begun meanwhile alone apart from correcting its before-value, so two
overlapping failed saves cannot leave either on a value that never landed. A
paste selects and marks its span only once it lands and only over the
selection it was pasted at.

* fix(studio): a failed save reads the file only after writes queued to it land

The settle read goes through the same per-path queue as the writes, so a save
that fails while another is queued settles the preview, the store and any
newer gesture's before-value on what that queued save wrote.

* fix(studio): an older failed save never settles over a newer edit on the same lane

Live-preview bookkeeping moves to createLiveLanes, one owner for both timeline
saves: each lane key carries a generation that every live write and save start
advances, and a save settles the preview and store (on its landed value, or on
the file after a failure) only while it is still the newest. A paste marks its
span only if it is still the latest paste.

* test(studio): a newer live drag keeps its preview when an older save recovers

* fix(studio): every finished save records what the file holds, even under a newer edit

A save's settle now always writes the file's value (landed, or read back after
a failure) into the lane store and a live gesture's before-value, and moves the
preview only while nothing newer has touched the lane. The clip store is set
from that value rather than copied from the preview. A paste marks its span
only while its clip is still the selected one.

* fix(studio): an older automation save never overrides a newer gesture on its lane

A failed save's recovery read could settle after a newer save started, and the
save's own optimistic preview patch and failure unwind ran over a newer drag.
Every preview write a save makes now goes through the lane's ownership check,
only the newest save records its value, and a refused drag hands the lane back
to the newest pending save.

* fix(studio): timeline attribute saves land in the order they start

An attribute save read the file before joining its write queue, so an older
save with a slow read could land after a newer one, overwrite it on disk and
leave the lane showing the newer value. The save now joins the queue first and
reads, patches and writes inside it. A save moves the preview only while it is
the newest save and no drag is live, which replaces the per-lane generation.

* refactor(studio): name the in-queue history write for the queue it needs

* test(studio): pin save start order and a newer release's preview mid-flight

* fix(studio): a drag keeps its points when an older save lands under it

An older save that landed mid-drag wrote the lane store, the lane dropped its
draft, and the release committed the older points. The lane now keeps its draft
while a gesture is live. A save that cannot read the file back settles on the
last value read or saved on its lane, never on an unsaved one, and an older save
that lands records its value there even when a newer save is pending.

* fix(studio): a lane's first failed save still puts its drag back

With the claimed-value fallback gone, a first save on a lane that failed and
could not read the file back settled on nothing, leaving the drag playing. A
save begun with no other save pending now records the value under its drag as
verified, since that is what the file last settled on.

* fix(studio): only a before-value read with no save pending counts as saved

The value under a drag was trusted when its save started with nothing pending,
but it was read when the drag began, possibly over a paste that later failed.
A lane now marks a before-value clean only when no save was pending as the drag
began. A group lane's preview carries its store mirror, as its live writes do.

* fix(studio): a lane's saved value never crosses into another project

The lane bookkeeping outlives a project switch and its keys had no project, so
a value saved in one project could settle into another project's clip with the
same key when a save there failed and could not read the file back. Lane keys
now carry the open project, and a save moves the preview or store only while
the project it was made in is still open.

* fix(studio): a failed save reads its file back from its own project

A save that failed after a project switch read its recovery from the newly
opened project and recorded that project's value as its own. Each save now
captures its project when it starts and reads back from it.

* fix(studio): a failed save never brings back a value an undo removed

A lane kept its last verified value after its saves settled, so after an undo a
save that failed and could not read the file back settled on the removed value.
The value now lives only while a save on the lane is in flight, and each save
records what its own in-queue file read found. The group preview's store mirror
is no longer needed and is gone.

* test(studio): pass the file-read callback in the direct attribute-save tests

* fix(studio): a drag whose before-value a save settled counts as clean

When a settle rewrites a live drag's before-value to a verified value, that
value is known saved, so the drag's own save can fall back on it even when its
read and read-back both fail. Adds a test for a failed save reading back from
its own project while another save is in flight across a switch.

* fix(studio): a save started in one composition never lands in another of the same project

Live lanes were scoped to the project only, so a save held across a switch
to another composition of that project settled its value into the new
composition's preview (clip and group lanes) and store (group lane).
Lanes are now scoped to the project and the composition open when the save
starts. createLiveLanes moves to its own module for the 600-line cap.

* fix(studio): an undo during or after a lane gesture stays undone when the next save fails

A live gesture records the lane's value before it starts, and a later save
that cannot read the file settles on that value. An undo mid-drag, or after a
gesture that ended without saving, left that value stale, so the save brought
back what the undo removed. Every undo and redo now re-reads each open lane's
value from the files it restored.

* fix(studio): undoing group automation clears it from the members' lanes at once

The group-info cache only noticed a change when its MutationObserver callback
ran, so the soft restore's store sync, which reads it synchronously, mirrored
the pre-undo group automation onto the members. A read now counts mutations
the observer has not delivered yet.

* fix(studio): an undo reaches every lane and pending save it rewrote, in any composition

The undo re-read only covered lanes in the open composition and live gestures.
History is project-wide, so undoing from another composition left the
rewritten composition's lanes on the pre-undo value, and a save released while
the undo was in flight kept that value as its fallback. The re-read now covers
every lane of the project on a restored path, including saves still pending,
and an undone file it cannot read makes the value untrusted instead of dropped.
A missing element reads as unreadable, the same as a save's read-back.

* fix(studio): a cancelled fade, stretch or preset audition leaves no stale lane value behind

A cancel put the gesture's start back through a live write, which left the
lane's before-value in place until the next save trusted it, even after the
property panel, the source editor or an agent had changed the file. The fade
cancel, the edge-stretch cancel and leaving a preset audition now end through
the lanes' revert, so a before-value exists only during a live gesture.

* test(studio): pin that a group write is read back fresh in the same task

* style(studio): drop two comments the names already carry

* fix(studio): a failed group save reads its own file back; fade cancels work without revert

- The group save's failure read-back used the file of whatever composition was
  loaded when the write failed. It now reads the file captured when the save
  started, as the clip save does.
- A fade cancel writes its start back live before it reverts, like the other
  cancel sites, so a host without the optional revert callback still puts the
  fade back.
- The edge-stretch cancel comment says what the code now does.
- The save test harness passes the revert callbacks, as the app does; without
  them the cancelled-gesture tests exercised a host that lacks them.

* refactor(studio): trim three long comments on live automation saves
2026-09-27 09:05:54 +00:00

115 lines
3.9 KiB
TypeScript

import { buildProjectApiPath } from "./projectRouting";
import type { MutableRefObject } from "react";
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
export interface RecordEditInput {
label: string;
coalesceKey?: string;
coalesceMs?: number;
files: Record<string, { before: string; after: string }>;
}
export interface DomEditCommitBaseParams {
activeCompPath: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
writeProjectFile: ProjectFileWriter;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
projectIdRef: MutableRefObject<string | null>;
reloadPreview: () => void;
clearDomSelection: () => void;
}
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
interface SaveProjectFilesWithHistoryInput {
projectId: string;
label: string;
coalesceKey?: string;
coalesceMs?: number;
files: Record<string, string>;
readFile: (path: string) => Promise<string>;
writeFile: ProjectFileWriter;
recordEdit: (entry: RecordEditInput) => Promise<void>;
/**
* What a path holds ON DISK right now, when that is not the same as the
* history's "before".
*
* The two are normally one value, so the write's optimistic-concurrency
* expectation was taken straight from the undo baseline. They come apart when
* a server-side mutation has already written part of the edit: deleting a clip
* POSTs `remove-element`, which rewrites the file, and only then saves the
* duration shrink — expecting the pre-delete content it read at the start. The
* server had moved the file on, so the write was refused as a conflict, the
* save queue paused, and the clip stayed on the timeline until a reload.
*
* Undo still restores `before`; this only says what to expect on disk.
*/
diskContent?: Record<string, string>;
}
export async function readProjectFileContent(pid: string, path: string): Promise<string> {
const response = await fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent(path)}`));
if (!response.ok) {
throw await createStudioSaveHttpError(response, `Failed to read ${path}`);
}
const data = (await response.json()) as { content?: string };
if (typeof data.content !== "string") {
throw new Error(`Missing file contents for ${path}`);
}
return data.content;
}
export async function saveProjectFilesWithHistory(
input: SaveProjectFilesWithHistoryInput,
): Promise<string[]> {
return serializeStudioFileMutations(input.writeFile, Object.keys(input.files), () =>
writeProjectFilesWithHistoryInQueue(input),
);
}
export async function writeProjectFilesWithHistoryInQueue({
label,
coalesceKey,
coalesceMs,
files,
readFile,
writeFile,
recordEdit,
diskContent,
}: SaveProjectFilesWithHistoryInput): Promise<string[]> {
const snapshots: Record<string, { before: string; after: string }> = {};
for (const [path, after] of Object.entries(files)) {
const before = await readFile(path);
if (before !== after) {
snapshots[path] = { before, after };
}
}
const changedPaths = Object.keys(snapshots);
if (changedPaths.length === 0) return [];
const writtenPaths: string[] = [];
try {
for (const path of changedPaths) {
await writeFile(path, snapshots[path].after, diskContent?.[path] ?? snapshots[path].before);
writtenPaths.push(path);
}
await recordEdit({ label, coalesceKey, coalesceMs, files: snapshots });
} catch (error) {
try {
for (const path of writtenPaths.reverse()) {
await writeFile(path, snapshots[path].before, snapshots[path].after);
}
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
"Failed to save project files and rollback did not complete",
);
}
throw error;
}
return changedPaths;
}