diff --git a/packages/mcp-server/shell/canvas-shell.tsx b/packages/mcp-server/shell/canvas-shell.tsx index 1d14fcef..e602960d 100644 --- a/packages/mcp-server/shell/canvas-shell.tsx +++ b/packages/mcp-server/shell/canvas-shell.tsx @@ -4,7 +4,7 @@ import { CanvasStage } from "@/components/canvas/canvas-stage" import { CompareDialog } from "@/components/canvas/compare-dialog" import { VersionStrip } from "@/components/canvas/version-strip" import { VersionsProvider } from "@/components/canvas/versions-context" -import { SaveDialog } from "@/components/save-dialog" +import { type ExportFormat, SaveDialog } from "@/components/save-dialog" import { AppToaster } from "@/components/workspace/app-toaster" import { useDiagram } from "@/contexts/diagram-context" import { useDictionary } from "@/hooks/use-dictionary" @@ -29,7 +29,7 @@ const iconButton = export function CanvasShell({ config }: { config: ShellConfig }) { const dict = useDictionary() const isDark = useSettingsStore((s) => s.isDark) - const { saveDiagramToFile } = useDiagram() + const { saveDiagramToFile, hasPendingLoad } = useDiagram() const { status, sync } = useMcpSync(config) const versions = useNodeVersions(sync) const [panelOpen, setPanelOpen] = useState(true) @@ -47,6 +47,24 @@ export function CanvasShell({ config }: { config: ShellConfig }) { const hasVersions = versions.versions.length > 0 const showPanel = panelOpen && hasVersions + // draw.io exports what it shows: while a page export's one-page + // projection is on the canvas, or a full load has yet to reach the + // editor, that is not the document. Both end within seconds (the + // export's deadline is 15 s); the save waits for them, up to 20 s + const save = (filename: string, format: ExportFormat, tries = 0) => { + const canvasBusy = sync?.read().projectionActive || hasPendingLoad() + if (canvasBusy && tries < 66) { + setTimeout(() => save(filename, format, tries + 1), 300) + return + } + saveDiagramToFile( + filename, + format, + undefined, + dict.save.savedSuccessfully, + ) + } + return (
@@ -136,14 +154,7 @@ export function CanvasShell({ config }: { config: ShellConfig }) { - saveDiagramToFile( - filename, - format, - undefined, - dict.save.savedSuccessfully, - ) - } + onSave={save} defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`} /> diff --git a/packages/mcp-server/shell/shell.css b/packages/mcp-server/shell/shell.css index 8eec509e..85180cac 100644 --- a/packages/mcp-server/shell/shell.css +++ b/packages/mcp-server/shell/shell.css @@ -9,6 +9,7 @@ @source "../../../components/ui"; @source "../../../components/ai-elements"; @source "../../../components/workspace/app-toaster.tsx"; +@source "../../../components/save-dialog.tsx"; /* System fonts stand in for the web fonts the Next.js layout loads */ :root { diff --git a/packages/mcp-server/tests/e2e/shell.spec.ts b/packages/mcp-server/tests/e2e/shell.spec.ts index aa01b38f..d6234726 100644 --- a/packages/mcp-server/tests/e2e/shell.spec.ts +++ b/packages/mcp-server/tests/e2e/shell.spec.ts @@ -349,6 +349,12 @@ test("the download button saves the diagram as a file", async ({ page }) => { await page.getByTestId("download").click() const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() + // The dialog's utility classes are in the shell's stylesheet (shell.css + // names the shared component with @source): the formats in two columns + const columns = await dialog + .getByRole("radiogroup") + .evaluate((el) => getComputedStyle(el).gridTemplateColumns) + expect(columns.split(" ")).toHaveLength(2) // .drawio is the default format const download = page.waitForEvent("download") await dialog.getByRole("button", { name: "Export" }).click() @@ -363,6 +369,53 @@ test("the download button saves the diagram as a file", async ({ page }) => { expect(saved).toContain('name="Page-1"') }) +test("the download waits for a page export's projection, and saves every page", async ({ + page, +}) => { + const frame = await openShell(page) + await toolText("create_new_diagram", { xml: box("2", "Stay here", 40) }) + await expect(frame.getByText("Stay here")).toBeVisible({ timeout: 10_000 }) + await toolText("add_page", { + name: "Elsewhere", + xml: ``, + }) + await expect( + frame + .locator(".geTabContainer") + .getByText("Elsewhere", { exact: true }), + ).toBeVisible({ timeout: 10_000 }) + await page.getByTestId("download").click() + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + // The export's result is held up for 2 s: the one-page projection of + // the other page is surely still on the canvas when Export is clicked + await page.route("**/api/state", async (route) => { + const request = route.request() + if ( + request.method() === "POST" && + (request.postData() ?? "").includes('"exportData"') + ) { + await new Promise((r) => setTimeout(r, 2000)) + } + await route.continue() + }) + const exported = toolText("export_diagram", { + path: join(dataDir, "elsewhere.svg"), + page_name: "Elsewhere", + }) + await expect(frame.getByText("OnElsewhere")).toBeVisible({ + timeout: 10_000, + }) + const download = page.waitForEvent("download") + await dialog.getByRole("button", { name: "Export" }).click() + // Saved once the user's document is back: both pages, not the projection + const saved = readFileSync(await (await download).path(), "utf8") + expect(saved.match(/