mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): coordinate external file changes (#2991)
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
||||
import { markStudioWriteToken, resetStudioWriteTokens } from "../utils/studioFileVersion";
|
||||
import {
|
||||
useExternalFileChangeCoordinator,
|
||||
type ExternalFileChangeCoordinatorHandle,
|
||||
} from "./useExternalFileChangeCoordinator";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
type HotHandler = (payload?: unknown) => void;
|
||||
type CoordinatorOptions = Parameters<typeof useExternalFileChangeCoordinator>[0];
|
||||
const roots: Array<ReturnType<typeof createRoot>> = [];
|
||||
let handler: HotHandler | null;
|
||||
|
||||
async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
|
||||
const captured: { handle: ExternalFileChangeCoordinatorHandle | null } = { handle: null };
|
||||
const defaults: CoordinatorOptions = {
|
||||
projectId: "project-a",
|
||||
activeCompPath: "index.html",
|
||||
pendingTimelineEditPathRef: { current: new Set() },
|
||||
drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })),
|
||||
reloadPreview: vi.fn(),
|
||||
reloadSdkSession: vi.fn(),
|
||||
persistConflictSnapshot: vi.fn(async () => undefined),
|
||||
discardPendingChanges: vi.fn(),
|
||||
overwriteConflict: vi.fn(async () => undefined),
|
||||
readProjectFile: vi.fn(async () => "external"),
|
||||
};
|
||||
const options = { ...defaults, ...overrides };
|
||||
const root = createRoot(document.createElement("div"));
|
||||
roots.push(root);
|
||||
function Probe() {
|
||||
captured.handle = useExternalFileChangeCoordinator(options);
|
||||
return null;
|
||||
}
|
||||
await act(async () => root.render(<Probe />));
|
||||
return { captured, options };
|
||||
}
|
||||
|
||||
describe("external file change coordinator", () => {
|
||||
beforeEach(() => {
|
||||
handler = null;
|
||||
resetStudioWriteTokens();
|
||||
vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", {
|
||||
on: (_event: string, next: HotHandler) => {
|
||||
handler = next;
|
||||
},
|
||||
off: () => {
|
||||
handler = null;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (roots.length > 0) await act(async () => roots.pop()?.unmount());
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("drains before reloading Preview and SDK exactly once", async () => {
|
||||
const order: string[] = [];
|
||||
const { captured } = await mountCoordinator({
|
||||
drainPendingChanges: async () => {
|
||||
order.push("drain");
|
||||
return { status: "clean" };
|
||||
},
|
||||
reloadPreview: () => order.push("preview"),
|
||||
reloadSdkSession: () => order.push("sdk"),
|
||||
});
|
||||
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
||||
expect(order).toEqual(["drain", "preview", "sdk"]);
|
||||
expect(captured.handle?.blocked).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses an exact Studio write receipt", async () => {
|
||||
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
|
||||
const reloadPreview = vi.fn();
|
||||
const reloadSdkSession = vi.fn();
|
||||
await mountCoordinator({ drainPendingChanges, reloadPreview, reloadSdkSession });
|
||||
markStudioWriteToken("studio-write-1");
|
||||
await act(async () =>
|
||||
handler?.({ path: "index.html", content: "studio", writeToken: "studio-write-1" }),
|
||||
);
|
||||
expect(drainPendingChanges).not.toHaveBeenCalled();
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(reloadSdkSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not suppress a racing external write by path alone", async () => {
|
||||
const pendingTimelineEditPathRef = { current: new Set(["index.html"]) };
|
||||
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
|
||||
const reloadPreview = vi.fn();
|
||||
const reloadSdkSession = vi.fn();
|
||||
await mountCoordinator({
|
||||
pendingTimelineEditPathRef,
|
||||
drainPendingChanges,
|
||||
reloadPreview,
|
||||
reloadSdkSession,
|
||||
});
|
||||
await act(async () => handler?.({ path: "index.html", content: "agent edit", version: "v2" }));
|
||||
expect(pendingTimelineEditPathRef.current).not.toContain("index.html");
|
||||
expect(drainPendingChanges).toHaveBeenCalledOnce();
|
||||
expect(reloadPreview).toHaveBeenCalledOnce();
|
||||
expect(reloadSdkSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks both reloads and retains a complete conflict", async () => {
|
||||
const conflict = new StudioFileConflictError({
|
||||
filePath: "index.html",
|
||||
currentVersion: "v2",
|
||||
currentContent: "external",
|
||||
attemptedContent: "studio",
|
||||
});
|
||||
const persistConflictSnapshot = vi.fn(async () => undefined);
|
||||
const { captured, options } = await mountCoordinator({
|
||||
drainPendingChanges: async () => ({ status: "conflict", error: conflict }),
|
||||
persistConflictSnapshot,
|
||||
});
|
||||
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
|
||||
expect(persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict);
|
||||
expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
|
||||
expect(options.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(options.reloadSdkSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores stale drain completion after a newer generation", async () => {
|
||||
const drains: Array<(result: { status: "clean" }) => void> = [];
|
||||
const { options } = await mountCoordinator({
|
||||
drainPendingChanges: () => new Promise((resolve) => drains.push(resolve)),
|
||||
});
|
||||
act(() => {
|
||||
handler?.({ path: "index.html", content: "first", version: "v2" });
|
||||
handler?.({ path: "index.html", content: "second", version: "v3" });
|
||||
});
|
||||
await act(async () => drains[0]?.({ status: "clean" }));
|
||||
expect(options.reloadPreview).not.toHaveBeenCalled();
|
||||
await act(async () => drains[1]?.({ status: "clean" }));
|
||||
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("restores a durable unresolved conflict after remount", async () => {
|
||||
const { captured } = await mountCoordinator({
|
||||
recoveryFilePath: "index.html",
|
||||
loadConflictSnapshot: vi.fn(async () => ({
|
||||
kind: "conflict" as const,
|
||||
projectId: "project-a",
|
||||
filePath: "index.html",
|
||||
externalVersion: "v2",
|
||||
externalContent: "external",
|
||||
studioContent: "studio",
|
||||
createdAt: 100,
|
||||
})),
|
||||
});
|
||||
await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("conflict"));
|
||||
expect(captured.handle?.blocked).toMatchObject({
|
||||
error: { currentContent: "external", attemptedContent: "studio" },
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the final local candidate when a drain fails", async () => {
|
||||
const failure = new Error("network unavailable");
|
||||
const persistFailureSnapshot = vi.fn(async () => undefined);
|
||||
const deleteConflictSnapshot = vi.fn(async () => undefined);
|
||||
const { captured } = await mountCoordinator({
|
||||
drainPendingChanges: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "failed" as const, error: failure })
|
||||
.mockResolvedValueOnce({ status: "clean" as const }),
|
||||
getPendingCandidate: () => ({ path: "index.html", content: "final local candidate" }),
|
||||
persistFailureSnapshot,
|
||||
deleteConflictSnapshot,
|
||||
});
|
||||
await act(async () => handler?.({ path: "index.html" }));
|
||||
expect(captured.handle?.blocked).toMatchObject({
|
||||
status: "failed",
|
||||
error: failure,
|
||||
studioContent: "final local candidate",
|
||||
});
|
||||
expect(persistFailureSnapshot).toHaveBeenCalledWith(
|
||||
"project-a",
|
||||
"index.html",
|
||||
"final local candidate",
|
||||
null,
|
||||
null,
|
||||
failure,
|
||||
);
|
||||
await act(async () => captured.handle?.retry());
|
||||
expect(deleteConflictSnapshot).toHaveBeenCalledWith("project-a", "index.html");
|
||||
});
|
||||
|
||||
it("restores and overwrites from a durable failed draft", async () => {
|
||||
const overwriteConflict = vi.fn(async () => undefined);
|
||||
const { captured } = await mountCoordinator({
|
||||
recoveryFilePath: "index.html",
|
||||
overwriteConflict,
|
||||
loadConflictSnapshot: vi.fn(async () => ({
|
||||
kind: "failed" as const,
|
||||
projectId: "project-a",
|
||||
filePath: "index.html",
|
||||
externalVersion: "v2",
|
||||
externalContent: "external",
|
||||
studioContent: "recover me",
|
||||
failureMessage: "network unavailable",
|
||||
createdAt: 100,
|
||||
})),
|
||||
});
|
||||
await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("failed"));
|
||||
expect(captured.handle?.blocked).toMatchObject({
|
||||
studioContent: "recover me",
|
||||
recovered: true,
|
||||
});
|
||||
await act(async () => captured.handle?.keepStudioFile());
|
||||
expect(overwriteConflict).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ attemptedContent: "recover me", currentVersion: "v2" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
||||
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
||||
import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
|
||||
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
||||
import { consumeStudioWriteToken } from "../utils/studioFileVersion";
|
||||
|
||||
type ExternalChangeDrainResult =
|
||||
| { status: "clean" }
|
||||
| { status: "conflict"; error: StudioFileConflictError }
|
||||
| { status: "failed"; error: unknown };
|
||||
|
||||
export type ExternalFileChangeBlockedState =
|
||||
| {
|
||||
status: "conflict";
|
||||
generation: number;
|
||||
error: StudioFileConflictError;
|
||||
payload: unknown;
|
||||
}
|
||||
| {
|
||||
status: "failed";
|
||||
generation: number;
|
||||
path: string;
|
||||
error: unknown;
|
||||
payload: unknown;
|
||||
studioContent: string | null;
|
||||
recovered: boolean;
|
||||
};
|
||||
|
||||
interface ExternalFileChangeCoordinatorOptions {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
recoveryFilePath?: string | null;
|
||||
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
||||
drainPendingChanges: () => Promise<ExternalChangeDrainResult>;
|
||||
getPendingCandidate?: () => { path: string; content: string } | null;
|
||||
discardPendingChanges: () => void;
|
||||
reloadPreview: () => void;
|
||||
reloadSdkSession: (path: string) => void;
|
||||
persistConflictSnapshot: (projectId: string, conflict: StudioFileConflictError) => Promise<void>;
|
||||
persistFailureSnapshot?: (
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
studioContent: string,
|
||||
externalVersion: string | null,
|
||||
externalContent: string | null,
|
||||
error: unknown,
|
||||
) => Promise<void>;
|
||||
loadConflictSnapshot?: (
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
) => Promise<ExternalConflictSnapshot | null>;
|
||||
deleteConflictSnapshot?: (projectId: string, filePath: string) => Promise<void>;
|
||||
overwriteConflict: (conflict: StudioFileConflictError) => Promise<void>;
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
onUseExternalFile?: (path: string, content: string) => void;
|
||||
resetSaveQueues?: () => void;
|
||||
}
|
||||
|
||||
export interface ExternalFileChangeCoordinatorHandle {
|
||||
blocked: ExternalFileChangeBlockedState | null;
|
||||
retry: () => Promise<void>;
|
||||
useExternalFile: () => Promise<void>;
|
||||
keepStudioFile: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface HotTestAdapter {
|
||||
on(event: string, handler: (payload?: unknown) => void): void;
|
||||
off(event: string, handler: (payload?: unknown) => void): void;
|
||||
}
|
||||
|
||||
function testHotAdapter(): HotTestAdapter | null {
|
||||
const value = (globalThis as { __HF_STUDIO_HOT_TEST_ADAPTER__?: unknown })
|
||||
.__HF_STUDIO_HOT_TEST_ADAPTER__;
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<HotTestAdapter>;
|
||||
return typeof candidate.on === "function" && typeof candidate.off === "function"
|
||||
? (candidate as HotTestAdapter)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readFileChangeContent(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.content === "string") return record.content;
|
||||
return "data" in record ? readFileChangeContent(record.data) : null;
|
||||
}
|
||||
|
||||
function readFileChangeVersion(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.version === "string") return record.version;
|
||||
return "data" in record ? readFileChangeVersion(record.data) : null;
|
||||
}
|
||||
|
||||
function readFileChangeWriteToken(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (typeof record.writeToken === "string") return record.writeToken;
|
||||
return "data" in record ? readFileChangeWriteToken(record.data) : null;
|
||||
}
|
||||
|
||||
function eventIdentity(path: string, payload: unknown): string | null {
|
||||
const version = readFileChangeVersion(payload);
|
||||
if (version) return `${path}\0${version}`;
|
||||
const content = readFileChangeContent(payload);
|
||||
return content == null ? null : `${path}\0${content.length}\0${content}`;
|
||||
}
|
||||
|
||||
export function useExternalFileChangeCoordinator({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
recoveryFilePath = activeCompPath,
|
||||
pendingTimelineEditPathRef,
|
||||
drainPendingChanges,
|
||||
getPendingCandidate,
|
||||
discardPendingChanges,
|
||||
reloadPreview,
|
||||
reloadSdkSession,
|
||||
persistConflictSnapshot,
|
||||
persistFailureSnapshot,
|
||||
loadConflictSnapshot,
|
||||
deleteConflictSnapshot,
|
||||
overwriteConflict,
|
||||
readProjectFile,
|
||||
onUseExternalFile,
|
||||
resetSaveQueues,
|
||||
}: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
|
||||
const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const lastEventIdentityRef = useRef<string | null>(null);
|
||||
const blockedRef = useRef(blocked);
|
||||
const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
|
||||
blockedRef.current = blocked;
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
generationRef.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
setBlocked(null);
|
||||
lastEventIdentityRef.current = null;
|
||||
}, [projectId, activeCompPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !recoveryFilePath || !loadConflictSnapshot) return;
|
||||
const generation = ++generationRef.current;
|
||||
let cancelled = false;
|
||||
void loadConflictSnapshot(projectId, recoveryFilePath)
|
||||
.then((snapshot) => {
|
||||
if (cancelled || !snapshot || !mountedRef.current || generation !== generationRef.current) {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
path: snapshot.filePath,
|
||||
version: snapshot.externalVersion,
|
||||
content: snapshot.externalContent,
|
||||
};
|
||||
if (snapshot.kind === "failed") {
|
||||
setBlocked({
|
||||
status: "failed",
|
||||
generation,
|
||||
path: snapshot.filePath,
|
||||
error: new Error(snapshot.failureMessage),
|
||||
payload,
|
||||
studioContent: snapshot.studioContent,
|
||||
recovered: true,
|
||||
});
|
||||
} else {
|
||||
const error = new StudioFileConflictError({
|
||||
filePath: snapshot.filePath,
|
||||
currentVersion: snapshot.externalVersion,
|
||||
currentContent: snapshot.externalContent,
|
||||
attemptedContent: snapshot.studioContent,
|
||||
});
|
||||
setBlocked({ status: "conflict", generation, error, payload });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Storage may be unavailable in restricted browser contexts. A failed
|
||||
// best-effort restore must not create an unhandled rejection or block
|
||||
// a project that has no known recovery record.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadConflictSnapshot, projectId, recoveryFilePath]);
|
||||
|
||||
const reloadAcceptedGeneration = useCallback(
|
||||
(path: string) => {
|
||||
reloadPreview();
|
||||
reloadSdkSession(path);
|
||||
},
|
||||
[reloadPreview, reloadSdkSession],
|
||||
);
|
||||
|
||||
const persistSnapshotInOrder = useCallback(async (write: () => Promise<void>) => {
|
||||
const next = snapshotWriteTailRef.current.catch(() => undefined).then(write);
|
||||
snapshotWriteTailRef.current = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
await next;
|
||||
}, []);
|
||||
|
||||
const processChange = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (payload: unknown, allowDuplicate = false) => {
|
||||
const path = readStudioFileChangePath(payload);
|
||||
if (!path || !projectId) return;
|
||||
const pendingTimelinePaths = pendingTimelineEditPathRef.current;
|
||||
// The old path-only suppression could drop a real agent/user write that
|
||||
// raced ahead of the timeline write receipt. Clear the legacy marker but
|
||||
// decide ownership only from the exact write token/content below.
|
||||
pendingTimelinePaths.delete(path);
|
||||
|
||||
const content = readFileChangeContent(payload);
|
||||
if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return;
|
||||
if (content != null && isSelfWriteEcho(path, content)) return;
|
||||
|
||||
const identity = eventIdentity(path, payload);
|
||||
if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) return;
|
||||
lastEventIdentityRef.current = identity;
|
||||
const generation = ++generationRef.current;
|
||||
const result = await drainPendingChanges();
|
||||
if (!mountedRef.current || generation !== generationRef.current) return;
|
||||
|
||||
if (result.status === "clean") {
|
||||
const previousBlocked = blockedRef.current;
|
||||
if (previousBlocked?.status === "failed" && deleteConflictSnapshot) {
|
||||
try {
|
||||
await deleteConflictSnapshot(projectId, path);
|
||||
} catch (error) {
|
||||
if (mountedRef.current && generation === generationRef.current) {
|
||||
setBlocked({ ...previousBlocked, generation, error });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!mountedRef.current || generation !== generationRef.current) return;
|
||||
setBlocked(null);
|
||||
reloadAcceptedGeneration(path);
|
||||
return;
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
const candidate = getPendingCandidate?.();
|
||||
const studioContent = candidate?.path === path ? candidate.content : null;
|
||||
let error = result.error;
|
||||
if (studioContent != null && persistFailureSnapshot) {
|
||||
try {
|
||||
await persistSnapshotInOrder(() =>
|
||||
persistFailureSnapshot(
|
||||
projectId,
|
||||
path,
|
||||
studioContent,
|
||||
readFileChangeVersion(payload),
|
||||
content,
|
||||
result.error,
|
||||
),
|
||||
);
|
||||
} catch (snapshotError) {
|
||||
error = new Error(
|
||||
`Studio could not save the edit or its recovery snapshot: ${
|
||||
snapshotError instanceof Error ? snapshotError.message : String(snapshotError)
|
||||
}`,
|
||||
{ cause: result.error },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!mountedRef.current || generation !== generationRef.current) return;
|
||||
setBlocked({
|
||||
status: "failed",
|
||||
generation,
|
||||
path,
|
||||
error,
|
||||
payload,
|
||||
studioContent,
|
||||
recovered: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await persistSnapshotInOrder(() => persistConflictSnapshot(projectId, result.error));
|
||||
} catch (error) {
|
||||
if (!mountedRef.current || generation !== generationRef.current) return;
|
||||
setBlocked({
|
||||
status: "failed",
|
||||
generation,
|
||||
path,
|
||||
error,
|
||||
payload,
|
||||
studioContent: result.error.attemptedContent,
|
||||
recovered: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current || generation !== generationRef.current) return;
|
||||
setBlocked({ status: "conflict", generation, error: result.error, payload });
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
pendingTimelineEditPathRef,
|
||||
drainPendingChanges,
|
||||
deleteConflictSnapshot,
|
||||
getPendingCandidate,
|
||||
persistConflictSnapshot,
|
||||
persistFailureSnapshot,
|
||||
persistSnapshotInOrder,
|
||||
reloadAcceptedGeneration,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (payload?: unknown) => processChange(payload);
|
||||
const adapter = testHotAdapter();
|
||||
if (adapter) {
|
||||
adapter.on("hf:file-change", handler);
|
||||
return () => adapter.off("hf:file-change", handler);
|
||||
}
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on("hf:file-change", handler);
|
||||
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
||||
}
|
||||
const eventSource = new EventSource("/api/events");
|
||||
eventSource.addEventListener("file-change", handler);
|
||||
return () => eventSource.close();
|
||||
}, [processChange]);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
const current = blockedRef.current;
|
||||
if (!current || current.status === "conflict" || current.recovered) return;
|
||||
resetSaveQueues?.();
|
||||
lastEventIdentityRef.current = null;
|
||||
await processChange(current.payload, true);
|
||||
}, [processChange, resetSaveQueues]);
|
||||
|
||||
const useExternalFile = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async () => {
|
||||
const current = blockedRef.current;
|
||||
if (!current || !projectId || current.generation !== generationRef.current) return;
|
||||
const path = current.status === "conflict" ? current.error.filePath : current.path;
|
||||
const external =
|
||||
current.status === "conflict" && current.error.currentContent != null
|
||||
? current.error.currentContent
|
||||
: await readProjectFile(path);
|
||||
if (current.generation !== generationRef.current) return;
|
||||
discardPendingChanges();
|
||||
resetSaveQueues?.();
|
||||
onUseExternalFile?.(path, external);
|
||||
await deleteConflictSnapshot?.(projectId, path);
|
||||
setBlocked(null);
|
||||
reloadAcceptedGeneration(path);
|
||||
},
|
||||
[
|
||||
deleteConflictSnapshot,
|
||||
discardPendingChanges,
|
||||
onUseExternalFile,
|
||||
projectId,
|
||||
readProjectFile,
|
||||
reloadAcceptedGeneration,
|
||||
resetSaveQueues,
|
||||
],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const keepStudioFile = useCallback(async () => {
|
||||
const current = blockedRef.current;
|
||||
if (!current || !projectId) return;
|
||||
if (current.generation !== generationRef.current) return;
|
||||
let conflict: StudioFileConflictError;
|
||||
if (current.status === "conflict") {
|
||||
conflict = current.error;
|
||||
} else {
|
||||
if (!current.recovered || current.studioContent == null) return;
|
||||
try {
|
||||
const currentContent =
|
||||
readFileChangeContent(current.payload) ?? (await readProjectFile(current.path));
|
||||
conflict = new StudioFileConflictError({
|
||||
filePath: current.path,
|
||||
currentVersion: readFileChangeVersion(current.payload),
|
||||
currentContent,
|
||||
attemptedContent: current.studioContent,
|
||||
});
|
||||
} catch (error) {
|
||||
if (current.generation === generationRef.current) setBlocked({ ...current, error });
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await overwriteConflict(conflict);
|
||||
} catch (error) {
|
||||
if (current.generation === generationRef.current) {
|
||||
setBlocked({
|
||||
status: "failed",
|
||||
generation: current.generation,
|
||||
path: conflict.filePath,
|
||||
error,
|
||||
payload: current.payload,
|
||||
studioContent: conflict.attemptedContent,
|
||||
recovered: current.status === "failed" && current.recovered,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current.generation !== generationRef.current) return;
|
||||
discardPendingChanges();
|
||||
resetSaveQueues?.();
|
||||
await deleteConflictSnapshot?.(projectId, conflict.filePath);
|
||||
setBlocked(null);
|
||||
reloadAcceptedGeneration(conflict.filePath);
|
||||
}, [
|
||||
deleteConflictSnapshot,
|
||||
discardPendingChanges,
|
||||
overwriteConflict,
|
||||
projectId,
|
||||
readProjectFile,
|
||||
reloadAcceptedGeneration,
|
||||
resetSaveQueues,
|
||||
]);
|
||||
|
||||
return { blocked, retry, useExternalFile, keepStudioFile };
|
||||
}
|
||||
Reference in New Issue
Block a user