mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +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