diff --git a/src/viewer.ts b/src/viewer.ts index 42138b6..e4cd1c5 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -1,5 +1,5 @@ import { createServer, type Server } from 'node:http'; -import { getCanvas, listCanvases } from './scene-graph.js'; +import { getCanvas, listCanvases, archiveCanvas, unarchiveCanvas, deleteCanvas } from './scene-graph.js'; import { resolveVariables } from './variables.js'; import { renderToHtml } from './renderer.js'; import { getProject, listProjects, listWorkspaces } from './workspaces.js'; @@ -119,6 +119,42 @@ export async function startViewer(port: number): Promise { return; } + // Archive page + if (path === '/archive') { + res.setHeader('Content-Type', 'text/html'); + res.end(renderArchivePage(runningPort ?? 3001)); + return; + } + + // Lifecycle API: archive / unarchive / delete a canvas. Three small + // wrappers around the scene-graph functions so the viewer's action + // buttons (archive page + detail page) don't need to round-trip + // through an MCP client. + const archiveApi = path.match(/^\/api\/canvas\/([^/]+)\/archive$/); + if (archiveApi && req.method === 'POST') { + const result = archiveCanvas(archiveApi[1]); + if (!result) { res.writeHead(404); res.end('Not found'); return; } + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true, canvasId: archiveApi[1], archived: true })); + return; + } + const unarchiveApi = path.match(/^\/api\/canvas\/([^/]+)\/unarchive$/); + if (unarchiveApi && req.method === 'POST') { + const result = unarchiveCanvas(unarchiveApi[1]); + if (!result) { res.writeHead(404); res.end('Not found'); return; } + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true, canvasId: unarchiveApi[1], archived: false })); + return; + } + const deleteApi = path.match(/^\/api\/canvas\/([^/]+)$/); + if (deleteApi && req.method === 'DELETE') { + if (!getCanvas(deleteApi[1])) { res.writeHead(404); res.end('Not found'); return; } + deleteCanvas(deleteApi[1]); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true, canvasId: deleteApi[1], deleted: true })); + return; + } + // Index → default project if (path === '/') { res.writeHead(302, { Location: `/project/${DEFAULT_PROJECT_ID}` }); @@ -164,13 +200,15 @@ export async function startViewer(port: number): Promise { return actualPort; } -/** Renders the Figma-style left sidebar: workspaces → projects tree, with - * canvas-count badges and an active-state highlight on the current project. - * Workspaces always expanded for v1 — collapse logic can come later. */ -function renderSidebar(activeProjectId: string): string { +/** Renders the Figma-style left sidebar: workspaces → projects tree + Archive + * entry, with canvas-count badges and an active-state highlight on whatever + * the user is currently viewing. Pass `activeProjectId` for a project view, + * or `'archive'` for the archive view. */ +function renderSidebar(active: string): string { const allCanvases = listCanvases(); const projectCount = (projectId: string) => allCanvases.filter((c) => c.projectId === projectId && !c.archived).length; + const archivedCount = allCanvases.filter((c) => c.archived).length; const sections = listWorkspaces().map((ws) => { const wsProjects = listProjects(ws.id); @@ -181,7 +219,7 @@ function renderSidebar(activeProjectId: string): string { `; } const items = wsProjects.map((p) => { - const isActive = p.id === activeProjectId; + const isActive = p.id === active; return ` ${esc(p.name)} ${projectCount(p.id)} @@ -193,11 +231,23 @@ function renderSidebar(activeProjectId: string): string { `; }).join(''); + const archiveActive = active === 'archive'; + const archiveLink = ` + + + + + + Archive + ${archivedCount} + `; + return ``; } @@ -270,6 +320,16 @@ export function renderProjectPage(projectId: string, port: number): string | nul .project-count { font-size: 11px; color: #666; flex-shrink: 0; margin-left: 8px; } .project.active .project-count { color: #93c5fd; } + /* Sidebar footer: archive entry. Sits below all workspaces, separated by a hairline. */ + .sidebar-footer { padding: 12px 8px; border-top: 1px solid #1a1a1a; } + .sidebar-archive { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; color: #aaa; text-decoration: none; font-size: 13px; transition: background 0.15s, color 0.15s; } + .sidebar-archive:hover { background: #181818; color: #fff; } + .sidebar-archive.active { background: #1e3a5f; color: #fff; } + .sidebar-archive-icon { width: 16px; height: 16px; flex-shrink: 0; } + .sidebar-archive-name { flex: 1; } + .sidebar-archive-count { font-size: 11px; color: #666; } + .sidebar-archive.active .sidebar-archive-count { color: #93c5fd; } + /* Main pane */ .main { flex: 1; min-width: 0; } .main-header { padding: 24px 32px; border-bottom: 1px solid #1a1a1a; } @@ -332,6 +392,155 @@ export function renderGalleryPage(port: number): string { return renderProjectPage(DEFAULT_PROJECT_ID, port) ?? '

