feat(mcp-server): saved sessions, restore_version and History on disk (#981)

* feat(mcp-server): list and resume saved diagram sessions

start_session accepts an optional session_id to continue a saved diagram
under the same preview URL and auto-save file, reporting the restored
pages. New read-only tool list_saved_diagrams prints every auto-saved
file newest first with its pages. Autosaver gains list() and dataDir().
INSTRUCTIONS and both READMEs mention the new tool.

* feat(mcp-server): add restore_version tool for undo and redo from History

Add the restore_version write tool so the model can put an earlier
version from History back on the canvas (steps_back, default 1). The
current canvas is kept in History unless it is the blank page, so a redo
is possible. http-server.ts exports restoreHistoryEntry, shared by the
HTTP restore endpoint and the tool; history.ts exports HistoryEntry and
otherVersions (distinct versions other than the canvas, newest first).
The drawing guide tells the model to call restore_version when the user
asks to undo, and both READMEs document the tool.

* feat(mcp-server): save the diagram History next to the auto-saved file

The Autosaver writes each session's History (the XML of its last 20 versions, without thumbnails) to <session-id>.history.json beside the .drawio file, after the same 1 second delay and only when the entries changed. When a saved session is resumed after an idle timeout or a process restart, the saved History is loaded back before the diagram, so the History panel and restore_version keep working. Removing the oldest files also removes their History files.

* fix(mcp-server): review fixes for saved sessions, undo tool, history on disk

* fix(mcp-server): Codex review fixes for saved sessions, undo and History on disk

- start_session resumes every saved file, also a cleared one or one of
  empty named pages, and decompresses pages draw.io compressed
- History kept by a lost user edit or a recovering tab is saved too
- A diagram cleared before its first save keeps its History on disk
- The History file is written again after the 50-file cap removed it
- restore_version compares versions like the edit gate (a bare
  mxGraphModel from the browser has no page name), checks the restore
  result, and says "none" when no version is left
- The preview closes an open History list when the server state was
  recreated, since the entries got new ids
- start_session's description mentions session_id

* fix(mcp-server): compare History versions pairwise and tie restores to the server state

Second Codex review of the History fixes:
- One bare mxGraphModel in History no longer hides page renames in every
  comparison: names are left out only when one of the two is bare
- A History list asked for before the server recreated the session is
  dropped, and a restore names the state its list belongs to; the server
  refuses one from a lost state (History ids change after a restart)

* fix(mcp-server): restore from a History list with the state it was taken in

Third Codex review: a restore sent the tab's current state, so an old
list shown after a failed refresh passed the server's check. The list now
keeps the state it came from, and the server refuses a missing (null)
state too; only a tab of an older version, which sends none, is let
through.
This commit is contained in:
Dayuan Jiang
2026-10-11 20:23:23 +09:00
committed by GitHub
parent c774a39c33
commit 77be43d996
13 changed files with 1003 additions and 55 deletions
+1
View File
@@ -29,6 +29,7 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide
- For artistic requests (a cat, a logo, a scene), compose the picture from standard shapes and connectors while keeping it clear.
- To clear the canvas to one blank page, call create_new_diagram with only the two root cells <mxCell id="0"/><mxCell id="1" parent="0"/>; the previous diagram stays in History.
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
- If the user asks to undo or go back, call restore_version instead of re-sending the earlier XML from memory.
## The XML you send
Single page (create_new_diagram, add_page): send ONLY the named styles and the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1", expands named styles (see Styles), adds html=1 to every cell and whiteSpace=wrap to shapes, and fills in vertex, edge, parent="1" and the mxGeometry element. A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target. A cell with source or target is always an edge. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
+51 -1
View File
@@ -3,11 +3,13 @@
* Stores {xml, svg} entries in a circular buffer
*/
import { contentFingerprint } from "./edit-gate.ts"
import { log } from "./logger.ts"
import { isMxGraphModel } from "./pages.ts"
const MAX_HISTORY = 20
interface HistoryEntry {
export interface HistoryEntry {
id: number // Stable across shifts of the circular buffer
xml: string
svg: string
@@ -59,6 +61,54 @@ export function clearHistory(sessionId: string): void {
historyStore.delete(sessionId)
}
/**
* The versions the diagram can go back (or forward) to: every entry whose
* content differs from currentXml, newest first, one entry per distinct
* content (its newest copy). Re-serialised copies of one diagram count as
* the same version.
*/
export function otherVersions(
sessionId: string,
currentXml: string,
): HistoryEntry[] {
const kept = [versionOf(currentXml)]
const result: HistoryEntry[] = []
const history = getHistory(sessionId)
for (let i = history.length - 1; i >= 0; i--) {
const version = versionOf(history[i].xml)
if (kept.some((seen) => sameVersion(seen, version))) continue
kept.push(version)
result.push(history[i])
}
return result
}
/** True when History holds a copy of this diagram. */
export function isInHistory(sessionId: string, xml: string): boolean {
const version = versionOf(xml)
return getHistory(sessionId).some((entry) =>
sameVersion(version, versionOf(entry.xml)),
)
}
/** A diagram's fingerprints, with and without its page names */
function versionOf(xml: string) {
return {
bare: isMxGraphModel(xml),
named: contentFingerprint(xml),
cells: contentFingerprint(xml, false),
}
}
// Same rule as checkEditGate: a bare <mxGraphModel> (the browser's sync
// can send one) has no page name, so names count only when both have them
function sameVersion(
a: ReturnType<typeof versionOf>,
b: ReturnType<typeof versionOf>,
): boolean {
return a.bare || b.bare ? a.cells === b.cells : a.named === b.named
}
/**
* Give the last entry the image the browser took of shownXml, the diagram
* it just loaded, when that entry is this diagram
+50 -20
View File
@@ -569,6 +569,8 @@ function handleStateApi(
data.xml !== current.xml
if (saved) {
addHistory(sessionId, data.xml, data.svg || "")
// Saved with the History, as after any change
stateListener?.(sessionId, current.xml)
}
res.writeHead(409, {
"Content-Type": "application/json",
@@ -605,6 +607,7 @@ function handleStateApi(
// it in history so the user can restore it.
addHistory(sessionId, data.xml, data.svg || "")
savedToHistory = true
if (current) stateListener?.(sessionId, current.xml)
}
res.writeHead(409, { "Content-Type": "application/json" })
res.end(
@@ -670,6 +673,36 @@ function handleHistoryApi(
)
}
/**
* Put a History entry back on the canvas (the preview page's Restore button
* and the restore_version tool). Returns the new version, or null when the
* entry is unknown.
*/
export function restoreHistoryEntry(
sessionId: string,
entryId: number,
): number | null {
const entry = getHistoryEntry(sessionId, entryId)
if (!entry) return null
// Edits in the browser since the last entry are not in history
// yet: keep them, so the restore can be undone
// (any state besides a blank page; a cleared document with its
// own pages counts)
const current = stateStore.get(sessionId)
if (
current &&
contentFingerprint(current.xml) !== contentFingerprint(BLANK_MXFILE)
) {
keepInHistory(sessionId, current.xml, current.svg)
}
const newVersion = setState(sessionId, entry.xml)
addHistory(sessionId, entry.xml, entry.svg)
log.info(`Restored session ${sessionId} to history entry ${entryId}`)
return newVersion
}
function handleRestoreApi(
req: http.IncomingMessage,
res: http.ServerResponse,
@@ -682,37 +715,34 @@ function handleRestoreApi(
readBody(req, res, (body) => {
try {
const { sessionId, id } = JSON.parse(body)
const data = JSON.parse(body)
const { sessionId, id } = data
if (!sessionId || typeof id !== "number") {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId and id required" }))
return
}
// Picked from the list of a state the server has since lost
// (a restart reloads History under new ids), or before the tab
// knew the state. A tab of an older version sends none.
const current = stateStore.get(sessionId)
if (
current &&
"stateId" in data &&
data.stateId !== current.stateId
) {
res.writeHead(409, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Session was recreated" }))
return
}
const entry = getHistoryEntry(sessionId, id)
if (!entry) {
const newVersion = restoreHistoryEntry(sessionId, id)
if (newVersion === null) {
res.writeHead(404, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Entry not found" }))
return
}
// Edits in the browser since the last entry are not in history
// yet: keep them, so the restore can be undone
// (any state besides a blank page; a cleared document with its
// own pages counts)
const current = stateStore.get(sessionId)
if (
current &&
contentFingerprint(current.xml) !==
contentFingerprint(BLANK_MXFILE)
) {
keepInHistory(sessionId, current.xml, current.svg)
}
const newVersion = setState(sessionId, entry.xml)
addHistory(sessionId, entry.xml, entry.svg)
log.info(`Restored session ${sessionId} to history entry ${id}`)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, newVersion }))
} catch {
+286 -13
View File
@@ -32,9 +32,15 @@ import type { DiagramOperation } from "./diagram-operations.ts"
import { installDomPolyfill } from "./dom.ts"
import { DRAWING_GUIDE } from "./drawing-guide.ts"
import { editDiagram, targetPageXml } from "./edit-diagram.ts"
import { checkEditGate, markPageSeen } from "./edit-gate.ts"
import { checkEditGate, contentFingerprint, markPageSeen } from "./edit-gate.ts"
import { createExclusive } from "./exclusive.ts"
import { addHistory } from "./history.ts"
import {
addHistory,
getHistory,
type HistoryEntry,
isInHistory,
otherVersions,
} from "./history.ts"
import {
type ExportFormat,
type ExportOptions,
@@ -45,6 +51,7 @@ import {
onStateChange,
requestExport,
requestSync,
restoreHistoryEntry,
restoreSavedSession,
setState,
shutdown,
@@ -60,6 +67,7 @@ import {
} from "./new-diagram.ts"
import {
addPageToDoc,
BLANK_MXFILE,
deletePageFromDoc,
findPageElement,
hasCells,
@@ -86,10 +94,23 @@ const config = {
port: parseInt(process.env.PORT || "6002", 10),
}
// Keep each session's latest diagram on disk, so it survives this process
const autosaver = new Autosaver(defaultDataDir())
// Keep each session's latest diagram and its History on disk, so they
// survive this process
const autosaver = new Autosaver(defaultDataDir(), undefined, undefined, (id) =>
getHistory(id),
)
onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml))
onSessionRecreate((sessionId) => autosaver.load(sessionId))
onSessionRecreate((sessionId) => {
const saved = autosaver.load(sessionId)
if (!saved) return null
// A file draw.io saved (compressed pages) comes back as plain XML, as
// with load_diagram
const loaded = parseDrawioFileContent(saved)
// History comes back with the diagram (addHistory drops consecutive
// duplicates)
for (const x of autosaver.loadHistory(sessionId)) addHistory(sessionId, x)
return loaded.ok ? loaded.xml : saved
})
// A one-page view that does not count for the whole document (edit-gate.ts)
const OTHER_PAGES_UNSEEN =
@@ -146,7 +167,9 @@ Tools:
- get_diagram: read the current XML, including the user's manual edits.
- screenshot_diagram: see the rendered diagram as an image.
- load_diagram, export_diagram: open or save .drawio files and export .png or .svg. Use absolute paths.
- list_pages, add_page, rename_page, delete_page: manage pages (tabs).`
- list_saved_diagrams: diagrams saved by earlier sessions; continue one with start_session session_id=<id>.
- list_pages, add_page, rename_page, delete_page: manage pages (tabs).
- restore_version: undo; go back to an earlier version of the diagram.`
const server = new McpServer(
{
@@ -289,20 +312,32 @@ registerWriteTool(
{
title: "Start session",
description:
"Start a new diagram session and open the browser for real-time preview. " +
"Start a new diagram session, or continue a saved one with session_id, and open the browser for real-time preview. " +
"Starts an embedded server and opens a browser window with draw.io. " +
"The browser will show diagram updates as they happen. " +
"The result includes the drawing guide; follow it when drawing.",
inputSchema: {},
inputSchema: {
session_id: z
.string()
.regex(/^mcp-[a-z0-9-]{1,64}$/)
.optional()
.describe(
"Id of a saved diagram (from list_saved_diagrams, or the Session ID of an earlier start_session) to continue it with the same preview URL and auto-save file. Omit to start a new diagram.",
),
},
annotations: { destructiveHint: false, openWorldHint: false },
},
async () => {
async (input) => {
// The only field is optional, so a client may send no arguments
const { session_id } = input ?? {}
try {
// Start embedded HTTP server
const port = await startHttpServer(config.port)
// Create session
const sessionId = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`
// Create session, or continue a saved one under its old id
const sessionId =
session_id ??
`mcp-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`
currentSession = {
id: sessionId,
xml: "",
@@ -314,9 +349,28 @@ registerWriteTool(
const browserUrl = `http://localhost:${port}?mcp=${sessionId}`
await open(browserUrl)
// A saved diagram comes back from its file. lastSeenXml stays
// empty, so edit_diagram asks for get_diagram first.
const restored = sessionState(sessionId)
let intro = "Session started successfully!"
if (session_id) {
// blank: the browser asked first and nothing was saved
if (restored && !restored.blank) {
const doc = parseMxfile(restored.xml)
const pages = doc ? listPagesFromDoc(doc) : []
const pageSummary =
pages.length > 0
? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}`
: "no pages parsed"
intro = `Resumed session ${sessionId}.\n\n${pageSummary}\n\nCall get_diagram before edit_diagram.`
} else {
intro = `No saved diagram for ${sessionId}; starting blank.`
}
}
const savePath = autosaver.pathFor(sessionId)
const saveNote = savePath
? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue it in a later conversation, call start_session, then load_diagram with this path.`
? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue this diagram in a later conversation, call start_session with session_id=${sessionId}. list_saved_diagrams lists older diagrams.`
: ""
const rulesNote = `\n\nYour own drawing rules: write them in ${customInstructionsPath()} (Markdown, up to 5000 characters, read on every call).`
@@ -326,7 +380,7 @@ registerWriteTool(
content: [
{
type: "text",
text: `Session started successfully!\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}${rulesNote}\n\n${guideText()}`,
text: `${intro}\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}${rulesNote}\n\n${guideText()}`,
},
],
}
@@ -342,6 +396,83 @@ registerWriteTool(
},
)
/** "2026-10-04 09:30" in the server's local time */
function formatLocalTime(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
// Tool: list_saved_diagrams
server.registerTool(
"list_saved_diagrams",
{
title: "List saved diagrams",
description:
"List the diagrams saved by earlier sessions (auto-save files), newest first, with their pages. " +
"Continue one with start_session session_id=<id>; the file path also works with load_diagram.",
inputSchema: {},
annotations: { readOnlyHint: true, openWorldHint: false },
},
async () => {
try {
const dir = autosaver.dataDir()
if (!dir) {
return {
content: [
{
type: "text",
text: "Auto-save is off (DRAWIO_DATA_DIR=off), so there are no saved diagrams.",
},
],
}
}
const saved = autosaver.list()
if (saved.length === 0) {
return {
content: [
{ type: "text", text: `No saved diagrams in ${dir}.` },
],
}
}
const fs = await import("node:fs/promises")
const lines: string[] = []
for (const { sessionId, path, savedAt } of saved) {
// A file may hold compressed pages (saved by draw.io itself)
let pages = "unreadable"
try {
const loaded = parseDrawioFileContent(
await fs.readFile(path, "utf-8"),
)
const doc = loaded.ok ? parseMxfile(loaded.xml) : null
if (doc) {
pages = listPagesFromDoc(doc)
.map((p) => `${p.name} (${p.cellCount} cells)`)
.join(", ")
}
} catch (error) {
log.warn(
`Could not read the saved diagram ${path}: ${error}`,
)
}
const current =
sessionId === currentSession?.id ? " (current)" : ""
lines.push(
`${sessionId} saved ${formatLocalTime(savedAt)} pages: ${pages} file: ${path}${current}`,
)
}
return { content: [{ type: "text", text: lines.join("\n") }] }
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("list_saved_diagrams failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Tool: create_new_diagram
registerWriteTool(
"create_new_diagram",
@@ -1915,6 +2046,148 @@ registerWriteTool(
},
)
/** One line of pages for a history entry, for restore_version's texts. */
function describePages(xml: string): string {
const doc = parseMxfile(xml)
const pages = doc ? listPagesFromDoc(doc) : []
return pages.length > 0
? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}`
: "no pages parsed"
}
/** The versions restore_version can go to, numbered by steps_back. */
function describeVersions(entries: HistoryEntry[]): string {
return entries
.slice(0, 20)
.map((entry, i) => {
const doc = parseMxfile(entry.xml)
const pages = doc ? listPagesFromDoc(doc) : []
const names =
pages.length > 0
? pages
.map((p) => `"${p.name}" (${p.cellCount} cells)`)
.join(", ")
: "no pages parsed"
return `steps_back=${i + 1}: ${names}`
})
.join(" | ")
}
// Tool: restore_version
registerWriteTool(
"restore_version",
{
title: "Restore version",
description:
"Undo: put an earlier version of the diagram from History back on the canvas (the current canvas is kept in History, so this can be undone too). Use it when the user asks to undo, revert or go back.",
inputSchema: {
steps_back: z
.number()
.int()
.min(1)
.optional()
.describe(
"1 = the newest version that differs from the canvas (default), 2 = the one before it, and so on. After an undo, steps_back=1 returns to the version you just left (redo) and steps_back=2 goes further back.",
),
},
annotations: { destructiveHint: false, openWorldHint: false },
},
async (input) => {
const stepsBack = input?.steps_back ?? 1
try {
if (!currentSession) {
return {
content: [
{
type: "text",
text: "Error: No active session. Please call start_session first.",
},
],
isError: true,
}
}
const session = currentSession
// The canvas, with the user's manual edits, is the state store
const current = sessionState(session.id)?.xml || session.xml
const candidates = otherVersions(session.id, current)
if (candidates.length === 0) {
return {
content: [
{
type: "text",
text: "Error: No other version in History.",
},
],
isError: true,
}
}
if (candidates.length < stepsBack) {
return {
content: [
{
type: "text",
text: `Error: Only ${candidates.length} other version(s) in History: ${describeVersions(candidates)}`,
},
],
isError: true,
}
}
const entry = candidates[stepsBack - 1]
// Manual edits since the last entry are not in History yet;
// restoreHistoryEntry keeps them (a blank page excepted)
const hadManual =
contentFingerprint(current) !==
contentFingerprint(BLANK_MXFILE) &&
!isInHistory(session.id, current)
if (restoreHistoryEntry(session.id, entry.id) === null) {
return {
content: [
{
type: "text",
text: "Error: That version is no longer in History.",
},
],
isError: true,
}
}
session.xml = entry.xml
session.version++
// As load_diagram: the model may never have seen this XML, so
// the edit gate requires one get_diagram before edits
session.lastSeenXml = ""
log.info(
`Restored the version ${stepsBack} step(s) back (entry ${entry.id})`,
)
const manualNote = hadManual
? " Your manual changes were kept in History (steps_back=1 brings them back)."
: ""
const next =
describeVersions(otherVersions(session.id, entry.xml)) || "none"
return {
content: [
{
type: "text",
text: `Restored the version ${stepsBack} step(s) back.${manualNote}\n\n${describePages(entry.xml)}\n\nVersions you can go to now: ${next}\n\nCall get_diagram before edit_diagram.`,
},
],
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error)
log.error("restore_version failed:", message)
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
}
}
},
)
// Graceful shutdown handler
let isShuttingDown = false
function gracefulShutdown(reason: string) {
+101 -11
View File
@@ -3,7 +3,8 @@
* diagram survives the MCP process (hosts start a new one when a
* conversation is resumed). Like the web app's IndexedDB sessions
* (lib/session-storage.ts): saved 1 second after the last change, at most
* 50 kept. History is not saved.
* 50 kept. The session's History (the XML of its last 20 versions, without
* the thumbnails) is saved next to it as <session-id>.history.json.
*/
import {
@@ -12,6 +13,7 @@ import {
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
@@ -65,6 +67,10 @@ export class Autosaver {
private dir: string | null,
private delayMs = DELAY_MS,
private maxFiles = MAX_FILES,
// The session's History entries, saved next to its diagram
private history: (
sessionId: string,
) => { id: number; xml: string }[] = () => [],
) {}
/** Path of a session's file, or null when saving is off. */
@@ -72,6 +78,60 @@ export class Autosaver {
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
}
/** Path of a session's History file, or null when saving is off. */
historyPathFor(sessionId: string): string | null {
return this.dir ? join(this.dir, `${sessionId}.history.json`) : null
}
/** The XML of the session's saved History entries, oldest first. */
loadHistory(sessionId: string): string[] {
const path = this.historyPathFor(sessionId)
if (!path) return []
try {
const entries: unknown = JSON.parse(readFileSync(path, "utf-8"))
return Array.isArray(entries) &&
entries.every((e) => typeof e === "string")
? entries
: []
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
log.warn(`Could not read the saved History ${path}: ${error}`)
}
return []
}
}
/** The folder the files are saved in, or null when saving is off. */
dataDir(): string | null {
return this.dir
}
/**
* Every file a session can resume (start_session's own files and
* hand-named ones such as mcp-notes.drawio), newest first. Empty when
* saving is off or the folder does not exist yet.
*/
list(): { sessionId: string; path: string; savedAt: Date }[] {
return this.files(/^mcp-[a-z0-9-]{1,64}\.drawio$/).map((file) => ({
sessionId: file.name.slice(0, -".drawio".length),
path: file.path,
savedAt: file.mtime,
}))
}
/** The files in the folder whose name matches, newest first */
private files(pattern: RegExp) {
const dir = this.dir
if (!dir || !existsSync(dir)) return []
return readdirSync(dir)
.filter((name) => pattern.test(name))
.map((name) => {
const path = join(dir, name)
return { name, path, mtime: statSync(path).mtime }
})
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
}
// Saved files that could not be read back: never written over, since
// the session then shows something else than what they hold. Cleared
// once the file is read, or is surely gone (a folder without permission
@@ -132,30 +192,60 @@ export class Autosaver {
}
try {
const isNew = !existsSync(path)
// A blank page the browser shows before any drawing: nothing to keep
if (isNew && isBlank(entry.xml)) return
// A blank page the browser shows before any drawing: nothing to
// keep (unless the diagram was cleared before its first save)
if (
isNew &&
isBlank(entry.xml) &&
this.history(sessionId).every((e) => isBlank(e.xml))
)
return
mkdirSync(this.dir, { recursive: true })
// Write to a temporary file first so a crash never leaves half a file
writeFileSync(`${path}.tmp`, entry.xml, "utf-8")
renameSync(`${path}.tmp`, path)
this.writeHistory(sessionId)
if (isNew) this.removeOldest()
} catch (error) {
log.warn(`Auto-save failed for ${path}: ${error}`)
}
}
// What each session's History file holds (entry count and last id):
// draw.io autosaves often, and the file is only rewritten when the
// entries changed
private historyKeys = new Map<string, string>()
// Saved with the diagram, after the same delay, so the History entry
// that follows every tool write is included. The thumbnails are left
// out (large); the History grid shows the entry's number instead.
private writeHistory(sessionId: string): void {
const path = this.historyPathFor(sessionId)
const entries = this.history(sessionId)
if (!path || entries.length === 0) return
const key = `${entries.length}:${entries[entries.length - 1].id}`
if (this.historyKeys.get(sessionId) === key) return
try {
const json = JSON.stringify(entries.map((e) => e.xml))
writeFileSync(`${path}.tmp`, json, "utf-8")
renameSync(`${path}.tmp`, path)
this.historyKeys.set(sessionId, key)
} catch (error) {
log.warn(`Saving the History failed for ${path}: ${error}`)
}
}
private removeOldest(): void {
if (!this.dir) return
const dir = this.dir
// Only our own session files (mcp-<time in base 36>-<random>, made
// by start_session): DRAWIO_DATA_DIR may be a folder that also holds
// the user's diagrams
const files = readdirSync(dir)
.filter((f) => /^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/.test(f))
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime)
for (const { f } of files.slice(this.maxFiles)) {
unlinkSync(join(dir, f))
const own = this.files(/^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/)
for (const { name, path } of own.slice(this.maxFiles)) {
unlinkSync(path)
// Its History goes with it (there may be none), and is written
// again if the session saves once more
rmSync(path.replace(/\.drawio$/, ".history.json"), { force: true })
this.historyKeys.delete(name.slice(0, -".drawio".length))
}
}
}
+11 -2
View File
@@ -237,6 +237,8 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
// process restarted. Decide whose diagram wins.
function recoverState(s) {
stateId = s.stateId;
// History entries got new ids with the new state: an open list is stale
if (historyModal.classList.contains('open')) cancelBtn.onclick();
// The old state's pending work is gone with it
const projectionShown = projectionExportActive;
projectionExportActive = false;
@@ -453,18 +455,24 @@ const historyGrid = document.getElementById('history-grid');
const historyEmpty = document.getElementById('history-empty');
const restoreBtn = document.getElementById('restore-btn');
const cancelBtn = document.getElementById('cancel-btn');
let historyData = [], selectedId = null;
// historyStateId: the server state the list belongs to, sent with a restore
let historyData = [], historyStateId = null, selectedId = null;
historyBtn.onclick = async () => {
if (!sessionId) return;
// A list for a state the server recreated meanwhile has old ids
const sid = stateId;
try {
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
if (r.ok) {
const d = await r.json();
if (sid !== stateId) return;
historyData = d.entries || [];
historyStateId = sid;
renderHistory();
}
} catch {}
if (sid !== stateId) return;
historyModal.classList.add('open');
};
@@ -518,9 +526,10 @@ restoreBtn.onclick = async () => {
const r = await fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, id: selectedId })
body: JSON.stringify({ sessionId, id: selectedId, stateId: historyStateId })
});
if (r.ok) { cancelBtn.onclick(); await poll(); }
else if (r.status === 409) { cancelBtn.onclick(); showNotice('History changed on the server. Please open it again.'); }
else { showNotice('Restore failed. Please try again.'); }
} catch { showNotice('Restore failed. Please try again.'); }
restoreBtn.textContent = 'Restore';