From f52f95025f119e4e8de5ce83b5d32ea02c4c3dbf Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 4 Oct 2026 06:46:09 +0900 Subject: [PATCH] refactor(mcp-server): move the preview page into src/preview The 580-line page template in http-server.ts becomes index.html, preview.css and preview.js, copied to dist/preview by the build and filled at request time. The rendered page is unchanged apart from the session id and draw.io origin now coming from a small config script. Biome skips the folder because of the {{placeholders}}, as it never linted the old template string either. --- biome.json | 2 +- packages/mcp-server/scripts/check-package.mjs | 8 +- packages/mcp-server/scripts/copy-assets.mjs | 5 + packages/mcp-server/src/http-server.ts | 651 +----------------- packages/mcp-server/src/preview/index.html | 93 +++ packages/mcp-server/src/preview/preview.css | 167 +++++ packages/mcp-server/src/preview/preview.js | 364 ++++++++++ packages/mcp-server/tests/http-server.test.ts | 12 +- 8 files changed, 677 insertions(+), 625 deletions(-) create mode 100644 packages/mcp-server/src/preview/index.html create mode 100644 packages/mcp-server/src/preview/preview.css create mode 100644 packages/mcp-server/src/preview/preview.js diff --git a/biome.json b/biome.json index 0bdb72c..199ceab 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!public"] + "includes": ["**", "!public", "!packages/mcp-server/src/preview"] }, "formatter": { "enabled": true, diff --git a/packages/mcp-server/scripts/check-package.mjs b/packages/mcp-server/scripts/check-package.mjs index df37bf1..133cbdf 100644 --- a/packages/mcp-server/scripts/check-package.mjs +++ b/packages/mcp-server/scripts/check-package.mjs @@ -3,7 +3,13 @@ // Run after `npm run build`. import { execSync } from "node:child_process" -const REQUIRED = ["dist/index.js", "dist/shape-libraries/aws4.md"] +const REQUIRED = [ + "dist/index.js", + "dist/shape-libraries/aws4.md", + "dist/preview/index.html", + "dist/preview/preview.css", + "dist/preview/preview.js", +] const [pack] = JSON.parse( execSync("npm pack --dry-run --json", { encoding: "utf8" }), diff --git a/packages/mcp-server/scripts/copy-assets.mjs b/packages/mcp-server/scripts/copy-assets.mjs index 65664cc..67f8e0e 100644 --- a/packages/mcp-server/scripts/copy-assets.mjs +++ b/packages/mcp-server/scripts/copy-assets.mjs @@ -15,3 +15,8 @@ for (const file of readdirSync(libSrc)) { cpSync(join(libSrc, file), join(libDest, file)) } } + +// Browser preview page (HTML, CSS and script) +cpSync(join(pkg, "src/preview"), join(pkg, "dist/preview"), { + recursive: true, +}) diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 38bc811..3d5a00d 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -3,7 +3,10 @@ * Serves draw.io embed with state sync and history UI */ +import { readFileSync } from "node:fs" import http from "node:http" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB @@ -586,624 +589,34 @@ function handleHistorySvgApi( }) } -function getHtmlPage(sessionId: string): string { - return ` - - - - - Next AI Draw.io - - - -
- - -
-
-
- -
-
- -
- - -` +function loadPreviewTemplate(): string { + if (!previewTemplate) { + const read = (file: string) => + readFileSync(join(PREVIEW_DIR, file), "utf-8") + previewTemplate = read("index.html") + .replace("{{CSS}}", () => read("preview.css")) + .replace("{{SCRIPT}}", () => read("preview.js")) + } + return previewTemplate +} + +/** A JSON string literal that is safe inside a + + + \ No newline at end of file diff --git a/packages/mcp-server/src/preview/preview.css b/packages/mcp-server/src/preview/preview.css new file mode 100644 index 0000000..16e9598 --- /dev/null +++ b/packages/mcp-server/src/preview/preview.css @@ -0,0 +1,167 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap'); +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { width: 100%; height: 100%; overflow: hidden; } +#container { width: 100%; height: 100%; display: flex; flex-direction: column; } +#header { + padding: 0 20px; height: 52px; + background: linear-gradient(to bottom, #ffffff, #fafbfc); + border-bottom: 1px solid #e8ecf0; + font-family: 'DM Sans', system-ui, -apple-system, sans-serif; + display: flex; justify-content: space-between; align-items: center; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); + position: relative; z-index: 10; +} +#header .brand { + display: flex; align-items: center; gap: 10px; +} +#header .logo { + width: 28px; height: 28px; border-radius: 6px; + background: #18181b; + display: flex; align-items: center; justify-content: center; + overflow: hidden; +} +#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); } +#header .title { + font-size: 15px; font-weight: 600; color: #1a1a2e; + letter-spacing: -0.3px; +} +#header .session { + font-size: 11px; color: #8b95a5; font-weight: 400; + background: #f1f3f9; padding: 3px 8px; border-radius: 4px; + margin-left: 12px; font-family: 'SF Mono', Monaco, monospace; +} +#header .right { display: flex; align-items: center; gap: 12px; } +#save-btn { + display: flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: 8px; font-size: 13px; + background: linear-gradient(to bottom, #18181b, #27272a); + color: white; border: none; cursor: pointer; + font-weight: 500; font-family: inherit; + box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1); + transition: all 0.15s ease; +} +#save-btn svg { width: 14px; height: 14px; } +#save-btn:hover { + background: linear-gradient(to bottom, #27272a, #3f3f46); + transform: translateY(-1px); + box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1); +} +#save-btn:active { transform: translateY(0); } +#save-btn:disabled, #history-btn:disabled { + background: #e5e7eb; color: #9ca3af; + cursor: not-allowed; transform: none; box-shadow: none; +} +#history-btn { + display: flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: 8px; font-size: 13px; + background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7; + cursor: pointer; font-weight: 500; font-family: inherit; + transition: all 0.15s ease; +} +#history-btn svg { width: 14px; height: 14px; } +#history-btn:hover { + background: #e4e4e7; border-color: #d4d4d8; +} +#drawio { flex: 1; border: none; } +#history-modal, #save-modal { + display: none; position: fixed; inset: 0; + background: rgba(0,0,0,0.4); backdrop-filter: blur(4px); + z-index: 2000; align-items: center; justify-content: center; +} +#history-modal.open, #save-modal.open { display: flex; } +.modal-content { + background: white; border-radius: 16px; + width: 90%; max-width: 480px; max-height: 70vh; + display: flex; flex-direction: column; + box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); + font-family: 'DM Sans', system-ui, -apple-system, sans-serif; + animation: modalIn 0.2s ease-out; +} +@keyframes modalIn { + from { opacity: 0; transform: scale(0.95) translateY(-10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} +.modal-header { + padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5; +} +.modal-header h2 { + font-size: 17px; font-weight: 600; margin: 0; color: #18181b; + letter-spacing: -0.3px; +} +.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; } +.modal-footer { + padding: 16px 24px; border-top: 1px solid #f1f3f5; + display: flex; gap: 10px; justify-content: flex-end; +} +.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.history-item { + border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px; + cursor: pointer; text-align: center; transition: all 0.15s ease; + background: #fafafa; +} +.history-item:hover { border-color: #a1a1aa; background: white; } +.history-item.selected { + border-color: #18181b; background: white; + box-shadow: 0 0 0 3px rgba(24,24,27,0.1); +} +.history-item .thumb { + aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + margin-bottom: 6px; overflow: hidden; +} +.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; } +.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; } +.btn { + padding: 9px 18px; border-radius: 8px; font-size: 13px; + cursor: pointer; border: none; font-weight: 500; + font-family: inherit; transition: all 0.15s ease; +} +.btn-primary { + background: linear-gradient(to bottom, #18181b, #27272a); + color: white; + box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1); +} +.btn-primary:hover { + background: linear-gradient(to bottom, #27272a, #3f3f46); + transform: translateY(-1px); +} +.btn-primary:disabled { + background: #e4e4e7; color: #a1a1aa; + cursor: not-allowed; transform: none; box-shadow: none; +} +.btn-secondary { + background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7; +} +.btn-secondary:hover { background: #e4e4e7; } +.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; } +.form-group { margin-bottom: 18px; } +.form-group label { + display: block; font-size: 13px; font-weight: 500; + margin-bottom: 8px; color: #3f3f46; +} +.form-group select, .form-group input { + width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7; + border-radius: 8px; font-size: 14px; outline: none; + font-family: inherit; background: white; + transition: all 0.15s ease; +} +.form-group select:focus, .form-group input:focus { + border-color: #18181b; + box-shadow: 0 0 0 3px rgba(24,24,27,0.08); +} +.filename-group { display: flex; } +.filename-group input { border-radius: 8px 0 0 8px; border-right: none; } +#notice { + display: none; position: fixed; left: 50%; bottom: 24px; + transform: translateX(-50%); z-index: 3000; max-width: 480px; + padding: 10px 16px; border-radius: 8px; font-size: 13px; + background: #18181b; color: white; + font-family: 'DM Sans', system-ui, -apple-system, sans-serif; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); +} +#notice.open { display: block; } +.filename-group .ext { + padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7; + border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a; + font-family: 'SF Mono', Monaco, monospace; +} diff --git a/packages/mcp-server/src/preview/preview.js b/packages/mcp-server/src/preview/preview.js new file mode 100644 index 0000000..95307e2 --- /dev/null +++ b/packages/mcp-server/src/preview/preview.js @@ -0,0 +1,364 @@ +const iframe = document.getElementById('drawio'); +let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null; +let pendingSvgExport = null; +let pendingSvgBase = 0; // version the pending autosave was based on +let pendingAiSvg = false; +let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export +let mcpExportSeq = 0; // number of the latest MCP export +let projectionExportActive = false; // page-targeted export: showing a transient single-page projection +let forceReload = false; // reload the server state on the next poll even if the version is unchanged +let noticeTimer = null; + +window.addEventListener('message', (e) => { + if (e.origin !== DRAWIO_ORIGIN) return; + try { + const msg = JSON.parse(e.data); + if (msg.event === 'init') { + isReady = true; + if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; } + } else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) { + // Ignore autosave while a single-page projection is on screen + // for a page-targeted export — otherwise we'd push the + // transient projection back as the canonical session state. + if (projectionExportActive) return; + // Request SVG export, then push state with SVG. Remember the + // version this edit is based on, so the server can reject it + // if the AI wrote a newer version that is not loaded yet. + pendingSvgExport = msg.xml; + pendingSvgBase = currentVersion; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*'); + // Fallback if export doesn't respond + setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase); pendingSvgExport = null; } }, 2000); + } else if (msg.event === 'export' && msg.format === 'xml') { + // Sync export requested by the server (get_diagram). + // draw.io returns the XML in msg.xml, with no msg.data. + if (pendingSyncExport && msg.xml) { + pendingSyncExport = false; + // Push with the version the export was taken at: a + // newer AI write may have loaded meanwhile, and this + // older XML must not overwrite it. + pushState(msg.xml, '', pendingSyncBase, 'sync'); + } + } else if (msg.event === 'export' && msg.data) { + // Handle MCP server export request (png/svg). fireExport tags + // the request with mcpExport and draw.io echoes the request + // back in msg.message, which tells it apart from autosave and + // preview SVG exports. + if (msg.message && msg.message.mcpExport) { + // A late reply to an export that already timed out + if (msg.message.mcpExport !== mcpExportSeq) return; + const d = msg.data; + const isPng = pendingMcpExport === 'png' && d.startsWith('data:image/png'); + const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith(' {}); + // Page-targeted export: restore the user's real + // multi-page document now that we have the image. + restoreFromProjection(); + } + return; + } + // Handle file download export (PNG/SVG only, drawio uses + // lastXml directly). Tagged with dlExport like mcpExport, + // so an autosave SVG export can never be saved instead. + if (msg.message && msg.message.dlExport) { + if (!pendingDownload) return; + const dl = pendingDownload; + pendingDownload = null; + let dataUrl = msg.data; + if (!dataUrl.startsWith('data:')) { + const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml'; + dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data))); + } + const a = document.createElement('a'); + a.href = dataUrl; a.download = dl.filename; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + saveModal.classList.remove('open'); + saveConfirmBtn.disabled = false; + saveConfirmBtn.textContent = 'Save'; + return; + } + // Handle SVG export + let svg = msg.data; + if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); + if (pendingSvgExport) { + const xml = pendingSvgExport; + pendingSvgExport = null; + pushState(xml, svg, pendingSvgBase); + } else if (pendingAiSvg) { + pendingAiSvg = false; + fetch('/api/history-svg', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, svg }) + }).catch(() => {}); + } + } + } catch {} +}); + +function loadDiagram(xml, capturePreview = false) { + if (!isReady) { pendingXml = xml; return; } + lastXml = xml; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*'); + if (capturePreview) { + setTimeout(() => { + pendingAiSvg = true; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*'); + }, 500); + } +} + +// Restore the user's real document after a page-targeted projection +// export by reloading the server state. The server also has any +// autosave that was still in flight when the projection started, +// which a copy taken at that moment would miss. A flag is used +// because a push finishing meanwhile may update currentVersion. +function restoreFromProjection() { + if (!projectionExportActive) return; + projectionExportActive = false; + forceReload = true; + poll(); +} + +function showNotice(text) { + const el = document.getElementById('notice'); + el.textContent = text; + el.classList.add('open'); + clearTimeout(noticeTimer); + noticeTimer = setTimeout(() => el.classList.remove('open'), 8000); +} + +// source is 'sync' for replies to a server sync request, else 'edit' +async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit') { + if (!sessionId) return; + try { + const r = await fetch('/api/state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, xml, svg, baseVersion, source }) + }); + if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; } + // 409: the AI wrote a newer version; load it now + else if (r.status === 409) { + const d = await r.json().catch(() => ({})); + if (d.savedToHistory) showNotice('The AI changed the diagram while you were editing. Your last change was saved in History.'); + poll(); + } + } catch (e) { console.error('Push failed:', e); } +} + +let pendingSyncExport = false; +let pendingSyncBase = 0; // version the pending sync export was taken at + +async function poll() { + if (!sessionId) return; + const knownVersion = currentVersion; + try { + const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId)); + if (!r.ok) return; + const s = await r.json(); + // Handle sync request - server needs fresh state. Reset after a + // while in case draw.io never answers, so later syncs still run. + if (s.syncRequested && !pendingSyncExport && isReady) { + pendingSyncExport = true; + pendingSyncBase = currentVersion; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*'); + setTimeout(() => { pendingSyncExport = false; }, 5000); + } + // The server lost this session (e.g. it expired) and rebuilt it + // with a blank diagram: push back what the browser shows. + if (s.version < knownVersion && lastXml) { + pushState(lastXml); + } + // Load new diagram from server (before export, so we export latest). + // While a page-targeted projection is on screen, skip the reload + // so it doesn't fight the projection — and leave currentVersion + // unadvanced so this bump is re-detected and applied once the + // real document is restored. + if ((forceReload || s.version > currentVersion) && s.xml && !projectionExportActive) { + forceReload = false; + currentVersion = s.version; + loadDiagram(s.xml, true); + } + // Handle export request from MCP server (png/svg). + // + // Plain export: capture whatever tab is currently displayed. + // + // Page-targeted export: the server sends a single-page + // projection in s.exportXml. We load it into the iframe, let + // draw.io render it, export, then reload the user's real + // document — all browser-side. The canonical session state is + // never mutated, so there is no server-side restore race and no + // dependence on poll timing. autosave is suppressed while the + // projection is showing (see projectionExportActive guard). + if (s.exportFormat && !pendingMcpExport && isReady) { + pendingMcpExport = s.exportFormat; + const seq = ++mcpExportSeq; + const extra = s.exportOptions || {}; + const fireExport = () => { + // mcpExport carries this export's number and is echoed + // back in msg.message (see the handler). PNG: width + // caps the size, pageId picks a page; without one + // draw.io would use the first page. + const exportOpts = pendingMcpExport === 'png' + ? { action: 'export', format: 'png', scale: 2, currentPage: !extra.pageId, ...extra, mcpExport: seq } + : { action: 'export', format: 'svg', mcpExport: seq }; + iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*'); + }; + if (s.exportXml) { + projectionExportActive = true; + // Load the projection without touching lastXml/server state. + iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*'); + // Let draw.io render the loaded page before exporting + // (same proven settle delay as the AI-preview path). + setTimeout(fireExport, 600); + } else { + fireExport(); + } + // Timeout: reset if draw.io never responds, and restore the + // real document if a projection was left showing. Only for + // this export: a later one may be running by then. + setTimeout(() => { + if (pendingMcpExport && seq === mcpExportSeq) { + pendingMcpExport = null; + restoreFromProjection(); + } + }, 10000); + } + } catch {} +} + +if (sessionId) { poll(); setInterval(poll, 2000); } + +// Save modal +const saveBtn = document.getElementById('save-btn'); +const saveModal = document.getElementById('save-modal'); +const saveFormat = document.getElementById('save-format'); +const saveFilename = document.getElementById('save-filename'); +const saveExt = document.getElementById('save-ext'); +const saveCancelBtn = document.getElementById('save-cancel-btn'); +const saveConfirmBtn = document.getElementById('save-confirm-btn'); +let pendingDownload = null; + +const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' }; + +saveBtn.onclick = () => { + if (!sessionId || !isReady) return; + saveModal.classList.add('open'); + saveFilename.focus(); + saveFilename.select(); +}; + +saveFormat.onchange = () => { + saveExt.textContent = extMap[saveFormat.value] || '.drawio'; +}; + +saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); }; +saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); }; + +saveConfirmBtn.onclick = () => { + const format = saveFormat.value; + const filename = (saveFilename.value.trim() || 'diagram') + extMap[format]; + saveConfirmBtn.disabled = true; + saveConfirmBtn.textContent = 'Exporting...'; + + if (format === 'drawio') { + // Use lastXml directly instead of requesting export (avoids race with SVG exports). + // session.xml is canonically after the multi-page refactor, + // so no wrapper injection is needed. The legacy fallback below + // remains only for documents that somehow slipped past + // normalisation (e.g. an older session loaded from external state). + let xmlData = lastXml || ''; + if (xmlData && !xmlData.includes(''; + } + const blob = new Blob([xmlData], { type: 'application/xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + URL.revokeObjectURL(url); + saveModal.classList.remove('open'); + saveConfirmBtn.disabled = false; + saveConfirmBtn.textContent = 'Save'; + } else if (format === 'png') { + pendingDownload = { format: 'png', filename }; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2, currentPage: true, dlExport: true }), '*'); + setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000); + } else if (format === 'svg') { + pendingDownload = { format: 'svg', filename }; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg', dlExport: true }), '*'); + setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000); + } +}; + +// History UI +const historyBtn = document.getElementById('history-btn'); +const historyModal = document.getElementById('history-modal'); +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; + +historyBtn.onclick = async () => { + if (!sessionId) return; + try { + const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId)); + if (r.ok) { + const d = await r.json(); + historyData = d.entries || []; + renderHistory(); + } + } catch {} + historyModal.classList.add('open'); +}; + +cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedId = null; restoreBtn.disabled = true; }; +historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); }; + +function renderHistory() { + if (historyData.length === 0) { + historyGrid.style.display = 'none'; + historyEmpty.style.display = 'block'; + return; + } + historyGrid.style.display = 'grid'; + historyEmpty.style.display = 'none'; + historyGrid.innerHTML = historyData.map((e, i) => ` +
+
${e.svg ? `` : '#' + e.index}
+
#${e.index}
+
+ `).join(''); + historyGrid.querySelectorAll('.history-item').forEach(item => { + item.onclick = () => { + const id = parseInt(item.dataset.id); + if (selectedId === id) { selectedId = null; restoreBtn.disabled = true; } + else { selectedId = id; restoreBtn.disabled = false; } + historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.id) === selectedId)); + }; + }); +} + +restoreBtn.onclick = async () => { + if (selectedId === null) return; + restoreBtn.disabled = true; + restoreBtn.textContent = 'Restoring...'; + try { + const r = await fetch('/api/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, id: selectedId }) + }); + if (r.ok) { cancelBtn.onclick(); await poll(); } + else { alert('Restore failed'); } + } catch { alert('Restore failed'); } + restoreBtn.textContent = 'Restore'; +}; diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts index af0a179..12fbfb2 100644 --- a/packages/mcp-server/tests/http-server.test.ts +++ b/packages/mcp-server/tests/http-server.test.ts @@ -254,11 +254,15 @@ describe("export requests", () => { }) describe("preview page", () => { - it("serves a script that parses", async () => { + it("serves scripts that parse, with every placeholder filled", async () => { const res = await request("/?mcp=mcp-test-script") - const script = res.body.match(/