From 8a0dccfc72224d993efdb0ae8d95fc0e020327d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 4 Aug 2026 15:43:20 -0700 Subject: [PATCH] feat(studio): add external conflict recovery UI (#2992) --- .../ExternalFileConflictBanner.test.tsx | 116 ++++++++ .../components/ExternalFileConflictBanner.tsx | 255 ++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 packages/studio/src/components/ExternalFileConflictBanner.test.tsx create mode 100644 packages/studio/src/components/ExternalFileConflictBanner.tsx 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. +

+
+ +
+
+ {[ + { title: "File on disk", content: external, suffix: "external" }, + { title: "Unsaved Studio version", content: studio, suffix: "studio" }, + ].map((side) => ( +
+
+

{side.title}

+
+ + +
+
+