From 0de1cdfe7c0551dd96e4263d5d7bb38d32e78d99 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 16:06:41 +0900 Subject: [PATCH] feat(mcp-server): version cards in the shell from the server's History The shell's VersionsSource (shell/node-versions-source.ts) reads GET /api/history through the sync and shows one version per distinct content (a restore's copy folds into the entry it copies; the blank page is none), numbered as they first appeared, with what changed since the one before. The entry the server says is on the canvas marks the version; undo and redo of the newest version restore the one before it, or it again, through POST /api/restore, so hand edits stay as versions of their own. The cards sit in a panel beside the canvas (toggle in the header); the strip and Compare come from the shared canvas components. The sync tells listeners once per server version, and when History changed without one, so the list is read again only then. --- lib/i18n/dictionaries/en.json | 5 +- lib/i18n/dictionaries/ja.json | 5 +- lib/i18n/dictionaries/zh-Hant.json | 5 +- lib/i18n/dictionaries/zh.json | 5 +- packages/mcp-server/shell/canvas-shell.tsx | 87 ++-- packages/mcp-server/shell/mcp-sync-core.ts | 68 +++- .../mcp-server/shell/node-versions-source.ts | 221 ++++++++++ packages/mcp-server/shell/use-mcp-sync.ts | 13 +- packages/mcp-server/shell/versions-panel.tsx | 50 +++ tests/unit/mcp-node-versions.test.tsx | 380 ++++++++++++++++++ tests/unit/mcp-shell-config.test.ts | 3 + tests/unit/mcp-sync-core.test.ts | 89 +++- 12 files changed, 885 insertions(+), 46 deletions(-) create mode 100644 packages/mcp-server/shell/node-versions-source.ts create mode 100644 packages/mcp-server/shell/versions-panel.tsx create mode 100644 tests/unit/mcp-node-versions.test.tsx diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index 322b142c..629c351d 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -710,6 +710,9 @@ "restoredFromFile": "The diagram was restored from its saved file. What this tab showed before is in History.", "aiChanged": "The AI changed the diagram while you were editing. Your last change was saved in History.", "historyChanged": "History changed on the server. Please open it again.", - "restoreFailed": "Restore failed. Please try again." + "restoreFailed": "Restore failed. Please try again.", + "userEdit": "Changed by hand", + "showVersions": "Show the versions", + "hideVersions": "Hide the versions" } } diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index 08303f6b..9b3d4d6f 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -710,6 +710,9 @@ "restoredFromFile": "図は保存済みファイルから復元されました。このタブに表示されていた内容は履歴にあります。", "aiChanged": "編集中に AI が図を変更しました。最後の変更は履歴に保存されました。", "historyChanged": "サーバー上の履歴が変わりました。もう一度開いてください。", - "restoreFailed": "復元に失敗しました。もう一度お試しください。" + "restoreFailed": "復元に失敗しました。もう一度お試しください。", + "userEdit": "手で変更しました", + "showVersions": "バージョンを表示", + "hideVersions": "バージョンを隠す" } } diff --git a/lib/i18n/dictionaries/zh-Hant.json b/lib/i18n/dictionaries/zh-Hant.json index 222dab38..ec907e80 100644 --- a/lib/i18n/dictionaries/zh-Hant.json +++ b/lib/i18n/dictionaries/zh-Hant.json @@ -710,6 +710,9 @@ "restoredFromFile": "圖表已從儲存的檔案還原。這個分頁之前顯示的內容已放入歷史記錄。", "aiChanged": "您編輯時 AI 修改了圖表。您的最後一次修改已儲存到歷史記錄。", "historyChanged": "伺服器上的歷史記錄已變化,請重新開啟。", - "restoreFailed": "還原失敗,請再試一次。" + "restoreFailed": "還原失敗,請再試一次。", + "userEdit": "手動修改", + "showVersions": "顯示版本", + "hideVersions": "隱藏版本" } } diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index 0a958437..daabf30f 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -710,6 +710,9 @@ "restoredFromFile": "图表已从保存的文件恢复。这个标签页之前显示的内容已放入历史记录。", "aiChanged": "您编辑时 AI 修改了图表。您的最后一次修改已保存到历史记录。", "historyChanged": "服务器上的历史记录已变化,请重新打开。", - "restoreFailed": "恢复失败,请重试。" + "restoreFailed": "恢复失败,请重试。", + "userEdit": "手动修改", + "showVersions": "显示版本", + "hideVersions": "隐藏版本" } } diff --git a/packages/mcp-server/shell/canvas-shell.tsx b/packages/mcp-server/shell/canvas-shell.tsx index 881e3e00..6288eae4 100644 --- a/packages/mcp-server/shell/canvas-shell.tsx +++ b/packages/mcp-server/shell/canvas-shell.tsx @@ -1,42 +1,35 @@ -import { Moon, Sun } from "lucide-react" -import { useMemo } from "react" +import { History, Moon, Sun } from "lucide-react" +import { useMemo, useState } from "react" import { CanvasStage } from "@/components/canvas/canvas-stage" import { CompareDialog } from "@/components/canvas/compare-dialog" import { VersionStrip } from "@/components/canvas/version-strip" -import { - VersionsProvider, - type VersionsSource, -} from "@/components/canvas/versions-context" +import { VersionsProvider } from "@/components/canvas/versions-context" import { AppToaster } from "@/components/workspace/app-toaster" import { useDictionary } from "@/hooks/use-dictionary" import type { DrawioSource } from "@/lib/drawio/drawio-config" +import { cn } from "@/lib/utils" import { useSettingsStore } from "@/stores/settings-store" +import { useNodeVersions } from "./node-versions-source" import type { ShellConfig } from "./runtime-config" import { setDark } from "./theme" import { useMcpSync } from "./use-mcp-sync" +import { VersionsPanel } from "./versions-panel" -// No versions until the shell reads the server's History (a later step) -const NO_VERSIONS: VersionsSource = { - versions: [], - onCanvasId: null, - undoneId: null, - isBusy: false, - canUndo: false, - canRedo: false, - restore: () => {}, - undo: () => {}, - redo: () => {}, -} +const iconButton = + "inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4" /** * The MCP preview: the web app's canvas without the chat. A slim bar names - * the session and the sync state; the versions strip and Compare come from - * the shared canvas components. + * the session and the sync state; the versions strip, the version cards + * (from the server's History) and Compare come from the shared canvas + * components. */ export function CanvasShell({ config }: { config: ShellConfig }) { const dict = useDictionary() const isDark = useSettingsStore((s) => s.isDark) - const status = useMcpSync(config) + const { status, sync } = useMcpSync(config) + const versions = useNodeVersions(sync) + const [panelOpen, setPanelOpen] = useState(true) const drawioSource = useMemo( () => ({ editorUrl: config.drawioBaseUrl, @@ -45,9 +38,11 @@ export function CanvasShell({ config }: { config: ShellConfig }) { }), [config], ) + const hasVersions = versions.versions.length > 0 + const showPanel = panelOpen && hasVersions return ( - +
@@ -69,23 +64,57 @@ export function CanvasShell({ config }: { config: ShellConfig }) { > {dict.shell[status]} + {hasVersions && ( + + )}
- -
- +
+
+ {/* With the cards open, one version alone adds + nothing to its card */} + +
+ +
+
+ {showPanel && ( + + )}
diff --git a/packages/mcp-server/shell/mcp-sync-core.ts b/packages/mcp-server/shell/mcp-sync-core.ts index 3e323832..79e14e2f 100644 --- a/packages/mcp-server/shell/mcp-sync-core.ts +++ b/packages/mcp-server/shell/mcp-sync-core.ts @@ -42,6 +42,21 @@ export interface HistoryEntryInfo { id: number index: number svg: string + xml: string + /** Who made it: "user", "recover", "restore"; null for the AI's writes */ + source: string | null + time: number + pages: number + /** The oldest entry with the same content (itself when it is the first) */ + firstId: number +} + +/** GET /api/history as the server answers it, for the state the list is of */ +export interface HistoryList { + entries: HistoryEntryInfo[] + stateId: string | null + /** The entry whose content the canvas shows (the server's rule), or null */ + currentId: number | null } /** @@ -121,18 +136,18 @@ export interface McpSync { /** draw.io reported a load done */ onDrawioLoad(): void /** The server's History, with the state its ids belong to; null when - * it could not be read, or the state changed meanwhile. For the versions - * strip (plan step 5), with restoreEntry below; until then only the - * unit tests call them */ - fetchHistory(): Promise<{ - entries: HistoryEntryInfo[] - stateId: string | null - } | null> + * it could not be read, or the state changed meanwhile (the version + * cards, shell/node-versions-source.ts) */ + fetchHistory(): Promise /** Put a History entry back, naming the state its list belongs to */ restoreEntry( id: number, listStateId: string | null, ): Promise<"ok" | "stale" | "failed"> + /** Called when the server's diagram or History changed (a write + * loaded, an edit saved, a thumbnail sent, the state recreated): the + * version cards read History again. Returns the unsubscribe */ + onServerChange(listener: () => void): () => void /** For tests and the status bar */ read(): { stateId: string | null @@ -299,6 +314,22 @@ export function createMcpSync(options: SyncOptions): McpSync { onStatus?.(next) } + // Listeners of onServerChange, told once per server version seen (any + // source: a write, the tab's own push) and whenever History changed + // without one (a rejected edit kept, a thumbnail stored) + const changeListeners = new Set<() => void>() + let notedVersion = 0 + let notedStateId: string | null = null + const emitChange = () => { + for (const listener of changeListeners) listener() + } + const noteServerVersion = (version: number, sid: string | null) => { + if (version === notedVersion && sid === notedStateId) return + notedVersion = version + notedStateId = sid + emitChange() + } + // A 403 means another MCP process (with its own token) now answers on // this port: read its token and retry once, so the next poll can recover // the session instead of being refused forever @@ -379,7 +410,10 @@ export function createMcpSync(options: SyncOptions): McpSync { svg: svgDataUrl(result.data), stateId: t.stateId, version: t.version, - }).catch(() => {}) + }) + // The write's History entry has its picture now + .then(emitChange) + .catch(() => {}) }) } @@ -434,6 +468,7 @@ export function createMcpSync(options: SyncOptions): McpSync { if (sid !== stateId || d.version < currentVersion) return currentVersion = d.version lastXml = xml + noteServerVersion(currentVersion, stateId) // The canvas changed while this edit was on its way, to // something no pending autosave will send (an undo back to // the previous version): send it now. A sync reply is @@ -460,6 +495,8 @@ export function createMcpSync(options: SyncOptions): McpSync { onNotice( source === "recover" ? "restoredFromFile" : "aiChanged", ) + // History grew without a new version + emitChange() } poll() } @@ -662,6 +699,8 @@ export function createMcpSync(options: SyncOptions): McpSync { } justLoaded = true } + // Also the tab's own push, and a version a projection holds back + noteServerVersion(s.version, stateId) // A sync request (get_diagram): after the load above, so draw.io // exports what it just loaded; never while a one-page projection // is on screen, which would be sent as the whole document @@ -770,7 +809,12 @@ export function createMcpSync(options: SyncOptions): McpSync { if (!r.ok) return null const d = await r.json() if (sid !== stateId) return null - return { entries: d.entries ?? [], stateId: sid } + return { + entries: d.entries ?? [], + stateId: sid, + currentId: + typeof d.currentId === "number" ? d.currentId : null, + } } catch { return null } @@ -796,6 +840,12 @@ export function createMcpSync(options: SyncOptions): McpSync { onNotice("restoreFailed") return "failed" }, + onServerChange(listener) { + changeListeners.add(listener) + return () => { + changeListeners.delete(listener) + } + }, read() { return { stateId, diff --git a/packages/mcp-server/shell/node-versions-source.ts b/packages/mcp-server/shell/node-versions-source.ts new file mode 100644 index 00000000..24f19664 --- /dev/null +++ b/packages/mcp-server/shell/node-versions-source.ts @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { toast } from "sonner" +import type { + VersionItem, + VersionsSource, +} from "@/components/canvas/versions-context" +import { useDictionary } from "@/hooks/use-dictionary" +import { type ChangeSummary, diffDiagrams } from "@/lib/diagram-diff" +import { formatMessage } from "@/lib/i18n/utils" +import { contentFingerprint } from "@/packages/mcp-server/src/edit-gate.ts" +import { BLANK_MXFILE, hasCells } from "@/packages/mcp-server/src/pages.ts" +import { useCanvasStore } from "@/stores/canvas-store" +import type { HistoryEntryInfo, HistoryList, McpSync } from "./mcp-sync-core" + +/** + * A version card of the shell: one per distinct content of the server's + * History, which keeps every state the diagram went through (a restore + * adds a copy of an older entry; copies fold into their first entry). + */ +export interface NodeVersion extends VersionItem { + /** The History entry to restore (the first copy's) */ + entryId: number + /** Who made it: "user", "recover", "restore"; null for the AI */ + source: string | null +} + +// The blank page a tab shows before any drawing: the server keeps it in +// History when the first write replaces it, but it is no version to show +// or go back to (the server's restore leaves it out by the same rule) +const BLANK_FINGERPRINT = contentFingerprint(BLANK_MXFILE) +const isBlankPage = (xml: string) => + !hasCells(xml) && contentFingerprint(xml) === BLANK_FINGERPRINT + +/** The ids of a document's pages; a bare model has one, without an id */ +function pageIds(xml: string): (string | undefined)[] { + const doc = new DOMParser().parseFromString(xml, "text/xml") + const ids = Array.from(doc.querySelectorAll("diagram")).map( + (page) => page.getAttribute("id") ?? undefined, + ) + return ids.length > 0 ? ids : [undefined] +} + +/** The page a change was made on: the first page with a change, else none */ +function changedPage(beforeXml: string, afterXml: string): string | undefined { + for (const id of pageIds(afterXml)) { + const { summary } = diffDiagrams(beforeXml, afterXml, id) + if (Object.values(summary).some((n) => n > 0)) return id + } + return undefined +} + +interface Change { + pageId?: string + summary: ChangeSummary + fromScratch: boolean +} + +/** + * The version cards for a History list: entries folded by content, in the + * order of their first copy, numbered as they first appeared in this tab + * (numbers is kept between calls), each with what changed since the one + * before. The thumbnail is the first copy's that has one; the newest + * version on the canvas waits for its picture (the sync sends it), older + * ones without any show none. The blank page is no version. + */ +export function buildVersions( + list: HistoryList, + numbers: Map, + changes: Map, +): { versions: NodeVersion[]; onCanvasId: string | null } { + const groups = new Map() + for (const entry of list.entries) { + const copies = groups.get(entry.firstId) + if (copies) copies.push(entry) + else groups.set(entry.firstId, [entry]) + } + const current = list.entries.find((e) => e.id === list.currentId) + const onCanvasId = current ? String(current.firstId) : null + const versions: NodeVersion[] = [] + let previous: HistoryEntryInfo | null = null + for (const [firstId, copies] of groups) { + const first = copies[0] + if (isBlankPage(first.xml)) { + previous = first + continue + } + let number = numbers.get(firstId) + if (number === undefined) { + number = Math.max(0, ...numbers.values()) + 1 + numbers.set(firstId, number) + } + let change = changes.get(firstId) + if (!change) { + const beforeXml = previous?.xml ?? "" + const pageId = changedPage(beforeXml, first.xml) + const { summary, fromScratch } = diffDiagrams( + beforeXml, + first.xml, + pageId, + ) + change = { pageId, summary, fromScratch } + changes.set(firstId, change) + } + const svg = copies.find((c) => c.svg)?.svg + versions.push({ + id: String(firstId), + entryId: firstId, + number, + xml: first.xml, + beforeXml: previous?.xml ?? "", + ...(change.pageId && { pageId: change.pageId }), + summary: change.summary, + fromScratch: change.fromScratch, + svg: svg || "", + source: first.source, + }) + previous = first + } + // The newest version on the canvas waits for its picture + const latest = versions.at(-1) + if (latest && !latest.svg && latest.id === onCanvasId) { + latest.svg = undefined + } + return { versions, onCanvasId } +} + +/** The versions source with the shell's own version type */ +export interface NodeVersionsSource extends Omit { + versions: NodeVersion[] +} + +/** + * The canvas components' versions, from the MCP server's History: read + * again whenever the sync reports a change on the server, restored through + * it. Undo and redo of the newest version restore the one before it, or it + * again; the server keeps the state they leave as an entry of its own, so + * hand edits are never lost. + */ +export function useNodeVersions(sync: McpSync | null): NodeVersionsSource { + const dict = useDictionary() + const [list, setList] = useState(null) + const [busy, setBusy] = useState(false) + // Stable across refreshes: an entry's number and change never move + const numbersRef = useRef(new Map()) + const changesRef = useRef(new Map()) + // Reads overlap when changes come fast; only the latest one counts + const readSeqRef = useRef(0) + + const refresh = useCallback(async () => { + if (!sync) return + const seq = ++readSeqRef.current + const next = await sync.fetchHistory() + if (next && seq === readSeqRef.current) setList(next) + }, [sync]) + + useEffect(() => { + if (!sync) return + refresh() + return sync.onServerChange(refresh) + }, [sync, refresh]) + + const { versions, onCanvasId } = useMemo( + () => + list + ? buildVersions(list, numbersRef.current, changesRef.current) + : { versions: [], onCanvasId: null }, + [list], + ) + + const restore = async (id: string) => { + if (!sync || !list || busy) return + const version = versions.find((v) => v.id === id) + if (!version) return + setBusy(true) + // The canvas components wait (SelectionAsk, the cards' buttons) + useCanvasStore.getState().set({ isBusy: true }) + try { + const result = await sync.restoreEntry( + version.entryId, + list.stateId, + ) + if (result === "ok") { + toast.success( + formatMessage(dict.versions.restoredVersion, { + n: version.number, + }), + { duration: 4000 }, + ) + } + // The sync's notices tell about a stale list or a failure; the + // list on screen is read again either way + await refresh() + } finally { + setBusy(false) + useCanvasStore.getState().set({ isBusy: false }) + } + } + + const latest = versions.at(-1) + const previous = versions.at(-2) + const canUndo = !!latest && !!previous && onCanvasId === latest.id + // The newest change is undone when the canvas is at the version before + const undoneId = + latest && previous && onCanvasId === previous.id ? latest.id : null + + return { + versions, + onCanvasId, + undoneId, + isBusy: busy, + canUndo, + canRedo: undoneId !== null, + restore, + undo: () => { + if (canUndo && previous) restore(previous.id) + }, + redo: () => { + if (undoneId && latest) restore(latest.id) + }, + } +} diff --git a/packages/mcp-server/shell/use-mcp-sync.ts b/packages/mcp-server/shell/use-mcp-sync.ts index b3e90f09..2c7e49c0 100644 --- a/packages/mcp-server/shell/use-mcp-sync.ts +++ b/packages/mcp-server/shell/use-mcp-sync.ts @@ -13,12 +13,17 @@ import type { ShellConfig } from "./runtime-config" * protocol): mounted once inside DiagramProvider. Server writes go on the * canvas through loadDiagram, as one undo step when they can; the user's * edits are pushed; the server's export and sync requests are answered. - * Returns the connection state for the status bar. + * Returns the connection state for the status bar, and the sync itself for + * the version cards (History and restores go through it). */ -export function useMcpSync(config: ShellConfig): SyncStatus { +export function useMcpSync(config: ShellConfig): { + status: SyncStatus + sync: McpSync | null +} { const dict = useDictionary() const diagram = useDiagram() const [status, setStatus] = useState("waiting") + const [sync, setSync] = useState(null) const syncRef = useRef(null) // The provider's functions are new on every render; the sync (created // once) reads the latest through refs @@ -54,6 +59,7 @@ export function useMcpSync(config: ShellConfig): SyncStatus { onStatus: setStatus, }) syncRef.current = sync + setSync(sync) sync.setReady(diagramRef.current.isDrawioReady) sync.start() @@ -86,6 +92,7 @@ export function useMcpSync(config: ShellConfig): SyncStatus { window.removeEventListener("message", onMessage) sync.stop() syncRef.current = null + setSync(null) } }, [config]) @@ -107,5 +114,5 @@ export function useMcpSync(config: ShellConfig): SyncStatus { return () => diagram.setCommitHandler(null) }, []) - return status + return { status, sync } } diff --git a/packages/mcp-server/shell/versions-panel.tsx b/packages/mcp-server/shell/versions-panel.tsx new file mode 100644 index 00000000..8413cd94 --- /dev/null +++ b/packages/mcp-server/shell/versions-panel.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef } from "react" +import { VersionCard } from "@/components/canvas/version-card" +import { useDictionary } from "@/hooks/use-dictionary" +import type { Dictionary } from "@/lib/i18n/dictionaries" +import type { NodeVersion } from "./node-versions-source" + +/** What a card says happened: the web app's chat knows the tool that ran, + * the shell tells from who made the entry and what changed */ +export function versionLabel(version: NodeVersion, dict: Dictionary): string { + if (version.source === "user" || version.source === "recover") { + return dict.shell.userEdit + } + return version.fromScratch ? dict.versions.created : dict.versions.edited +} + +/** The version cards, oldest first, as the web app's chat shows them */ +export function VersionsPanel({ versions }: { versions: NodeVersion[] }) { + const dict = useDictionary() + const listRef = useRef(null) + + // Keep the newest version in view + useEffect(() => { + const el = listRef.current + if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }) + }, [versions.length]) + + return ( + + ) +} diff --git a/tests/unit/mcp-node-versions.test.tsx b/tests/unit/mcp-node-versions.test.tsx new file mode 100644 index 00000000..f6282643 --- /dev/null +++ b/tests/unit/mcp-node-versions.test.tsx @@ -0,0 +1,380 @@ +import { act, cleanup, renderHook } from "@testing-library/react" +import type React from "react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { DictionaryProvider } from "@/hooks/use-dictionary" +import type { Dictionary } from "@/lib/i18n/dictionaries" +import en from "@/lib/i18n/dictionaries/en.json" +import type { + HistoryEntryInfo, + HistoryList, + McpSync, +} from "@/packages/mcp-server/shell/mcp-sync-core" +import { + buildVersions, + useNodeVersions, +} from "@/packages/mcp-server/shell/node-versions-source" +import { versionLabel } from "@/packages/mcp-server/shell/versions-panel" +import { useCanvasStore } from "@/stores/canvas-store" + +// The shell's version cards come from the MCP server's History (GET +// /api/history), read through the sync; restores go back through it + +afterEach(cleanup) + +const toasts = vi.hoisted(() => ({ success: vi.fn() })) +vi.mock("sonner", () => ({ toast: toasts })) + +const cell = (id: string, label: string) => + `` +const doc = (cells: string, pageId = "p1") => + `${cells}` +const A = doc(cell("a", "A")) +const AB = doc(cell("a", "A") + cell("b", "B")) +const ABC = doc(cell("a", "A") + cell("b", "B") + cell("c", "C")) + +function entry( + id: number, + xml: string, + extra: Partial = {}, +): HistoryEntryInfo { + return { + id, + index: id, + svg: "", + xml, + source: null, + time: 1000 + id, + pages: 1, + firstId: id, + ...extra, + } +} + +const list = ( + entries: HistoryEntryInfo[], + currentId: number | null, +): HistoryList => ({ entries, stateId: "S1", currentId }) + +describe("buildVersions", () => { + it("makes one numbered version per History entry, with what changed since the one before", () => { + const { versions, onCanvasId } = buildVersions( + list([entry(0, A), entry(1, AB, { svg: "data:AB" })], 1), + new Map(), + new Map(), + ) + expect(versions.map((v) => [v.id, v.number])).toEqual([ + ["0", 1], + ["1", 2], + ]) + expect(versions[0].fromScratch).toBe(true) + expect(versions[0].summary.shapesAdded).toBe(1) + expect(versions[1].fromScratch).toBe(false) + expect(versions[1].summary.shapesAdded).toBe(1) + expect(versions[1].pageId).toBe("p1") + expect(versions[1].beforeXml).toBe(A) + expect(versions[1].svg).toBe("data:AB") + expect(onCanvasId).toBe("1") + }) + + it("leaves the blank page out, and counts the first drawing from it", () => { + // The server keeps the blank page the tab showed when the first + // write replaces it; a page the user named is a version + const blank = `` + const named = blank.replace('name="Page-1"', 'name="Plan"') + const { versions, onCanvasId } = buildVersions( + list([entry(0, blank), entry(1, A), entry(2, named)], 2), + new Map(), + new Map(), + ) + expect(versions.map((v) => [v.id, v.number])).toEqual([ + ["1", 1], + ["2", 2], + ]) + expect(versions[0].fromScratch).toBe(true) + expect(onCanvasId).toBe("2") + // Blank on the canvas: no version is + expect( + buildVersions(list([entry(0, blank)], 0), new Map(), new Map()), + ).toEqual({ versions: [], onCanvasId: "0" }) + }) + + it("folds a restore's copy into the version it copies, where that one is", () => { + // History: A, AB, A again (restored); the canvas shows A + const { versions, onCanvasId } = buildVersions( + list( + [ + entry(0, A), + entry(1, AB), + entry(2, A, { + firstId: 0, + svg: "data:A", + source: "restore", + }), + ], + 2, + ), + new Map(), + new Map(), + ) + expect(versions.map((v) => v.id)).toEqual(["0", "1"]) + // The copy's picture stands in for the first copy's missing one + expect(versions[0].svg).toBe("data:A") + expect(onCanvasId).toBe("0") + }) + + it("keeps the numbers once given, and gives new versions the next", () => { + const numbers = new Map() + const changes = new Map() + buildVersions(list([entry(0, A), entry(1, AB)], 1), numbers, changes) + // The oldest entry dropped out of the server's buffer + const { versions } = buildVersions( + list([entry(1, AB), entry(2, ABC)], 2), + numbers, + changes, + ) + expect(versions.map((v) => v.number)).toEqual([2, 3]) + }) + + it("lets the newest version on the canvas wait for its picture, older ones show none", () => { + const { versions } = buildVersions( + list([entry(0, A), entry(1, AB)], 1), + new Map(), + new Map(), + ) + expect(versions[0].svg).toBe("") + expect(versions[1].svg).toBeUndefined() + const edited = buildVersions( + list([entry(0, A), entry(1, AB)], null), + new Map(), + new Map(), + ) + expect(edited.versions[1].svg).toBe("") + }) + + it("labels a version by who made it and what it did", () => { + const dict = en as unknown as Dictionary + const { versions } = buildVersions( + list( + [entry(0, A), entry(1, AB, { source: "user" }), entry(2, ABC)], + 2, + ), + new Map(), + new Map(), + ) + expect(versions.map((v) => versionLabel(v, dict))).toEqual([ + "Drew the diagram", + "Changed by hand", + "Changed the diagram", + ]) + }) +}) + +/** A stand-in for the sync: History as the server would answer, restores + * recorded */ +function fakeSync(initial: HistoryList) { + let current = initial + const listeners = new Set<() => void>() + const restores: { id: number; stateId: string | null }[] = [] + let restoreResult: "ok" | "stale" | "failed" = "ok" + const sync = { + fetchHistory: vi.fn(async () => current), + restoreEntry: vi.fn(async (id: number, stateId: string | null) => { + restores.push({ id, stateId }) + return restoreResult + }), + onServerChange: (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } as unknown as McpSync + return { + sync, + restores, + /** The server changed: the list it would answer now */ + serve(next: HistoryList) { + current = next + for (const listener of listeners) listener() + }, + failRestores(result: "stale" | "failed") { + restoreResult = result + }, + } +} + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) + +const flush = () => act(() => Promise.resolve()) + +describe("useNodeVersions", () => { + it("reads History when the sync starts and whenever the server changed", async () => { + const server = fakeSync(list([entry(0, A)], 0)) + const { result } = renderHook(() => useNodeVersions(server.sync), { + wrapper, + }) + await flush() + expect(result.current.versions.map((v) => v.number)).toEqual([1]) + expect(result.current.onCanvasId).toBe("0") + expect(result.current.canUndo).toBe(false) + + await act(async () => + server.serve(list([entry(0, A), entry(1, AB)], 1)), + ) + expect(result.current.versions.map((v) => v.number)).toEqual([1, 2]) + expect(result.current.onCanvasId).toBe("1") + expect(result.current.canUndo).toBe(true) + expect(result.current.canRedo).toBe(false) + }) + + it("has no versions without a sync", () => { + const { result } = renderHook(() => useNodeVersions(null), { wrapper }) + expect(result.current.versions).toEqual([]) + expect(result.current.onCanvasId).toBeNull() + }) + + it("restores through the sync with the list's state, and says so", async () => { + const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) + const { result } = renderHook(() => useNodeVersions(server.sync), { + wrapper, + }) + await flush() + await act(async () => { + result.current.restore("0") + }) + expect(server.restores).toEqual([{ id: 0, stateId: "S1" }]) + expect(toasts.success).toHaveBeenCalledWith("Restored v1", { + duration: 4000, + }) + // The canvas was busy for the restore, and is free again + expect(useCanvasStore.getState().isBusy).toBe(false) + }) + + it("undoes the newest version by restoring the one before, and redoes it", async () => { + // The server keeps the state it left as a copy of the entry + const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) + const { result } = renderHook(() => useNodeVersions(server.sync), { + wrapper, + }) + await flush() + await act(async () => { + result.current.undo() + }) + expect(server.restores).toEqual([{ id: 0, stateId: "S1" }]) + await act(async () => + server.serve( + list( + [entry(0, A), entry(1, AB), entry(2, A, { firstId: 0 })], + 2, + ), + ), + ) + expect(result.current.versions).toHaveLength(2) + expect(result.current.onCanvasId).toBe("0") + expect(result.current.undoneId).toBe("1") + expect(result.current.canUndo).toBe(false) + expect(result.current.canRedo).toBe(true) + + await act(async () => { + result.current.redo() + }) + expect(server.restores.at(-1)).toEqual({ id: 1, stateId: "S1" }) + await act(async () => + server.serve( + list( + [ + entry(0, A), + entry(1, AB), + entry(2, A, { firstId: 0 }), + entry(3, AB, { firstId: 1 }), + ], + 3, + ), + ), + ) + expect(result.current.onCanvasId).toBe("1") + expect(result.current.undoneId).toBeNull() + expect(result.current.canUndo).toBe(true) + }) + + it("offers neither undo nor redo after a hand edit, and keeps that edit as a version once a write follows", async () => { + const server = fakeSync(list([entry(0, A), entry(1, AB)], null)) + const { result } = renderHook(() => useNodeVersions(server.sync), { + wrapper, + }) + await flush() + expect(result.current.canUndo).toBe(false) + expect(result.current.canRedo).toBe(false) + expect(result.current.undoneId).toBeNull() + // The AI writes: the server kept the hand edit first + const manual = doc(cell("a", "A") + cell("b", "B") + cell("m", "M")) + await act(async () => + server.serve( + list( + [ + entry(0, A), + entry(1, AB), + entry(2, manual, { source: "user" }), + entry(3, ABC), + ], + 3, + ), + ), + ) + expect(result.current.versions.map((v) => v.source)).toEqual([ + null, + null, + "user", + null, + ]) + }) + + it("does not restore twice at once, nor what is not a version", async () => { + const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) + let resolveRestore: (r: "ok") => void = () => {} + server.sync.restoreEntry = vi.fn( + () => + new Promise<"ok">((resolve) => { + resolveRestore = resolve + }), + ) as unknown as McpSync["restoreEntry"] + const { result } = renderHook(() => useNodeVersions(server.sync), { + wrapper, + }) + await flush() + act(() => { + result.current.restore("0") + }) + await flush() + expect(result.current.isBusy).toBe(true) + act(() => { + result.current.restore("0") + result.current.restore("missing") + }) + expect(server.sync.restoreEntry).toHaveBeenCalledTimes(1) + await act(async () => resolveRestore("ok")) + expect(result.current.isBusy).toBe(false) + }) + + it("stays quiet when a restore was refused, and reads the list again", async () => { + const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) + server.failRestores("stale") + const { result } = renderHook(() => useNodeVersions(server.sync), { + wrapper, + }) + await flush() + toasts.success.mockClear() + const reads = (server.sync.fetchHistory as ReturnType) + .mock.calls.length + await act(async () => { + result.current.restore("0") + }) + expect(toasts.success).not.toHaveBeenCalled() + expect( + (server.sync.fetchHistory as ReturnType).mock.calls + .length, + ).toBe(reads + 1) + }) +}) diff --git a/tests/unit/mcp-shell-config.test.ts b/tests/unit/mcp-shell-config.test.ts index 15bf1768..0b1dd494 100644 --- a/tests/unit/mcp-shell-config.test.ts +++ b/tests/unit/mcp-shell-config.test.ts @@ -106,13 +106,16 @@ describe("shell language", () => { expect(Object.keys(dict.shell).sort(), locale).toEqual([ "aiChanged", "connected", + "hideVersions", "historyChanged", "offline", "restoreFailed", "restoredFromFile", + "showVersions", "toggleTheme", "tooLarge", "unreachable", + "userEdit", "waiting", ]) expect(dict.shell.waiting, locale).toBeTruthy() diff --git a/tests/unit/mcp-sync-core.test.ts b/tests/unit/mcp-sync-core.test.ts index 480f557e..7959d580 100644 --- a/tests/unit/mcp-sync-core.test.ts +++ b/tests/unit/mcp-sync-core.test.ts @@ -866,7 +866,12 @@ describe("MCP sync export requests", () => { describe("MCP sync History", () => { const historyList = { status: 200, - body: { entries: [{ id: 5, index: 0, svg: "" }], count: 1 }, + body: { + entries: [{ id: 5, index: 0, svg: "" }], + count: 1, + stateId: "S1", + currentId: null, + }, } it("drops a History list asked for before the session was recreated", async () => { @@ -889,6 +894,7 @@ describe("MCP sync History", () => { expect(list).toEqual({ entries: [{ id: 5, index: 0, svg: "" }], stateId: "S1", + currentId: null, }) const restore = t.sync.restoreEntry(5, list?.stateId ?? null) await t.settle() @@ -938,6 +944,87 @@ describe("MCP sync History", () => { expect(await restore).toBe("failed") expect(t.notices).toEqual(["restoreFailed"]) }) + + it("passes on the entry the server says is on the canvas", async () => { + const t = await inStep() + const history = t.sync.fetchHistory() + t.next("GET", "/api/history").answer({ + status: 200, + body: { entries: [{ id: 7 }], count: 1, currentId: 7 }, + }) + expect((await history)?.currentId).toBe(7) + }) +}) + +// The version cards read History again when the server changed +describe("MCP sync server change listener", () => { + it("tells once per server version: the first poll, a write, the tab's own edit", async () => { + const t = open() + let changes = 0 + const stop = t.sync.onServerChange(() => changes++) + t.sync.setReady(true) + let poll = t.sync.poll() + t.next("GET").answer(state("S1", 2, "A")) + await poll + expect(changes).toBe(1) + // The same version again: nothing new + poll = t.sync.poll() + t.next("GET").answer(state("S1", 2, "A")) + await poll + expect(changes).toBe(1) + // The thumbnail of the write is stored: its entry has a picture now + await vi.advanceTimersByTimeAsync(500) + t.nextExport().answer({ data: "A" }) + await t.settle() + t.next("POST", "/api/history-svg").answer({ status: 200, body: {} }) + await t.settle() + expect(changes).toBe(2) + // An AI write + poll = t.sync.poll() + t.next("GET").answer(state("S1", 3, "B")) + await poll + expect(changes).toBe(3) + // The user's edit is saved: the canvas is at no version now + await edit(t, "C") + t.next("POST", "/api/state").answer({ + status: 200, + body: { success: true, version: 4 }, + }) + await t.settle() + expect(changes).toBe(4) + // The next poll sees the version the push already told about + poll = t.sync.poll() + t.next("GET").answer(state("S1", 4, "C")) + await poll + expect(changes).toBe(4) + stop() + poll = t.sync.poll() + t.next("GET").answer(state("S1", 5, "D")) + await poll + expect(changes).toBe(4) + }) + + it("tells when a rejected edit was kept in History, and when the state was recreated", async () => { + const t = await inStep() + let changes = 0 + t.sync.onServerChange(() => changes++) + await edit(t, "B") + t.next("POST", "/api/state").answer({ + status: 409, + body: { error: "changed", version: 3, savedToHistory: true }, + }) + await t.settle() + expect(changes).toBe(1) + // The 409 handler polls and loads the newer write + t.next("GET").answer(state("S1", 3, "AI")) + await t.settle() + expect(changes).toBe(2) + // The server restarted: History has new ids + const poll = t.sync.poll() + t.next("GET").answer(state("S2", 1, "AI")) + await poll + expect(changes).toBe(3) + }) }) // Documents as the server writes them (one page per call)