diff --git a/packages/mcp-server/shell/mcp-sync-core.ts b/packages/mcp-server/shell/mcp-sync-core.ts index 73c2f8fe..dca5bb94 100644 --- a/packages/mcp-server/shell/mcp-sync-core.ts +++ b/packages/mcp-server/shell/mcp-sync-core.ts @@ -38,6 +38,9 @@ export interface ServerState { exportId: string | null /** A get_selection request waiting for this tab's answer */ selectionId?: string | null + /** Changes when History or the entry on the canvas does (the version + * cards read History again then); older servers send none */ + historyKey?: string } /** An entry of GET /api/history */ @@ -48,8 +51,6 @@ export interface HistoryEntryInfo { 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 } @@ -323,18 +324,20 @@ 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) + // Listeners of onServerChange, told once per History key seen (the + // server sends it with every state and push answer; a write, a restore + // and the first hand edit change it, further hand edits do not) and + // whenever History changed without one (a rejected edit kept, a + // thumbnail stored) const changeListeners = new Set<() => void>() - let notedVersion = 0 + let notedKey: string | null = null 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 + const noteHistory = (key: string, sid: string | null) => { + if (key === notedKey && sid === notedStateId) return + notedKey = key notedStateId = sid emitChange() } @@ -477,7 +480,7 @@ export function createMcpSync(options: SyncOptions): McpSync { if (sid !== stateId || d.version < currentVersion) return currentVersion = d.version lastXml = xml - noteServerVersion(currentVersion, stateId) + noteHistory(d.historyKey ?? String(d.version), 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 @@ -709,7 +712,7 @@ 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) + noteHistory(s.historyKey ?? String(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 @@ -825,13 +828,24 @@ export function createMcpSync(options: SyncOptions): McpSync { async fetchHistory() { // A list for a state the server recreated meanwhile has old ids const sid = stateId + // A failed read is told again at the next poll: no other event + // comes for the same History + const failed = () => { + notedKey = null + return null + } try { const r = await api( `/history?sessionId=${encodeURIComponent(sessionId)}`, ) - if (!r.ok) return null + if (!r.ok) return failed() const d = await r.json() if (sid !== stateId) return null + // A list from a state the poll has not seen yet (another + // process answers): its poll tells the cards to read again + if (typeof d.stateId === "string" && d.stateId !== sid) { + return null + } return { entries: d.entries ?? [], stateId: sid, @@ -839,7 +853,7 @@ export function createMcpSync(options: SyncOptions): McpSync { typeof d.currentId === "number" ? d.currentId : null, } } catch { - return null + return failed() } }, async restoreEntry(id, listStateId) { diff --git a/packages/mcp-server/shell/node-versions-source.ts b/packages/mcp-server/shell/node-versions-source.ts index 24f19664..1d5edb3b 100644 --- a/packages/mcp-server/shell/node-versions-source.ts +++ b/packages/mcp-server/shell/node-versions-source.ts @@ -18,15 +18,19 @@ import type { HistoryEntryInfo, HistoryList, McpSync } from "./mcp-sync-core" * 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) */ + /** The History entry to restore: the newest copy, as restore_version + * picks it (a later copy may carry page settings the first lacks) */ entryId: number + /** The version this one replaced, when it has a card (undo goes there) */ + beforeId: string | null /** 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) +// History when the first write replaces it, but it is no version to show. +// A blank page after a drawing is the canvas cleared, a version of its own +// (restore_version offers both; the shell hides only the first) const BLANK_FINGERPRINT = contentFingerprint(BLANK_MXFILE) const isBlankPage = (xml: string) => !hasCells(xml) && contentFingerprint(xml) === BLANK_FINGERPRINT @@ -49,7 +53,9 @@ function changedPage(beforeXml: string, afterXml: string): string | undefined { return undefined } +/** What a version changed, kept from its first appearance (see buildVersions) */ interface Change { + beforeXml: string pageId?: string summary: ChangeSummary fromScratch: boolean @@ -57,39 +63,71 @@ interface Change { /** * 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. + * order of their first copy, numbered as they first appeared in this tab, + * each with what changed since the state it replaced (the entry right + * before its first copy; after a restore that is another than the card + * before). numbers and changes are kept between calls, keyed by content: + * a version keeps its number and change when its first copy drops out of + * the server's buffer and a later copy stands in. 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. */ export function buildVersions( list: HistoryList, - numbers: Map, - changes: Map, + numbers: Map, + changes: Map, ): { versions: NodeVersion[]; onCanvasId: string | null } { + // The server folds entries by content (firstId). A blank page after a + // drawing is a clear of the canvas, a version of its own, which the + // server folds into the blank page the tab started with: blanks are + // grouped here, a restore's copy of a clear with the newest clear 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 + const keys = new Map() // entry id -> its group + const befores = new Map() // group -> entry before it + const hidden = new Set() // the blank page(s) before any drawing + let drawn = false + let lastClear: number | null = null + list.entries.forEach((entry, i) => { + let key = entry.firstId + if (!isBlankPage(entry.xml)) { + drawn = true + } else if (drawn) { + key = + entry.source === "restore" && lastClear !== null + ? lastClear + : entry.id + lastClear = key + } else { + hidden.add(key) } - let number = numbers.get(firstId) + keys.set(entry.id, key) + const copies = groups.get(key) + if (copies) copies.push(entry) + else { + groups.set(key, [entry]) + if (i > 0) befores.set(key, list.entries[i - 1]) + } + }) + const onCanvasId = + list.currentId !== null && keys.has(list.currentId) + ? String(keys.get(list.currentId)) + : null + const versions: NodeVersion[] = [] + for (const [key, copies] of groups) { + if (hidden.has(key)) continue + const first = copies[0] + const newest = copies[copies.length - 1] + const previous = befores.get(key) ?? null + // Clears have one content: each is its own version + const contentKey = isBlankPage(first.xml) + ? `clear:${key}` + : contentFingerprint(first.xml) + let number = numbers.get(contentKey) if (number === undefined) { number = Math.max(0, ...numbers.values()) + 1 - numbers.set(firstId, number) + numbers.set(contentKey, number) } - let change = changes.get(firstId) + let change = changes.get(contentKey) if (!change) { const beforeXml = previous?.xml ?? "" const pageId = changedPage(beforeXml, first.xml) @@ -98,23 +136,27 @@ export function buildVersions( first.xml, pageId, ) - change = { pageId, summary, fromScratch } - changes.set(firstId, change) + change = { beforeXml, pageId, summary, fromScratch } + changes.set(contentKey, change) } const svg = copies.find((c) => c.svg)?.svg + const beforeKey = previous ? keys.get(previous.id) : undefined versions.push({ - id: String(firstId), - entryId: firstId, + id: String(key), + entryId: newest.id, + beforeId: + beforeKey !== undefined && !hidden.has(beforeKey) + ? String(beforeKey) + : null, number, - xml: first.xml, - beforeXml: previous?.xml ?? "", + xml: newest.xml, + beforeXml: change.beforeXml, ...(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) @@ -132,17 +174,20 @@ export interface NodeVersionsSource extends Omit { /** * 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. + * it. Undo and redo of the newest version restore the one it replaced, 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()) + // Stable across refreshes: a version's number and change never move + const numbersRef = useRef(new Map()) + const changesRef = useRef(new Map()) + // The server state the cards are of: another one (the session expired, + // the process restarted) is a History of its own, numbered anew + const cacheStateIdRef = useRef(null) // Reads overlap when changes come fast; only the latest one counts const readSeqRef = useRef(0) @@ -150,7 +195,13 @@ export function useNodeVersions(sync: McpSync | null): NodeVersionsSource { if (!sync) return const seq = ++readSeqRef.current const next = await sync.fetchHistory() - if (next && seq === readSeqRef.current) setList(next) + if (!next || seq !== readSeqRef.current) return + if (next.stateId !== cacheStateIdRef.current) { + numbersRef.current.clear() + changesRef.current.clear() + cacheStateIdRef.current = next.stateId + } + setList(next) }, [sync]) useEffect(() => { @@ -197,7 +248,10 @@ export function useNodeVersions(sync: McpSync | null): NodeVersionsSource { } const latest = versions.at(-1) - const previous = versions.at(-2) + // The version the newest one replaced (restore_version's steps_back=1) + const previous = latest?.beforeId + ? versions.find((v) => v.id === latest.beforeId) + : undefined const canUndo = !!latest && !!previous && onCanvasId === latest.id // The newest change is undone when the canvas is at the version before const undoneId = diff --git a/packages/mcp-server/src/history.ts b/packages/mcp-server/src/history.ts index 39bf4e3c..ff3e1809 100644 --- a/packages/mcp-server/src/history.ts +++ b/packages/mcp-server/src/history.ts @@ -5,7 +5,7 @@ import { contentFingerprint } from "./edit-gate.ts" import { log } from "./logger.ts" -import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.ts" +import { isMxGraphModel } from "./pages.ts" const MAX_HISTORY = 20 @@ -22,21 +22,11 @@ export interface HistoryEntry { xml: string svg: string source?: HistorySource - /** When it was added, in ms since the epoch */ - time: number - /** Pages in the document (0 when it has none) */ - pages: number } let nextEntryId = 0 const historyStore = new Map() -function countPages(xml: string): number { - const normalized = normalizeToMxfile(xml) - const doc = normalized ? parseMxfile(normalized) : null - return doc ? doc.querySelectorAll("diagram").length : 0 -} - export function addHistory( sessionId: string, xml: string, @@ -63,8 +53,6 @@ export function addHistory( xml, svg, ...(source && { source }), - time: Date.now(), - pages: countPages(xml), }) // Circular buffer @@ -140,16 +128,24 @@ export function currentEntryId(sessionId: string, xml: string): number | null { /** * For every entry, the id of the oldest entry with the same content (its * own when no earlier one has it). A restore adds a copy of an older entry; - * the shell shows one version per content, where its first copy is. + * the shell shows one version per content, where its first copy is, and + * restores the newest copy (as restore_version picks it). An entry is + * compared with the first of each group, never with later copies: a bare + * model matches any page name, so "same" is not transitive, and every + * entry must name a first copy that has its content. */ export function firstCopyIds(sessionId: string): Map { - const history = getHistory(sessionId) - const versions = history.map(entryVersion) const result = new Map() - history.forEach((entry, i) => { - const first = versions.findIndex((v) => sameVersion(v, versions[i])) - result.set(entry.id, history[first].id) - }) + const firsts: { version: Version; id: number }[] = [] + for (const entry of getHistory(sessionId)) { + const version = entryVersion(entry) + let first = firsts.find((f) => sameVersion(f.version, version)) + if (!first) { + first = { version, id: entry.id } + firsts.push(first) + } + result.set(entry.id, first.id) + } return result } diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index f0b062de..b23d6bcd 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -445,6 +445,21 @@ export async function waitForSelection( return answer ?? null } +/** + * What the version cards depend on: History's entries (their count and the + * newest id) and the entry the canvas shows. The shell reads History again + * only when this changes, so a hand edit (a new version, the same History) + * costs no download of every entry + */ +function historyKeyOf(sessionId: string, state?: SessionState): string { + const history = getHistory(sessionId) + const current = + state && history.length > 0 + ? currentEntryId(sessionId, state.xml) + : null + return `${history.length}:${history.at(-1)?.id ?? -1}:${current}` +} + /** POST /api/state with a selection: the tab's answer to requestSelection */ function handleSelectionResult( sessionId: string, @@ -742,6 +757,7 @@ function handleStateApi( exportOptions: state?.exportOptions || null, exportId: state?.exportId ?? null, selectionId: state?.selectionId ?? null, + historyKey: historyKeyOf(sessionId, state), }), ) } else if (req.method === "POST") { @@ -884,7 +900,13 @@ function handleStateApi( const saved = stateStore.get(sessionId) if (saved && data.source !== "sync") saved.userEdited = true res.writeHead(200, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ success: true, version })) + res.end( + JSON.stringify({ + success: true, + version, + historyKey: historyKeyOf(sessionId, saved), + }), + ) } catch { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Invalid JSON" })) @@ -929,8 +951,6 @@ function handleHistoryApi( svg: entry.svg, xml: entry.xml, source: entry.source ?? null, - time: entry.time, - pages: entry.pages, firstId: firstIds.get(entry.id), })), count: history.length, diff --git a/packages/mcp-server/tests/history.test.ts b/packages/mcp-server/tests/history.test.ts index b2048a0e..6c07f451 100644 --- a/packages/mcp-server/tests/history.test.ts +++ b/packages/mcp-server/tests/history.test.ts @@ -120,34 +120,17 @@ describe("otherVersions", () => { }) describe("entries", () => { - it("record who made them, when, and their page count", () => { + it("record who made them", () => { const id = "history-fields" clearHistory(id) - const before = Date.now() addHistory(id, A) addHistory(id, B, "", "user") - addHistory( - id, - ``, - "", - "restore", - ) + addHistory(id, C, "", "restore") const [first, second, third] = getHistory(id) expect(first.source).toBeUndefined() - expect(first.time).toBeGreaterThanOrEqual(before) - expect(first.pages).toBe(1) expect(second.source).toBe("user") expect(second.svg).toBe("") expect(third.source).toBe("restore") - expect(third.pages).toBe(2) - // A bare model is one page; text that is no diagram has none - addHistory(id, "") - addHistory(id, "not a diagram") - expect( - getHistory(id) - .slice(-2) - .map((e) => e.pages), - ).toEqual([1, 0]) }) }) @@ -171,6 +154,31 @@ describe("currentEntryId and firstCopyIds", () => { ]) }) + it("group a bare model with the first named page it matches, never a later one", () => { + // A bare model (the browser's sync can send one) has no page name: + // it is the same as "Arch" and as "Overview", which differ from each + // other. Each entry names a first copy with its own content, so a + // card never shows one document and restores another + const id = "history-bare-groups" + const named = (name: string) => A.replace('name="P"', `name="${name}"`) + const bare = `` + fill(id, [named("Arch"), bare, named("Overview")]) + const [arch, model, overview] = getHistory(id) + const firsts = firstCopyIds(id) + expect([...firsts.entries()]).toEqual([ + [arch.id, arch.id], + [model.id, arch.id], + [overview.id, overview.id], + ]) + // The entry named as first has the content of every entry in its + // group (otherVersions of that content never lists it) + for (const entry of getHistory(id)) { + expect(otherVersions(id, entry.xml).map((e) => e.id)).not.toContain( + firsts.get(entry.id), + ) + } + }) + it("agree with otherVersions on a page setting that only the canvas shows", () => { // The same cells with another background: one version to the MCP // core (the model's edits stay valid when the user changes a page diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts index 0620288b..ab4bc4f5 100644 --- a/packages/mcp-server/tests/http-server.test.ts +++ b/packages/mcp-server/tests/http-server.test.ts @@ -1197,8 +1197,6 @@ describe("GET /api/history for the version cards", () => { svg: "", xml: page("a"), source: null, - time: a.time, - pages: 1, firstId: a.id, }) expect(d.entries[1]).toMatchObject({ @@ -1209,6 +1207,49 @@ describe("GET /api/history for the version cards", () => { }) }) + it("gives the state a History key, which a hand edit changes once and a further one not at all", async () => { + // The shell reads History (every entry's XML and picture) again + // only when the key changes + const id = "mcp-history-key" + setState(id, page("a")) + addHistory(id, page("a")) + const [a] = getHistory(id) + const first = JSON.parse( + (await request(`/api/state?sessionId=${id}`)).body, + ) + expect(first.historyKey).toBe(`1:${a.id}:${a.id}`) + // A hand edit: the canvas is at no entry now, History is the same + const push = await postJson("/api/state", { + sessionId: id, + xml: page("b"), + baseVersion: first.version, + stateId: first.stateId, + }) + expect(push.status).toBe(200) + expect(JSON.parse(push.body).historyKey).toBe(`1:${a.id}:null`) + const again = await postJson("/api/state", { + sessionId: id, + xml: page("c"), + baseVersion: first.version + 1, + stateId: first.stateId, + }) + expect(JSON.parse(again.body).historyKey).toBe(`1:${a.id}:null`) + const polled = JSON.parse( + (await request(`/api/state?sessionId=${id}`)).body, + ) + expect(polled.historyKey).toBe(`1:${a.id}:null`) + // A write keeps the edit in History: the key names the new entry + keepInHistory(id, page("c")) + setState(id, page("d")) + addHistory(id, page("d")) + const [, c, d] = getHistory(id) + expect(c.xml).toBe(page("c")) + const after = JSON.parse( + (await request(`/api/state?sessionId=${id}`)).body, + ) + expect(after.historyKey).toBe(`3:${d.id}:${d.id}`) + }) + it("names the entry a restored copy stands for, and marks the copy as a restore", async () => { const id = "mcp-history-cards-restore" addHistory(id, page("a")) diff --git a/tests/unit/mcp-node-versions.test.tsx b/tests/unit/mcp-node-versions.test.tsx index f6282643..975cee76 100644 --- a/tests/unit/mcp-node-versions.test.tsx +++ b/tests/unit/mcp-node-versions.test.tsx @@ -43,8 +43,6 @@ function entry( svg: "", xml, source: null, - time: 1000 + id, - pages: 1, firstId: id, ...extra, } @@ -123,7 +121,7 @@ describe("buildVersions", () => { }) it("keeps the numbers once given, and gives new versions the next", () => { - const numbers = new Map() + 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 @@ -135,6 +133,138 @@ describe("buildVersions", () => { expect(versions.map((v) => v.number)).toEqual([2, 3]) }) + it("counts a write from the state it replaced, not from the card before, after a restore", () => { + // History: A, AB, ABC, A again (restored), AD: the AI drew D on A + const AD = doc(cell("a", "A") + cell("d", "D")) + const { versions } = buildVersions( + list( + [ + entry(0, A), + entry(1, AB), + entry(2, ABC), + entry(3, A, { firstId: 0, source: "restore" }), + entry(4, AD), + ], + 4, + ), + new Map(), + new Map(), + ) + expect(versions.map((v) => v.id)).toEqual(["0", "1", "2", "4"]) + const latest = versions[3] + expect(latest.beforeXml).toBe(A) + expect(latest.summary.shapesAdded).toBe(1) + expect(latest.summary.shapesRemoved).toBe(0) + expect(latest.beforeId).toBe("0") + // The first drawing replaced nothing a card shows + expect(versions[0].beforeId).toBeNull() + expect(versions[1].beforeId).toBe("0") + }) + + it("restores the newest copy of a version: a later copy may carry the user's page settings", () => { + // The user set a background on A (kept as a "user" entry, the same + // content to the server), then the AI added B + const yellow = A.replace( + "", + '', + ) + const { versions } = buildVersions( + list( + [ + entry(0, A), + entry(1, yellow, { firstId: 0, source: "user" }), + entry(2, AB), + ], + 2, + ), + new Map(), + new Map(), + ) + expect(versions).toHaveLength(2) + expect(versions[0].id).toBe("0") + expect(versions[0].entryId).toBe(1) + expect(versions[0].xml).toBe(yellow) + expect(versions[0].source).toBeNull() + }) + + it("shows a clear of the canvas as a version, hiding only the blank page before any drawing", () => { + const blank = `` + // The server folds the cleared page into the first blank (same + // content); then AB is restored + const entries = [ + entry(0, blank), + entry(1, A), + entry(2, AB), + entry(3, blank, { firstId: 0 }), + entry(4, AB, { firstId: 2, source: "restore" }), + ] + const cleared = buildVersions( + list(entries.slice(0, 4), 3), + new Map(), + new Map(), + ) + expect(cleared.versions.map((v) => v.id)).toEqual(["1", "2", "3"]) + expect(cleared.onCanvasId).toBe("3") + expect(cleared.versions[2].beforeId).toBe("2") + expect(cleared.versions[2].summary.shapesRemoved).toBe(2) + const back = buildVersions(list(entries, 4), new Map(), new Map()) + expect(back.versions.map((v) => v.id)).toEqual(["1", "2", "3"]) + expect(back.onCanvasId).toBe("2") + // A second clear is a version of its own; a restore of a clear is + // a copy of the newest one + const twice = buildVersions( + list( + [ + ...entries, + entry(5, blank, { firstId: 0 }), + entry(6, blank, { firstId: 0, source: "restore" }), + ], + 6, + ), + new Map(), + new Map(), + ) + expect(twice.versions.map((v) => [v.id, v.number])).toEqual([ + ["1", 1], + ["2", 2], + ["3", 3], + ["5", 4], + ]) + expect(twice.onCanvasId).toBe("5") + }) + + it("keeps a version's number and change when its first copy drops out of the server's buffer", () => { + const numbers = new Map() + const changes = new Map() + const docs = Array.from({ length: 20 }, (_, i) => + doc( + Array.from({ length: i + 1 }, (_, j) => + cell(`c${j}`, `C${j}`), + ).join(""), + ), + ) + const full = docs.map((xml, i) => entry(i, xml)) + buildVersions(list(full, 19), numbers, changes) + // v1 restored: its copy is added and the original, the oldest + // entry, drops out; the copy is now the first with that content + const after = [ + ...full.slice(1), + entry(20, docs[0], { firstId: 20, source: "restore" }), + ] + const { versions, onCanvasId } = buildVersions( + list(after, 20), + numbers, + changes, + ) + const v1 = versions.find((v) => v.id === "20") + expect(v1?.number).toBe(1) + expect(v1?.fromScratch).toBe(true) + expect(v1?.summary.shapesAdded).toBe(1) + expect(v1?.beforeXml).toBe("") + expect(versions.map((v) => v.number)).not.toContain(21) + expect(onCanvasId).toBe("20") + }) + 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), @@ -229,6 +359,76 @@ describe("useNodeVersions", () => { expect(result.current.canRedo).toBe(false) }) + it("numbers the History of another server state anew", async () => { + // The process restarted: entry ids start over and name other content + const server = fakeSync(list([entry(0, A), entry(1, AB)], 1)) + const { result } = renderHook(() => useNodeVersions(server.sync), { + wrapper, + }) + await flush() + expect(result.current.versions.map((v) => v.number)).toEqual([1, 2]) + const AB2 = doc(cell("a", "A") + cell("b", "B"), "p2") + const ABC2 = doc(cell("a", "A") + cell("b", "B") + cell("c", "C"), "p2") + await act(async () => + server.serve({ + entries: [entry(0, AB2), entry(1, ABC2)], + stateId: "S2", + currentId: 1, + }), + ) + expect(result.current.versions.map((v) => v.number)).toEqual([1, 2]) + const [first, second] = result.current.versions + expect(first.fromScratch).toBe(true) + expect(first.summary.shapesAdded).toBe(2) + expect(second.pageId).toBe("p2") + }) + + it("undoes to the version the newest one replaced, after a restore in between", async () => { + // History: A, AB, ABC, A again (restored), AD; undo from AD goes to + // A (its newest copy), as restore_version does, not to ABC + const AD = doc(cell("a", "A") + cell("d", "D")) + const server = fakeSync( + list( + [ + entry(0, A), + entry(1, AB), + entry(2, ABC), + entry(3, A, { firstId: 0, source: "restore" }), + entry(4, AD), + ], + 4, + ), + ) + const { result } = renderHook(() => useNodeVersions(server.sync), { + wrapper, + }) + await flush() + expect(result.current.canUndo).toBe(true) + await act(async () => { + result.current.undo() + }) + expect(server.restores).toEqual([{ id: 3, stateId: "S1" }]) + // Back at A: AD is the undone one + await act(async () => + server.serve( + list( + [ + entry(0, A), + entry(1, AB), + entry(2, ABC), + entry(3, A, { firstId: 0, source: "restore" }), + entry(4, AD), + entry(5, A, { firstId: 0, source: "restore" }), + ], + 5, + ), + ), + ) + expect(result.current.onCanvasId).toBe("0") + expect(result.current.undoneId).toBe("4") + expect(result.current.canRedo).toBe(true) + }) + it("has no versions without a sync", () => { const { result } = renderHook(() => useNodeVersions(null), { wrapper }) expect(result.current.versions).toEqual([]) diff --git a/tests/unit/mcp-sync-core.test.ts b/tests/unit/mcp-sync-core.test.ts index 7f713b08..f0fa60bd 100644 --- a/tests/unit/mcp-sync-core.test.ts +++ b/tests/unit/mcp-sync-core.test.ts @@ -1079,6 +1079,18 @@ describe("MCP sync History", () => { expect(t.notices).toEqual(["restoreFailed"]) }) + it("drops a History list of a state the poll has not seen yet", async () => { + // Another process answers on the port; its History has other ids. + // Its state is seen at the next poll, which tells the cards again + const t = await inStep() + const history = t.sync.fetchHistory() + t.next("GET", "/api/history").answer({ + ...historyList, + body: { ...historyList.body, stateId: "S2" }, + }) + expect(await history).toBeNull() + }) + it("passes on the entry the server says is on the canvas", async () => { const t = await inStep() const history = t.sync.fetchHistory() @@ -1092,18 +1104,24 @@ describe("MCP sync History", () => { // 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 () => { + it("tells once per History key: the first poll, a write, the tab's first edit, not its next", async () => { + // The server's key names History (count, newest id) and the entry + // on the canvas; the version cards read History only when it changes 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")) + t.next("GET").answer( + state("S1", 2, "A", { historyKey: "1:0:0" }), + ) await poll expect(changes).toBe(1) - // The same version again: nothing new + // The same key again: nothing new poll = t.sync.poll() - t.next("GET").answer(state("S1", 2, "A")) + t.next("GET").answer( + state("S1", 2, "A", { historyKey: "1:0:0" }), + ) await poll expect(changes).toBe(1) // The thumbnail of the write is stored: its entry has a picture now @@ -1115,29 +1133,82 @@ describe("MCP sync server change listener", () => { expect(changes).toBe(2) // An AI write poll = t.sync.poll() - t.next("GET").answer(state("S1", 3, "B")) + t.next("GET").answer( + state("S1", 3, "B", { historyKey: "2:1:1" }), + ) 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 }, + body: { success: true, version: 4, historyKey: "2:1:null" }, }) await t.settle() expect(changes).toBe(4) - // The next poll sees the version the push already told about + // The next poll sees the key the push already told about poll = t.sync.poll() - t.next("GET").answer(state("S1", 4, "C")) + t.next("GET").answer( + state("S1", 4, "C", { historyKey: "2:1:null" }), + ) await poll expect(changes).toBe(4) + // A further edit changes History not at all: no download of it + await edit(t, "C2") + t.next("POST", "/api/state").answer({ + status: 200, + body: { success: true, version: 5, historyKey: "2:1:null" }, + }) + await t.settle() + expect(changes).toBe(4) stop() poll = t.sync.poll() - t.next("GET").answer(state("S1", 5, "D")) + t.next("GET").answer( + state("S1", 6, "D", { historyKey: "3:2:2" }), + ) await poll expect(changes).toBe(4) }) + it("tells once per server version when the server sends no key", async () => { + const t = open() + let changes = 0 + t.sync.onServerChange(() => changes++) + let poll = t.sync.poll() + t.next("GET").answer(state("S1", 2, "A")) + await poll + poll = t.sync.poll() + t.next("GET").answer(state("S1", 2, "A")) + await poll + expect(changes).toBe(1) + poll = t.sync.poll() + t.next("GET").answer(state("S1", 3, "B")) + await poll + expect(changes).toBe(2) + }) + + it("tells again at the next poll when a History read failed", async () => { + // The cards would otherwise keep the old list until the next write + const t = await inStep() + let changes = 0 + t.sync.onServerChange(() => changes++) + const history = t.sync.fetchHistory() + t.next("GET", "/api/history").answer({ status: 500, body: {} }) + expect(await history).toBeNull() + const poll = t.sync.poll() + t.next("GET", "/api/state").answer(state("S1", 2, "A")) + await poll + expect(changes).toBe(1) + // Also when the request itself fails + const again = t.sync.fetchHistory() + t.next("GET", "/api/history").fail() + expect(await again).toBeNull() + const next = t.sync.poll() + t.next("GET", "/api/state").answer(state("S1", 2, "A")) + await next + expect(changes).toBe(2) + }) + it("tells when a rejected edit was kept in History, and when the state was recreated", async () => { const t = await inStep() let changes = 0