No projects found

'; } +/** Archive view: shows all archived canvases across every project. Each card + * includes the source-project name (since archive is cross-project) and + * inline Restore / Delete actions that call the JSON API. */ +export function renderArchivePage(port: number): string { + const archived = listCanvases().filter((c) => c.archived); + const projectName = (projectId: string) => getProject(projectId)?.name ?? '—'; + + const cards = archived.map((c) => { + const canvas = getCanvas(c.id)!; + const w = typeof canvas.root.width === 'number' ? canvas.root.width : 1440; + const h = typeof canvas.root.height === 'number' ? canvas.root.height : 900; + const archivedDate = c.lastModified ? new Date(c.lastModified).toLocaleString() : ''; + const isEmpty = !canvas.root.children || canvas.root.children.length === 0; + const thumbBody = isEmpty + ? `
+ + + + Empty canvas +
` + : ``; + return ` + `; + }).join('\n'); + + const emptyState = archived.length === 0 + ? `
+
+
No archived canvases
+
Archive a canvas with canvas_archive({ canvasId }) or the Archive button on a canvas page.
+
` + : ''; + + return ` + + + +Archive — Canvas MCP + + + +
+ ${renderSidebar('archive')} +
+
+ +

Archived canvases

+
${archived.length} canvas${archived.length !== 1 ? 'es' : ''} across all projects
+
+ ${archived.length > 0 ? `
${cards}
` : emptyState} +
+
+ + +`; +} + export function renderDetailPage(canvas: Canvas, port: number): string { const w = typeof canvas.root.width === 'number' ? canvas.root.width : 1440; const h = typeof canvas.root.height === 'number' ? canvas.root.height : 900; @@ -353,6 +562,7 @@ export function renderDetailPage(canvas: Canvas, port: number): string { .toolbar .btn { background: #1a1a1a; border: 1px solid #333; color: #ccc; padding: 6px 14px; border-radius: 6px; font-size: 12px; cursor: pointer; font-family: inherit; } .toolbar .btn:hover { background: #222; color: #fff; } .toolbar .btn.active { background: #3b82f6; border-color: #3b82f6; color: #fff; } + .toolbar .btn--danger:hover { background: #7f1d1d; border-color: #ef4444; color: #fff; } .status { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; flex-shrink: 0; } .status.stale { background: #555; } .viewport { flex: 1; display: flex; align-items: flex-start; justify-content: center; overflow: auto; background: #0a0a0a; padding: 24px 0; } @@ -393,6 +603,11 @@ export function renderDetailPage(canvas: Canvas, port: number): string { + ${canvas.archived + ? `` + : `` + } +
@@ -512,6 +727,23 @@ export function renderDetailPage(canvas: Canvas, port: number): string { document.getElementById('json-content').textContent = 'Error loading JSON'; } } + + // Archive / unarchive / delete. Delete is irreversible — confirm first. + // After action: archive / unarchive bounce back to the project page; + // delete bounces too (the canvas is gone, no point staying on its page). + async function lifecycleAction(action) { + if (action === 'delete' && !confirm('Permanently delete this canvas? This cannot be undone.')) return; + const projectId = ${JSON.stringify(canvas.projectId)}; + try { + const url = '/api/canvas/' + canvasId + (action === 'archive' ? '/archive' : action === 'unarchive' ? '/unarchive' : ''); + const method = action === 'delete' ? 'DELETE' : 'POST'; + const res = await fetch(url, { method }); + if (!res.ok) throw new Error('Request failed: ' + res.status); + location.href = '/project/' + projectId; + } catch (err) { + alert('Action failed: ' + err.message); + } + } `; diff --git a/test-archive-view.ts b/test-archive-view.ts new file mode 100644 index 0000000..9cdb1bb --- /dev/null +++ b/test-archive-view.ts @@ -0,0 +1,117 @@ +// Smoke for Phase 7 slice 4b: archive surface (route + sidebar entry + +// per-card restore/delete) and detail-page lifecycle buttons. Drives the +// underlying functions directly — the API endpoints are thin wrappers over +// scene-graph.archiveCanvas / unarchiveCanvas / deleteCanvas, already +// covered by test-workspace-mcp-tools.ts. +// +// Usage: npx tsx test-archive-view.ts + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const tmp = mkdtempSync(join(tmpdir(), 'canvas-mcp-test-')); +process.env.CANVAS_MCP_HOME = tmp; + +const ws = await import('./src/workspaces.js'); +const sg = await import('./src/scene-graph.js'); +const viewer = await import('./src/viewer.js'); +const { DEFAULT_PROJECT_ID } = await import('./src/types.js'); + +ws.loadPersistedWorkspaces(); +ws.ensureDefaultWorkspaceAndProject(); +sg.loadPersistedCanvases(); + +let allPass = true; +function check(name: string, cond: boolean, extra?: string) { + if (!cond) allPass = false; + console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ` — ${extra}` : ''}`); +} + +// Build state: one active canvas, two archived from two different projects +const acme = ws.createWorkspace('Acme'); +const brand = ws.createProject(acme.id, 'Brand')!; + +const active = sg.createCanvas('still-around', DEFAULT_PROJECT_ID); +const archivedA = sg.createCanvas('to-archive-a', DEFAULT_PROJECT_ID); +const archivedB = sg.createCanvas('to-archive-b', brand.id); +sg.archiveCanvas(archivedA.id); +sg.archiveCanvas(archivedB.id); + +// ---- 1. Sidebar Archive entry on a project page ------------------------ +{ + const html = viewer.renderProjectPage(DEFAULT_PROJECT_ID, 3001)!; + check('project page: sidebar contains Archive link', html.includes('class="sidebar-archive')); + check('project page: Archive link points to /archive', html.includes('href="/archive"')); + check('project page: Archive count = 2 (cross-project total)', + /class="sidebar-archive-count">2<\/span>/.test(html)); + check('project page: Archive entry is NOT active', !/class="sidebar-archive active"/.test(html)); +} + +// ---- 2. Archive page renders the right canvases ----------------------- +{ + const html = viewer.renderArchivePage(3001); + check('archive: title shows "Archived canvases"', + /

