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)