mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(studio): add external conflict recovery UI (#2992)
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
||||||
|
import type { ExternalFileChangeCoordinatorHandle } from "../hooks/useExternalFileChangeCoordinator";
|
||||||
|
import { ExternalFileConflictBanner } from "./ExternalFileConflictBanner";
|
||||||
|
|
||||||
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("ExternalFileConflictBanner", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps destructive choices explicit and exposes both full versions for review", async () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const conflict = new StudioFileConflictError({
|
||||||
|
filePath: "index.html",
|
||||||
|
currentVersion: "v2",
|
||||||
|
currentContent: "<html>external</html>",
|
||||||
|
attemptedContent: "<html>studio</html>",
|
||||||
|
});
|
||||||
|
const coordinator: ExternalFileChangeCoordinatorHandle = {
|
||||||
|
blocked: { status: "conflict", generation: 1, error: conflict, payload: {} },
|
||||||
|
retry: vi.fn(async () => undefined),
|
||||||
|
useExternalFile: vi.fn(async () => undefined),
|
||||||
|
keepStudioFile: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
|
||||||
|
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
|
||||||
|
"Preview is paused so neither version is lost",
|
||||||
|
);
|
||||||
|
expect(document.body.textContent).toContain("Discard Studio edits and reload file");
|
||||||
|
expect(document.body.textContent).toContain("Overwrite file with Studio version");
|
||||||
|
|
||||||
|
const review = Array.from(document.querySelectorAll("button")).find((button) =>
|
||||||
|
button.textContent?.includes("Review or export both"),
|
||||||
|
);
|
||||||
|
await act(async () => review?.click());
|
||||||
|
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
||||||
|
expect(Array.from(document.querySelectorAll("textarea"), (field) => field.value)).toEqual([
|
||||||
|
"<html>external</html>",
|
||||||
|
"<html>studio</html>",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets authors review and export the local candidate after a drain failure", async () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const coordinator: ExternalFileChangeCoordinatorHandle = {
|
||||||
|
blocked: {
|
||||||
|
status: "failed",
|
||||||
|
generation: 1,
|
||||||
|
path: "index.html",
|
||||||
|
error: new Error("offline"),
|
||||||
|
payload: {},
|
||||||
|
studioContent: "<html>recover me</html>",
|
||||||
|
recovered: false,
|
||||||
|
},
|
||||||
|
retry: vi.fn(async () => undefined),
|
||||||
|
useExternalFile: vi.fn(async () => undefined),
|
||||||
|
keepStudioFile: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
|
||||||
|
const review = Array.from(document.querySelectorAll("button")).find((button) =>
|
||||||
|
button.textContent?.includes("Review or export Studio draft"),
|
||||||
|
);
|
||||||
|
await act(async () => review?.click());
|
||||||
|
expect(document.querySelector("textarea")?.value).toBe("<html>recover me</html>");
|
||||||
|
expect(document.body.textContent).toContain("Copy");
|
||||||
|
expect(document.body.textContent).toContain("Download");
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer a fake retry after remount and instead offers explicit overwrite", async () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
const keepStudioFile = vi.fn(async () => undefined);
|
||||||
|
const coordinator: ExternalFileChangeCoordinatorHandle = {
|
||||||
|
blocked: {
|
||||||
|
status: "failed",
|
||||||
|
generation: 1,
|
||||||
|
path: "index.html",
|
||||||
|
error: new Error("offline"),
|
||||||
|
payload: { path: "index.html", version: "v2", content: "external" },
|
||||||
|
studioContent: "recovered draft",
|
||||||
|
recovered: true,
|
||||||
|
},
|
||||||
|
retry: vi.fn(async () => undefined),
|
||||||
|
useExternalFile: vi.fn(async () => undefined),
|
||||||
|
keepStudioFile,
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
|
||||||
|
expect(document.body.textContent).not.toContain("Retry save");
|
||||||
|
const overwrite = Array.from(document.querySelectorAll("button")).find((button) =>
|
||||||
|
button.textContent?.includes("Overwrite file with recovered Studio draft"),
|
||||||
|
);
|
||||||
|
expect(overwrite).toBeTruthy();
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
await act(async () => overwrite?.click());
|
||||||
|
expect(keepStudioFile).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import type {
|
||||||
|
ExternalFileChangeBlockedState,
|
||||||
|
ExternalFileChangeCoordinatorHandle,
|
||||||
|
} from "../hooks/useExternalFileChangeCoordinator";
|
||||||
|
import { useDialogBehavior } from "./ui/useDialogBehavior";
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadText(filename: string, content: string): void {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type: "text/html;charset=utf-8" }));
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConflictReview({
|
||||||
|
conflict,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
conflict: Extract<ExternalFileChangeBlockedState, { status: "conflict" }>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDialogBehavior({ open: true, onClose, containerRef });
|
||||||
|
const external = conflict.error.currentContent ?? "(The server did not return file contents.)";
|
||||||
|
const studio = conflict.error.attemptedContent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/70 px-5 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="external-conflict-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="max-h-[88vh] w-full max-w-5xl overflow-auto rounded-xl border border-amber-400/30 bg-neutral-950 p-5 text-neutral-100 shadow-2xl outline-none"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 id="external-conflict-title" className="text-base font-semibold">
|
||||||
|
Review both versions of {conflict.error.filePath}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">
|
||||||
|
Reviewing or exporting does not change either version.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="rounded px-2 py-1 text-neutral-400">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
{[
|
||||||
|
{ title: "File on disk", content: external, suffix: "external" },
|
||||||
|
{ title: "Unsaved Studio version", content: studio, suffix: "studio" },
|
||||||
|
].map((side) => (
|
||||||
|
<section key={side.suffix} aria-label={side.title}>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-medium">{side.title}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
|
||||||
|
onClick={() => void navigator.clipboard.writeText(side.content)}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
|
||||||
|
onClick={() =>
|
||||||
|
downloadText(`${conflict.error.filePath}.${side.suffix}.html`, side.content)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={side.content}
|
||||||
|
className="h-80 w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-3 font-mono text-[11px] leading-relaxed text-neutral-300"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FailedDraftReview({
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDialogBehavior({ open: true, onClose, containerRef });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/70 px-5 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="failed-draft-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="max-h-[88vh] w-full max-w-3xl overflow-auto rounded-xl border border-amber-400/30 bg-neutral-950 p-5 text-neutral-100 shadow-2xl outline-none"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 id="failed-draft-title" className="text-base font-semibold">
|
||||||
|
Recover unsaved Studio draft for {path}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">
|
||||||
|
Copy or download this draft before choosing to discard it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="rounded px-2 py-1 text-neutral-400">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
|
||||||
|
onClick={() => void navigator.clipboard.writeText(content)}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
|
||||||
|
onClick={() => downloadText(`${path}.studio.html`, content)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={content}
|
||||||
|
className="mt-2 h-80 w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-3 font-mono text-[11px] leading-relaxed text-neutral-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalFileConflictBanner({
|
||||||
|
coordinator,
|
||||||
|
}: {
|
||||||
|
coordinator: ExternalFileChangeCoordinatorHandle;
|
||||||
|
}) {
|
||||||
|
const [reviewing, setReviewing] = useState(false);
|
||||||
|
const blocked = coordinator.blocked;
|
||||||
|
if (!blocked) return null;
|
||||||
|
|
||||||
|
const conflict = blocked.status === "conflict" ? blocked : null;
|
||||||
|
const failure = blocked.status === "failed" ? blocked : null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="absolute left-1/2 top-14 z-[94] flex max-w-[calc(100vw-32px)] -translate-x-1/2 flex-wrap items-center gap-3 rounded-md border border-amber-400/30 bg-amber-950/95 px-4 py-2 text-[12px] font-medium text-amber-50 shadow-lg"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{conflict
|
||||||
|
? `${conflict.error.filePath} changed outside Studio. Preview is paused so neither version is lost.`
|
||||||
|
: `Studio could not safely finish local saves: ${errorMessage(blocked.error)}. Preview is paused.`}
|
||||||
|
</span>
|
||||||
|
{conflict && (
|
||||||
|
<button type="button" onClick={() => setReviewing(true)} className="underline">
|
||||||
|
Review or export both
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{failure?.studioContent != null && (
|
||||||
|
<button type="button" onClick={() => setReviewing(true)} className="underline">
|
||||||
|
Review or export Studio draft
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{failure && !failure.recovered && (
|
||||||
|
<button type="button" onClick={() => void coordinator.retry()} className="underline">
|
||||||
|
Retry save
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void coordinator.useExternalFile()}
|
||||||
|
className="rounded border border-amber-200/30 px-2 py-1"
|
||||||
|
>
|
||||||
|
Discard Studio edits and reload file
|
||||||
|
</button>
|
||||||
|
{conflict && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
"Overwrite the externally changed file with the Studio version? The server will preserve its normal backup before writing.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
void coordinator.keepStudioFile();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border border-red-300/40 px-2 py-1 text-red-100"
|
||||||
|
>
|
||||||
|
Overwrite file with Studio version
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{failure?.recovered && failure.studioContent != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
"Overwrite the file with the recovered Studio draft? The current file will be preserved by the server's normal backup before writing.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
void coordinator.keepStudioFile();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border border-red-300/40 px-2 py-1 text-red-100"
|
||||||
|
>
|
||||||
|
Overwrite file with recovered Studio draft
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{reviewing && conflict && (
|
||||||
|
<ConflictReview conflict={conflict} onClose={() => setReviewing(false)} />
|
||||||
|
)}
|
||||||
|
{reviewing && failure?.studioContent != null && (
|
||||||
|
<FailedDraftReview
|
||||||
|
path={failure.path}
|
||||||
|
content={failure.studioContent}
|
||||||
|
onClose={() => setReviewing(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user