Archived canvases<\/h1>/.test(html)); + check('archive: meta shows count across all projects', html.includes('2 canvases across all projects')); + check('archive: Archive sidebar entry IS active', /class="sidebar-archive active"/.test(html)); + + check('archive: contains archivedA card', html.includes(`/canvas/${archivedA.id}`)); + check('archive: contains archivedB card', html.includes(`/canvas/${archivedB.id}`)); + check('archive: does NOT contain active canvas card', !html.includes(`/canvas/${active.id}`)); + + // Each archived card shows its source project name + check('archive: archivedA card shows source project "Untitled"', html.includes('Untitled ·')); + check('archive: archivedB card shows source project "Brand"', html.includes('Brand ·')); + + // Restore + Delete buttons per card + const restoreBtns = (html.match(/data-action="restore"/g) ?? []).length; + const deleteBtns = (html.match(/data-action="delete"/g) ?? []).length; + check('archive: each archived canvas has a Restore button', restoreBtns === 2); + check('archive: each archived canvas has a Delete button', deleteBtns === 2); +} + +// ---- 3. Archive page empty state -------------------------------------- +{ + // Unarchive both → archive should be empty + sg.unarchiveCanvas(archivedA.id); + sg.unarchiveCanvas(archivedB.id); + const html = viewer.renderArchivePage(3001); + check('archive empty: title still "Archived canvases"', + /

Archived canvases<\/h1>/.test(html)); + check('archive empty: meta shows 0 canvases', html.includes('0 canvases across all projects')); + check('archive empty: empty-state message present', html.includes('No archived canvases')); + check('archive empty: empty-state hint references canvas_archive', html.includes('canvas_archive')); + // Re-archive for the next checks + sg.archiveCanvas(archivedA.id); +} + +// ---- 4. Detail page lifecycle buttons --------------------------------- +{ + // Non-archived canvas → Archive button + Delete button, NO Restore button + const detailActive = viewer.renderDetailPage(sg.getCanvas(active.id)!, 3001); + check('detail (active): Archive button present', detailActive.includes('id="btn-archive"')); + check('detail (active): Restore button absent', !detailActive.includes('id="btn-restore"')); + check('detail (active): Delete button present', detailActive.includes('id="btn-delete"')); + check('detail (active): lifecycleAction handler defined', detailActive.includes('async function lifecycleAction')); + check('detail (active): "Back" goes to canvas\'s project', + detailActive.includes(`/project/${DEFAULT_PROJECT_ID}`)); + + // Archived canvas → Restore button instead of Archive + const detailArchived = viewer.renderDetailPage(sg.getCanvas(archivedA.id)!, 3001); + check('detail (archived): Restore button present', detailArchived.includes('id="btn-restore"')); + check('detail (archived): Archive button absent', !detailArchived.includes('id="btn-archive"')); + check('detail (archived): Delete button still present', detailArchived.includes('id="btn-delete"')); +} + +// ---- 5. Counts are dynamic --------------------------------------------- +{ + // Delete the archived canvas, archive count should drop in the sidebar. + sg.deleteCanvas(archivedA.id); + const html = viewer.renderProjectPage(DEFAULT_PROJECT_ID, 3001)!; + check('after delete: sidebar archive count drops to 0', + /class="sidebar-archive-count">0<\/span>/.test(html)); +} + +rmSync(tmp, { recursive: true, force: true }); +process.exit(allPass ? 0 : 1);