mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
fix(mcp-server): review fixes for the version cards and History
The shell's version cards (shell/node-versions-source.ts): - a version's change and undo target are the state it replaced, the History entry right before its first copy, not the card before it: after a restore those differ, and undo went to the wrong version (and not where restore_version steps_back=1 goes) - a card restores the newest copy of its content, as restore_version does, so page settings the user changed (a "user" copy) are kept - a blank page after a drawing is a clear of the canvas, a version of its own; only the blank page before any drawing is hidden - numbers and changes are keyed by content, not by the first copy's id, so a version keeps them when its first copy drops out of the server's 20-entry buffer; the caches start over for another server state (the process restarted: entry ids name other content) The server's History (src/history.ts): - firstCopyIds compares each entry with the first of every group only: a bare model matches any page name, so "same content" is not transitive, and a card could show one document and restore another - the time and pages fields had no reader; pages parsed every XML once more on every write Reading History (src/http-server.ts, shell/mcp-sync-core.ts): - GET /api/state and a push's answer carry a History key (entry count, newest id, the entry on the canvas); the shell reads History again only when it changes, so a hand edit no longer downloads every entry's XML and thumbnail - a failed History read is told again at the next poll - a History list from a state the poll has not seen yet is dropped
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<number, number>,
|
||||
changes: Map<number, Change>,
|
||||
numbers: Map<string, number>,
|
||||
changes: Map<string, Change>,
|
||||
): { 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<number, HistoryEntryInfo[]>()
|
||||
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<number, number>() // entry id -> its group
|
||||
const befores = new Map<number, HistoryEntryInfo>() // group -> entry before it
|
||||
const hidden = new Set<number>() // 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<VersionsSource, "versions"> {
|
||||
/**
|
||||
* 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<HistoryList | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// Stable across refreshes: an entry's number and change never move
|
||||
const numbersRef = useRef(new Map<number, number>())
|
||||
const changesRef = useRef(new Map<number, Change>())
|
||||
// Stable across refreshes: a version's number and change never move
|
||||
const numbersRef = useRef(new Map<string, number>())
|
||||
const changesRef = useRef(new Map<string, Change>())
|
||||
// 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<string | null>(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 =
|
||||
|
||||
@@ -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<string, HistoryEntry[]>()
|
||||
|
||||
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<number, number> {
|
||||
const history = getHistory(sessionId)
|
||||
const versions = history.map(entryVersion)
|
||||
const result = new Map<number, number>()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "<svg/>", "user")
|
||||
addHistory(
|
||||
id,
|
||||
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/></root></mxGraphModel></diagram><diagram id="q" name="Q"><mxGraphModel><root><mxCell id="0"/></root></mxGraphModel></diagram></mxfile>`,
|
||||
"",
|
||||
"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("<svg/>")
|
||||
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, "<mxGraphModel><root/></mxGraphModel>")
|
||||
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 = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel>`
|
||||
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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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<number, number>()
|
||||
const numbers = new Map<string, number>()
|
||||
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(
|
||||
"<mxGraphModel>",
|
||||
'<mxGraphModel background="#ffcc00">',
|
||||
)
|
||||
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 = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
// 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<string, number>()
|
||||
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([])
|
||||
|
||||
@@ -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, "<mxfile>A</mxfile>"))
|
||||
t.next("GET").answer(
|
||||
state("S1", 2, "<mxfile>A</mxfile>", { 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, "<mxfile>A</mxfile>"))
|
||||
t.next("GET").answer(
|
||||
state("S1", 2, "<mxfile>A</mxfile>", { 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, "<mxfile>B</mxfile>"))
|
||||
t.next("GET").answer(
|
||||
state("S1", 3, "<mxfile>B</mxfile>", { 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, "<mxfile>C</mxfile>")
|
||||
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, "<mxfile>C</mxfile>"))
|
||||
t.next("GET").answer(
|
||||
state("S1", 4, "<mxfile>C</mxfile>", { 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, "<mxfile>C2</mxfile>")
|
||||
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, "<mxfile>D</mxfile>"))
|
||||
t.next("GET").answer(
|
||||
state("S1", 6, "<mxfile>D</mxfile>", { 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, "<mxfile>A</mxfile>"))
|
||||
await poll
|
||||
poll = t.sync.poll()
|
||||
t.next("GET").answer(state("S1", 2, "<mxfile>A</mxfile>"))
|
||||
await poll
|
||||
expect(changes).toBe(1)
|
||||
poll = t.sync.poll()
|
||||
t.next("GET").answer(state("S1", 3, "<mxfile>B</mxfile>"))
|
||||
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, "<mxfile>A</mxfile>"))
|
||||
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, "<mxfile>A</mxfile>"))
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user