diff --git a/packages/studio/src/components/ExternalFileConflictBanner.test.tsx b/packages/studio/src/components/ExternalFileConflictBanner.test.tsx new file mode 100644 index 000000000..37112e62e --- /dev/null +++ b/packages/studio/src/components/ExternalFileConflictBanner.test.tsx @@ -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: "external", + attemptedContent: "studio", + }); + 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()); + 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([ + "external", + "studio", + ]); + + 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: "recover me", + recovered: false, + }, + retry: vi.fn(async () => undefined), + useExternalFile: vi.fn(async () => undefined), + keepStudioFile: vi.fn(async () => undefined), + }; + + await act(async () => root.render()); + 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("recover me"); + 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()); + 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()); + }); +}); diff --git a/packages/studio/src/components/ExternalFileConflictBanner.tsx b/packages/studio/src/components/ExternalFileConflictBanner.tsx new file mode 100644 index 000000000..1abddf20c --- /dev/null +++ b/packages/studio/src/components/ExternalFileConflictBanner.tsx @@ -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; + onClose: () => void; +}) { + const containerRef = useRef(null); + useDialogBehavior({ open: true, onClose, containerRef }); + const external = conflict.error.currentContent ?? "(The server did not return file contents.)"; + const studio = conflict.error.attemptedContent; + + return ( + + event.stopPropagation()} + > + + + + Review both versions of {conflict.error.filePath} + + + Reviewing or exporting does not change either version. + + + + Close + + + + {[ + { title: "File on disk", content: external, suffix: "external" }, + { title: "Unsaved Studio version", content: studio, suffix: "studio" }, + ].map((side) => ( + + + {side.title} + + void navigator.clipboard.writeText(side.content)} + > + Copy + + + downloadText(`${conflict.error.filePath}.${side.suffix}.html`, side.content) + } + > + Download + + + + + + ))} + + + + ); +} + +function FailedDraftReview({ + path, + content, + onClose, +}: { + path: string; + content: string; + onClose: () => void; +}) { + const containerRef = useRef(null); + useDialogBehavior({ open: true, onClose, containerRef }); + return ( + + event.stopPropagation()} + > + + + + Recover unsaved Studio draft for {path} + + + Copy or download this draft before choosing to discard it. + + + + Close + + + + void navigator.clipboard.writeText(content)} + > + Copy + + downloadText(`${path}.studio.html`, content)} + > + Download + + + + + + ); +} + +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 ( + <> + + + {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.`} + + {conflict && ( + setReviewing(true)} className="underline"> + Review or export both + + )} + {failure?.studioContent != null && ( + setReviewing(true)} className="underline"> + Review or export Studio draft + + )} + {failure && !failure.recovered && ( + void coordinator.retry()} className="underline"> + Retry save + + )} + void coordinator.useExternalFile()} + className="rounded border border-amber-200/30 px-2 py-1" + > + Discard Studio edits and reload file + + {conflict && ( + { + 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 + + )} + {failure?.recovered && failure.studioContent != null && ( + { + 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 + + )} + + {reviewing && conflict && ( + setReviewing(false)} /> + )} + {reviewing && failure?.studioContent != null && ( + setReviewing(false)} + /> + )} + > + ); +}
+ Reviewing or exporting does not change either version. +
+ Copy or download this draft before choosing to discard it. +