mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): journal source writebacks (#1388)
This commit is contained in:
@@ -75,6 +75,7 @@ docs/plans/
|
||||
# Local proof / test artifacts
|
||||
qa-artifacts/
|
||||
my-video/
|
||||
.hyperframes/backup/
|
||||
examples/*
|
||||
# Tracked OSS examples — negations override the blanket `examples/*` ignore.
|
||||
!examples/aws-lambda
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { backupPathForResponse, snapshotBeforeWrite } from "./backupJournal";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createProjectDir(): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-backup-journal-"));
|
||||
tempDirs.push(projectDir);
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
describe("snapshotBeforeWrite", () => {
|
||||
it("copies the current file bytes before overwrite", () => {
|
||||
const projectDir = createProjectDir();
|
||||
mkdirSync(join(projectDir, "compositions"), { recursive: true });
|
||||
const file = join(projectDir, "compositions", "scene.html");
|
||||
writeFileSync(file, "before");
|
||||
|
||||
const result = snapshotBeforeWrite(projectDir, file);
|
||||
writeFileSync(file, "after");
|
||||
|
||||
expect(result.backupPath && existsSync(result.backupPath)).toBe(true);
|
||||
expect(readFileSync(result.backupPath!, "utf-8")).toBe("before");
|
||||
expect(backupPathForResponse(projectDir, result.backupPath)).toMatch(
|
||||
/^\.hyperframes\/backup\//,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates backups for zero-byte files", () => {
|
||||
const projectDir = createProjectDir();
|
||||
const file = join(projectDir, "empty.html");
|
||||
writeFileSync(file, "");
|
||||
|
||||
const result = snapshotBeforeWrite(projectDir, file);
|
||||
|
||||
expect(result.backupPath && existsSync(result.backupPath)).toBe(true);
|
||||
expect(readFileSync(result.backupPath!, "utf-8")).toBe("");
|
||||
});
|
||||
|
||||
it("prunes older backups for the same file", () => {
|
||||
const projectDir = createProjectDir();
|
||||
const file = join(projectDir, "index.html");
|
||||
writeFileSync(file, "0");
|
||||
|
||||
for (let i = 1; i <= 5; i += 1) {
|
||||
writeFileSync(file, String(i));
|
||||
snapshotBeforeWrite(projectDir, file, { keepPerFile: 3 });
|
||||
}
|
||||
|
||||
expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not prune backups for paths with colliding sanitized names", () => {
|
||||
const projectDir = createProjectDir();
|
||||
const first = join(projectDir, "My File.html");
|
||||
const second = join(projectDir, "My_File.html");
|
||||
writeFileSync(first, "space");
|
||||
writeFileSync(second, "underscore");
|
||||
|
||||
snapshotBeforeWrite(projectDir, first, { keepPerFile: 1 });
|
||||
snapshotBeforeWrite(projectDir, second, { keepPerFile: 1 });
|
||||
|
||||
const backups = readdirSync(join(projectDir, ".hyperframes", "backup"));
|
||||
expect(backups).toHaveLength(2);
|
||||
expect(
|
||||
backups
|
||||
.map((name) => readFileSync(join(projectDir, ".hyperframes", "backup", name), "utf-8"))
|
||||
.sort(),
|
||||
).toEqual(["space", "underscore"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { join, relative } from "node:path";
|
||||
import { isSafePath } from "./safePath.js";
|
||||
|
||||
const DEFAULT_KEEP_PER_FILE = 10;
|
||||
|
||||
export interface BackupJournalResult {
|
||||
backupPath: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function backupKeyForPath(path: string): string {
|
||||
return Buffer.from(path, "utf-8").toString("base64url");
|
||||
}
|
||||
|
||||
function timestampPrefix(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
export function backupPathForResponse(
|
||||
projectDir: string,
|
||||
backupPath: string | null,
|
||||
): string | null {
|
||||
if (!backupPath) return null;
|
||||
const rel = relative(projectDir, backupPath);
|
||||
if (!rel || rel.startsWith("..")) return null;
|
||||
return rel.split("\\").join("/");
|
||||
}
|
||||
|
||||
export function snapshotBeforeWrite(
|
||||
projectDir: string,
|
||||
absPath: string,
|
||||
options: { keepPerFile?: number } = {},
|
||||
): BackupJournalResult {
|
||||
if (!isSafePath(projectDir, absPath)) return { backupPath: null };
|
||||
|
||||
try {
|
||||
const content = readFileSync(absPath);
|
||||
|
||||
const relativePath = relative(projectDir, absPath);
|
||||
const backupDir = join(projectDir, ".hyperframes", "backup");
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
const backupKey = backupKeyForPath(relativePath);
|
||||
const backupPath = nextBackupPath(backupDir, backupKey);
|
||||
writeFileSync(backupPath, content);
|
||||
pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
|
||||
return { backupPath };
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error.code === "ENOENT" || error.code === "EISDIR")
|
||||
) {
|
||||
return { backupPath: null };
|
||||
}
|
||||
return { backupPath: null, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function nextBackupPath(backupDir: string, backupKey: string): string {
|
||||
const base = `${timestampPrefix()}-${backupKey}`;
|
||||
let candidate = join(backupDir, base);
|
||||
let counter = 2;
|
||||
while (true) {
|
||||
try {
|
||||
readFileSync(candidate);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return candidate;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
candidate = join(backupDir, `${base}-${counter}`);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneBackups(backupDir: string, backupKey: string, keepPerFile: number): void {
|
||||
const keep = Math.max(1, Math.floor(keepPerFile));
|
||||
const suffix = `-${backupKey}`;
|
||||
const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
|
||||
const matches = readdirSync(backupDir)
|
||||
.filter((name) => name.endsWith(suffix) || numberedSuffix.test(name))
|
||||
.map((name) => join(backupDir, name))
|
||||
.sort((a, b) => {
|
||||
return b.localeCompare(a);
|
||||
});
|
||||
|
||||
for (const file of matches.slice(keep)) {
|
||||
try {
|
||||
unlinkSync(file);
|
||||
} catch {
|
||||
// Backup pruning is best-effort and must not block the user's write.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { walkDir } from "./safePath";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createProjectDir(): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-safe-path-"));
|
||||
tempDirs.push(projectDir);
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
describe("walkDir", () => {
|
||||
it("hides internal HyperFrames backup files from project listings", () => {
|
||||
const projectDir = createProjectDir();
|
||||
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
|
||||
mkdirSync(join(projectDir, "compositions"), { recursive: true });
|
||||
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup");
|
||||
writeFileSync(join(projectDir, "compositions", "scene.html"), "scene");
|
||||
|
||||
expect(walkDir(projectDir)).toEqual(["compositions/scene.html"]);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ export function isSafePath(base: string, resolved: string): boolean {
|
||||
return resolved.startsWith(norm) || resolved === resolve(base);
|
||||
}
|
||||
|
||||
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
|
||||
const IGNORE_DIRS = new Set([".hyperframes", ".thumbnails", "node_modules", ".git"]);
|
||||
|
||||
/** Recursively walk a directory and return relative file paths. */
|
||||
export function walkDir(dir: string, prefix = ""): string[] {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerFileRoutes } from "./files";
|
||||
@@ -64,6 +64,74 @@ describe("registerFileRoutes", () => {
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("backs up the previous file content before PUT overwrite", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(join(projectDir, "index.html"), "before");
|
||||
const app = new Hono();
|
||||
registerFileRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/files/index.html", {
|
||||
method: "PUT",
|
||||
body: "after",
|
||||
});
|
||||
const payload = (await response.json()) as { path?: string; backupPath?: string };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.path).toBe("index.html");
|
||||
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
|
||||
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before");
|
||||
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after");
|
||||
});
|
||||
|
||||
it("backs up the previous file content before delete", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(join(projectDir, "index.html"), "before delete");
|
||||
const app = new Hono();
|
||||
registerFileRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/files/index.html", {
|
||||
method: "DELETE",
|
||||
});
|
||||
const payload = (await response.json()) as { backupPath?: string };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
|
||||
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before delete");
|
||||
});
|
||||
|
||||
it("backs up the previous file content before structured DOM mutations", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(projectDir + "/index.html", '<div id="title">Before</div>');
|
||||
const app = new Hono();
|
||||
registerFileRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const response = await app.request(
|
||||
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target: { id: "title" },
|
||||
operations: [{ type: "text-content", property: "textContent", value: "After" }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
changed?: boolean;
|
||||
path?: string;
|
||||
backupPath?: string;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.changed).toBe(true);
|
||||
expect(payload.path).toBe("index.html");
|
||||
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
|
||||
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(
|
||||
'<div id="title">Before</div>',
|
||||
);
|
||||
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After");
|
||||
});
|
||||
|
||||
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
|
||||
// targeting element variables resolved from querySelector, with interleaved
|
||||
// gsap.set() calls. This is the shape every scaffolded composition uses.
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isAudioFile } from "../helpers/mime.js";
|
||||
import { generateWaveformCache } from "../helpers/waveform.js";
|
||||
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
|
||||
import { isSafePath } from "../helpers/safePath.js";
|
||||
import { backupPathForResponse, snapshotBeforeWrite } from "../helpers/backupJournal.js";
|
||||
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
@@ -94,15 +95,25 @@ type MutationTarget = {
|
||||
/** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */
|
||||
function writeIfChanged(
|
||||
c: RouteContext,
|
||||
projectDir: string,
|
||||
filePath: string,
|
||||
absPath: string,
|
||||
original: string,
|
||||
next: string,
|
||||
): Response {
|
||||
if (next === original) {
|
||||
return c.json({ ok: true, changed: false, content: original });
|
||||
return c.json({ ok: true, changed: false, content: original, path: filePath });
|
||||
}
|
||||
const backup = snapshotBeforeWrite(projectDir, absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`);
|
||||
writeFileSync(absPath, next, "utf-8");
|
||||
return c.json({ ok: true, changed: true, content: next });
|
||||
return c.json({
|
||||
ok: true,
|
||||
changed: true,
|
||||
content: next,
|
||||
path: filePath,
|
||||
backupPath: backupPathForResponse(projectDir, backup.backupPath),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -815,9 +826,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
|
||||
ensureDir(res.absPath);
|
||||
const body = await c.req.text();
|
||||
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
|
||||
writeFileSync(res.absPath, body, "utf-8");
|
||||
|
||||
return c.json({ ok: true });
|
||||
return c.json({
|
||||
ok: true,
|
||||
path: res.filePath,
|
||||
backupPath: backupPathForResponse(res.project.dir, backup.backupPath),
|
||||
});
|
||||
});
|
||||
|
||||
// ── Create (fail if exists) ──
|
||||
@@ -844,13 +861,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
if ("error" in res) return res.error;
|
||||
|
||||
const stat = statSync(res.absPath);
|
||||
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
|
||||
if (stat.isDirectory()) {
|
||||
rmSync(res.absPath, { recursive: true });
|
||||
} else {
|
||||
unlinkSync(res.absPath);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
return c.json({
|
||||
ok: true,
|
||||
backupPath: backupPathForResponse(res.project.dir, backup.backupPath),
|
||||
});
|
||||
});
|
||||
|
||||
api.post("/projects/:id/file-mutations/remove-element/*", async (c) => {
|
||||
@@ -867,6 +889,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
const originalContent = readFileSync(ctx.absPath, "utf-8");
|
||||
return writeIfChanged(
|
||||
c,
|
||||
ctx.project.dir,
|
||||
ctx.filePath,
|
||||
ctx.absPath,
|
||||
originalContent,
|
||||
removeElementFromHtml(originalContent, parsed.target),
|
||||
@@ -900,10 +924,19 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
parsed.body.newId,
|
||||
);
|
||||
if (!result.matched) {
|
||||
return c.json({ ok: false, changed: false, content: originalContent });
|
||||
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
|
||||
}
|
||||
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
|
||||
writeFileSync(ctx.absPath, result.html, "utf-8");
|
||||
return c.json({ ok: true, changed: true, content: result.html, newId: result.newId });
|
||||
return c.json({
|
||||
ok: true,
|
||||
changed: true,
|
||||
content: result.html,
|
||||
newId: result.newId,
|
||||
path: ctx.filePath,
|
||||
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
|
||||
});
|
||||
});
|
||||
|
||||
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
|
||||
@@ -931,10 +964,25 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
parsed.body.operations,
|
||||
);
|
||||
if (patched === originalContent) {
|
||||
return c.json({ ok: true, changed: false, matched, content: originalContent });
|
||||
return c.json({
|
||||
ok: true,
|
||||
changed: false,
|
||||
matched,
|
||||
content: originalContent,
|
||||
path: ctx.filePath,
|
||||
});
|
||||
}
|
||||
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
||||
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
|
||||
writeFileSync(ctx.absPath, patched, "utf-8");
|
||||
return c.json({ ok: true, changed: true, matched, content: patched });
|
||||
return c.json({
|
||||
ok: true,
|
||||
changed: true,
|
||||
matched,
|
||||
content: patched,
|
||||
path: ctx.filePath,
|
||||
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
|
||||
});
|
||||
});
|
||||
|
||||
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {
|
||||
@@ -1113,7 +1161,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
const newScript = typeof result === "string" ? result : result.script;
|
||||
const changed = newScript !== block.scriptText;
|
||||
const newHtml = changed ? block.replaceScript(newScript) : html;
|
||||
let backupPath: string | null = null;
|
||||
if (changed) {
|
||||
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
||||
if (backup.error)
|
||||
console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
|
||||
backupPath = backupPathForResponse(res.project.dir, backup.backupPath);
|
||||
writeFileSync(res.absPath, newHtml, "utf-8");
|
||||
}
|
||||
|
||||
@@ -1126,6 +1179,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
before: html,
|
||||
after: newHtml,
|
||||
scriptText: newScript,
|
||||
path: res.filePath,
|
||||
backupPath,
|
||||
};
|
||||
if (typeof result !== "string" && result.skippedSelectors.length > 0) {
|
||||
responsePayload.skippedSelectors = result.skippedSelectors;
|
||||
|
||||
@@ -33,7 +33,6 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
||||
|
||||
// ── Helpers ──
|
||||
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
||||
|
||||
export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
|
||||
@@ -70,8 +69,6 @@ function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLEl
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
@@ -105,7 +102,6 @@ export interface UseDomEditCommitsParams {
|
||||
projectIdRef: React.MutableRefObject<string | null>;
|
||||
reloadPreview: () => void;
|
||||
|
||||
// From useDomSelection
|
||||
domEditSelection: DomEditSelection | null;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
@@ -119,8 +115,6 @@ export interface UseDomEditCommitsParams {
|
||||
) => Promise<DomEditSelection | null>;
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export function useDomEditCommits({
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
@@ -209,6 +203,7 @@ export function useDomEditCommits({
|
||||
changed?: boolean;
|
||||
matched?: boolean;
|
||||
content?: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
if (!patchData.changed) {
|
||||
@@ -248,6 +243,7 @@ export function useDomEditCommits({
|
||||
coalesceKey: options?.coalesceKey,
|
||||
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
||||
});
|
||||
showToast(`Updated ${patchData.path ?? targetPath}`, "info");
|
||||
|
||||
if (!options?.skipRefresh) {
|
||||
reloadPreview();
|
||||
@@ -260,6 +256,7 @@ export function useDomEditCommits({
|
||||
projectIdRef,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -285,6 +285,7 @@ export function useDomEditSession({
|
||||
reloadPreview,
|
||||
onCacheInvalidate: bumpGsapCache,
|
||||
onFileContentChanged: updateEditingFileContent,
|
||||
showToast,
|
||||
});
|
||||
|
||||
// ── Commit handlers (delegated to useDomEditCommits) ──
|
||||
|
||||
@@ -56,6 +56,7 @@ interface MutationResult {
|
||||
before?: string;
|
||||
after?: string;
|
||||
scriptText?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
async function mutateGsapScript(
|
||||
@@ -94,6 +95,7 @@ interface GsapScriptCommitsParams {
|
||||
reloadPreview: () => void;
|
||||
onCacheInvalidate: () => void;
|
||||
onFileContentChanged?: (path: string, content: string) => void;
|
||||
showToast?: (message: string, tone?: "error" | "info") => void;
|
||||
}
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
@@ -107,6 +109,7 @@ export function useGsapScriptCommits({
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
onFileContentChanged,
|
||||
showToast,
|
||||
}: GsapScriptCommitsParams) {
|
||||
const pendingPropertyEditRef = useRef<{
|
||||
selection: DomEditSelection;
|
||||
@@ -157,6 +160,7 @@ export function useGsapScriptCommits({
|
||||
if (result.after != null) {
|
||||
onFileContentChanged?.(targetPath, result.after);
|
||||
}
|
||||
showToast?.(`Updated ${result.path ?? targetPath}`, "info");
|
||||
|
||||
if (options.skipReload) return;
|
||||
|
||||
@@ -195,6 +199,7 @@ export function useGsapScriptCommits({
|
||||
reloadPreview,
|
||||
onCacheInvalidate,
|
||||
onFileContentChanged,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
const flushPendingPropertyEdit = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user