fix(mcp-server): the download waits for a page export's projection to end, and the dialog's styles are bundled

This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 70d392729a
commit fdc974e85a
3 changed files with 75 additions and 10 deletions
+21 -10
View File
@@ -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 (
<VersionsProvider value={versions}>
<div className="flex h-dvh flex-col bg-background text-foreground">
@@ -136,14 +154,7 @@ export function CanvasShell({ config }: { config: ShellConfig }) {
<SaveDialog
open={saveOpen}
onOpenChange={setSaveOpen}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
undefined,
dict.save.savedSuccessfully,
)
}
onSave={save}
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
/>
<AppToaster />
+1
View File
@@ -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 {
@@ -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: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="e1" value="OnElsewhere" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
})
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(/<diagram\b/g)).toHaveLength(2)
expect(saved).toContain('name="Page-1"')
expect(saved).toContain('name="Elsewhere"')
expect(await exported).toContain("exported successfully")
})
// --- Version cards from the server's History (shell/node-versions-source.ts) ---
test("the version cards follow the server's History; undo, redo and restore go through it", async ({