From 87071f210957248bc3ae1ea316f8d786728d8058 Mon Sep 17 00:00:00 2001 From: Delqhi Date: Sat, 13 Jun 2026 01:36:19 +0200 Subject: [PATCH 1/2] fix: #59 NFT warning (4/5 fixes), #60 undo/redo shortcuts, #62 api 401 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #62 (P1) — 401 on all /api/* routes: - proxy.ts: treat placeholder BETTER_AUTH_SECRET as 'auth disabled' (dev stays anonymous), add /api/health to PUBLIC_PATHS (Docker healthcheck must work) - lib/auth/better-auth.ts: trust both localhost:3000 and :3100 origins (port mapping breaks Better Auth baseURL) - app/api/health/route.ts: force-dynamic, runtime nodejs (no caching) #59 (P2) — Turbopack NFT warnings (3 → 1 remaining): Extract all dynamic fs/path/cwd into separately-imported modules so the NFT tracer never sees them at route boundaries: - lib/workspace/design-edit-fs.ts (from design-edit route) - lib/workspace/files-fs.ts (from files route) - lib/workspace/design-history-fs.ts (from design-history re-export) - lib/audit-fs.ts (from audit re-export) - lib/sin/orchestrator-stream-impl.ts (from orchestrator-stream wrapper) - lib/sin/orchestrator-runner.ts: DELETED (orphan, replaced by orchestrator-stream) - next.config.mjs: extended outputFileTracingExcludes for all new -fs files #60 (P2) — ⌘Z / ⌘⇧Z in Design Mode: - public/design-mode-agent.js: keydown listener posts design-undo / design-redo to parent (gated by 'enabled' flag, ignores editable fields) - components/workspace/preview-panel.tsx: postMessage listener calls /api/workspace/design-history POST, shows toast feedback - components/workspace/workspace-header.tsx: ⌘Z / ⌘⇧Z hint icon in Design tab #61 (P1) — CI / branch protection: verification-only, no code change. Known limitation: 1 NFT warning remains in lib/sin/* chunk (output asset trace from execFile usage in lib/sin/run.ts). Next.js 16.2.6 limitation — the NFT tracer still flags transitive node:child_process imports even behind two layers of dynamic imports. Build succeeds, runtime works. Refs: #51-#55 (closed), #59 #60 #61 #62 --- app/api/chat/route.ts | 2 +- app/api/chats/[id]/export/route.ts | 2 +- app/api/chats/[id]/route.ts | 2 +- app/api/chats/[id]/share/route.ts | 2 +- app/api/chats/route.ts | 2 +- app/api/health/route.ts | 3 + app/api/settings/activity/route.ts | 2 +- app/api/settings/api-keys/route.ts | 2 +- app/api/settings/files/route.ts | 2 +- app/api/settings/mcp/route.ts | 2 +- app/api/settings/members/route.ts | 2 +- app/api/settings/preferences/route.ts | 2 +- app/api/settings/workspace/route.ts | 2 +- app/api/sin/orchestrator/stream/route.ts | 83 ++------------- app/api/workspace/deploy/route.ts | 2 +- app/api/workspace/deployments/route.ts | 2 +- app/api/workspace/design-edit/route.ts | 80 +++------------ app/api/workspace/files/route.ts | 67 ++---------- app/api/workspaces/route.ts | 2 +- components/workspace/preview-panel.tsx | 51 +++++++++- components/workspace/workspace-header.tsx | 10 ++ lib/audit-fs.ts | 90 +++++++++++++++++ lib/audit.ts | 79 +++------------ lib/auth/better-auth.ts | 7 ++ lib/sin/guard.ts | 30 ++++++ lib/sin/orchestrator-runner.ts | 27 ----- lib/sin/orchestrator-stream-impl.ts | 76 ++++++++++++++ lib/sin/orchestrator-stream.ts | 20 ++++ lib/sin/run.ts | 29 +----- lib/workspace/design-edit-fs.ts | 78 ++++++++++++++ lib/workspace/design-history-fs.ts | 118 ++++++++++++++++++++++ lib/workspace/design-history.ts | 109 +++----------------- lib/workspace/files-fs.ts | 63 ++++++++++++ next.config.mjs | 15 ++- proxy.ts | 12 ++- public/design-mode-agent.js | 26 +++++ 36 files changed, 670 insertions(+), 433 deletions(-) create mode 100644 lib/audit-fs.ts create mode 100644 lib/sin/guard.ts delete mode 100644 lib/sin/orchestrator-runner.ts create mode 100644 lib/sin/orchestrator-stream-impl.ts create mode 100644 lib/sin/orchestrator-stream.ts create mode 100644 lib/workspace/design-edit-fs.ts create mode 100644 lib/workspace/design-history-fs.ts create mode 100644 lib/workspace/files-fs.ts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 342ef58..56e8d6f 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -13,7 +13,7 @@ import { buildAgentContext } from '@/lib/settings/agent-context' import { agentPrompt } from '@/lib/sin/agents' import { getSinTools } from '@/lib/sin/mcp' import { resolveModel } from '@/lib/sin/models' -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' import { SIN_CODE_INSTALL_CMD, SIN_MCP_TOOLS } from '@/lib/sin/tools' import { getSession } from '@/lib/session' import { getWorkspace, BUILT_IN_WORKSPACES } from '@/lib/workspaces' diff --git a/app/api/chats/[id]/export/route.ts b/app/api/chats/[id]/export/route.ts index b46116c..816a6c2 100644 --- a/app/api/chats/[id]/export/route.ts +++ b/app/api/chats/[id]/export/route.ts @@ -5,7 +5,7 @@ */ import { chatToMarkdown } from '@/lib/chat-export' import { isValidChatId, listChats, loadMessages } from '@/lib/storage' -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' export async function GET( req: Request, diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index 69d3d89..60bfa6d 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -11,7 +11,7 @@ import { loadMessages, saveMessages, } from '@/lib/storage' -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' type Params = { params: Promise<{ id: string }> } diff --git a/app/api/chats/[id]/share/route.ts b/app/api/chats/[id]/share/route.ts index 98203af..ed8723f 100644 --- a/app/api/chats/[id]/share/route.ts +++ b/app/api/chats/[id]/share/route.ts @@ -4,7 +4,7 @@ * POST /api/chats/[id]/share — create share, returns public URL slug * DELETE /api/chats/[id]/share — revoke share */ -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' import { getSession } from '@/lib/session' import { getShareByChatId, shareChat, unshareChat } from '@/lib/shares' import { isValidChatId, ownsChat } from '@/lib/storage' diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 9cce538..0b0504b 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -1,5 +1,5 @@ import { isValidChatId, listChats, upsertChatMeta } from '@/lib/storage' -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' import { getSession } from '@/lib/session' export async function GET(req: Request) { diff --git a/app/api/health/route.ts b/app/api/health/route.ts index b295c40..68212d4 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -4,6 +4,9 @@ */ import { isDbConfigured, getPool } from '@/lib/db' +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + export async function GET() { let db: 'ok' | 'error' | 'file' = 'file' if (isDbConfigured()) { diff --git a/app/api/settings/activity/route.ts b/app/api/settings/activity/route.ts index d9e504a..1bbd647 100644 --- a/app/api/settings/activity/route.ts +++ b/app/api/settings/activity/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { readActivity, summarize } from "@/lib/settings/activity" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" export async function GET(req: Request) { diff --git a/app/api/settings/api-keys/route.ts b/app/api/settings/api-keys/route.ts index 193d761..07cdcc4 100644 --- a/app/api/settings/api-keys/route.ts +++ b/app/api/settings/api-keys/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { listApiKeys, createApiKey, revokeApiKey } from "@/lib/settings/api-keys" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" export async function GET(req: Request) { diff --git a/app/api/settings/files/route.ts b/app/api/settings/files/route.ts index 928df8c..d3819b4 100644 --- a/app/api/settings/files/route.ts +++ b/app/api/settings/files/route.ts @@ -6,7 +6,7 @@ import { deleteFile, type Scope, } from "@/lib/settings/store" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" type Kind = "memories" | "skills" diff --git a/app/api/settings/mcp/route.ts b/app/api/settings/mcp/route.ts index 5dc319c..9be4f1b 100644 --- a/app/api/settings/mcp/route.ts +++ b/app/api/settings/mcp/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server" import { promises as fs } from "fs" import path from "path" import crypto from "crypto" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" let _base: string | null = null diff --git a/app/api/settings/members/route.ts b/app/api/settings/members/route.ts index 392fb95..8d4fc06 100644 --- a/app/api/settings/members/route.ts +++ b/app/api/settings/members/route.ts @@ -6,7 +6,7 @@ import { NextResponse } from "next/server" import { getPool } from "@/lib/db" import { isBetterAuthEnabled } from "@/lib/auth/better-auth" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" async function requireAdmin(req: Request): Promise { diff --git a/app/api/settings/preferences/route.ts b/app/api/settings/preferences/route.ts index 03a2aeb..955b0ea 100644 --- a/app/api/settings/preferences/route.ts +++ b/app/api/settings/preferences/route.ts @@ -4,7 +4,7 @@ import { writePreferences, DEFAULT_PREFERENCES, } from "@/lib/settings/store" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" export async function GET(req: Request) { diff --git a/app/api/settings/workspace/route.ts b/app/api/settings/workspace/route.ts index 590386d..30be322 100644 --- a/app/api/settings/workspace/route.ts +++ b/app/api/settings/workspace/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server" import { promises as fs } from "fs" import path from "path" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" let _base: string | null = null diff --git a/app/api/sin/orchestrator/stream/route.ts b/app/api/sin/orchestrator/stream/route.ts index 34b5e58..8e2442a 100644 --- a/app/api/sin/orchestrator/stream/route.ts +++ b/app/api/sin/orchestrator/stream/route.ts @@ -3,19 +3,19 @@ * GET /api/sin/orchestrator/stream?task=… * Emits: event "line" per stdout line, event "done" with exit code, * event "error" on failure. + * + * The actual spawn() lives in lib/sin/orchestrator-stream.ts and is + * dynamically imported so the NFT tracer never sees child_process at + * the route boundary (#59 / #60). */ -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' -export const dynamic = "force-dynamic" -export const runtime = "nodejs" +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' export const maxDuration = 300 const SAFE_TOKEN = /^[\w@./:=,\- ?!]{1,512}$/ -function sse(event: string, data: unknown): string { - return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` -} - export async function GET(req: Request) { const guard = await guardRequest(req, 'orchestrator-stream', 3, 60_000) if (guard) return guard @@ -25,71 +25,6 @@ export async function GET(req: Request) { return Response.json({ ok: false, error: 'invalid task' }, { status: 400 }) } - const encoder = new TextEncoder() - const bin = process.env.SIN_CODE_BIN || 'sin-code' - const { spawn } = await import('node:child_process') - - const stream = new ReadableStream({ - start(controller) { - const child = spawn(bin, ['orchestrator-run', task], { - timeout: 280_000, - }) - - let buffer = '' - const pushLines = (chunk: Buffer) => { - buffer += chunk.toString() - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' - for (const line of lines) { - if (line.trim()) { - controller.enqueue(encoder.encode(sse('line', { line }))) - } - } - } - - child.stdout.on('data', pushLines) - child.stderr.on('data', pushLines) - - child.on('error', (err) => { - const e = err as NodeJS.ErrnoException - controller.enqueue( - encoder.encode( - sse('error', { - error: - e.code === 'ENOENT' - ? 'sin-code binary not installed' - : e.message, - }), - ), - ) - controller.close() - }) - - child.on('close', (code) => { - if (buffer.trim()) { - controller.enqueue(encoder.encode(sse('line', { line: buffer }))) - } - controller.enqueue(encoder.encode(sse('done', { exitCode: code }))) - controller.close() - }) - - req.signal.addEventListener('abort', () => { - child.kill('SIGTERM') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - }, - }) -} - -export async function POST(req: Request) { - const { task } = await req.json() - const { runOrchestratorStream } = await import("@/lib/sin/orchestrator-runner") - return runOrchestratorStream(task) + const { runOrchestratorStream } = await import('@/lib/sin/orchestrator-stream') + return await runOrchestratorStream(task, req.signal) } diff --git a/app/api/workspace/deploy/route.ts b/app/api/workspace/deploy/route.ts index d7ec113..5c7aebb 100644 --- a/app/api/workspace/deploy/route.ts +++ b/app/api/workspace/deploy/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server" import { isVercelConfigured } from "@/lib/vercel/client" import { createDeployment, getDeploymentStatus } from "@/lib/vercel/deploy" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" export const maxDuration = 300 diff --git a/app/api/workspace/deployments/route.ts b/app/api/workspace/deployments/route.ts index 179800a..97aa7cd 100644 --- a/app/api/workspace/deployments/route.ts +++ b/app/api/workspace/deployments/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { listDeployments } from "@/lib/vercel/deploy" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" export async function GET(req: Request) { const guard = await guardRequest(req, "deploy", 60) diff --git a/app/api/workspace/design-edit/route.ts b/app/api/workspace/design-edit/route.ts index c80ad15..70814a6 100644 --- a/app/api/workspace/design-edit/route.ts +++ b/app/api/workspace/design-edit/route.ts @@ -1,80 +1,28 @@ -import { NextResponse } from "next/server" -import { promises as fs } from "fs" -import path from "path" -import { pushEntry } from "@/lib/workspace/design-history" +import { NextResponse } from 'next/server' -let _root: string | null = null -// @turbopack-disable-next-line -function root(): string { - if (!_root) _root = /*turbopackIgnore: true*/ (process.env.SIN_WORKSPACE_DIR ?? process.cwd()) - return _root -} - -function safeResolve(rel: string): string { - const resolved = /*turbopackIgnore: true*/ path.resolve(root(), "." + path.sep + rel) - if (!resolved.startsWith(root())) throw new Error("Invalid path") - return resolved -} +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' export async function POST(req: Request) { const { loc, oldClasses, newClasses } = await req.json() - if (typeof oldClasses !== "string" || typeof newClasses !== "string") { - return NextResponse.json({ error: "oldClasses and newClasses required" }, { status: 400 }) + if (typeof oldClasses !== 'string' || typeof newClasses !== 'string') { + return NextResponse.json({ error: 'oldClasses and newClasses required' }, { status: 400 }) } - if (typeof loc !== "string" || !loc.includes(":")) { + if (typeof loc !== 'string' || !loc.includes(':')) { return NextResponse.json( - { error: "loc required (file:line). Add data-sin-loc attributes to your dev build." }, + { error: 'loc required (file:line). Add data-sin-loc attributes to your dev build.' }, { status: 400 }, ) } - const [file, lineStr] = loc.split(":") - const lineNo = parseInt(lineStr, 10) + // Dynamic import keeps fs/path/cwd out of the route boundary the NFT + // tracer inspects — this is what finally clears the warning (#59). + const { applyClassEdit } = await import('@/lib/workspace/design-edit-fs') + const result = await applyClassEdit(loc, oldClasses, newClasses) - let abs: string - try { - abs = safeResolve(file) - } catch { - return NextResponse.json({ error: "Invalid path" }, { status: 400 }) + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }) } - - let content: string - try { - content = await /*turbopackIgnore: true*/ fs.readFile(abs, "utf8") - } catch { - return NextResponse.json({ error: "File not found" }, { status: 404 }) - } - - const lines = content.split("\n") - const from = Math.max(0, lineNo - 3) - const to = Math.min(lines.length, lineNo + 5) - let patched = false - - for (let i = from; i < to; i++) { - if (lines[i].includes(oldClasses)) { - lines[i] = lines[i].replace(oldClasses, newClasses) - patched = true - break - } - } - - if (!patched) { - return NextResponse.json( - { error: "Could not locate the class string near the reported line." }, - { status: 409 }, - ) - } - - await /*turbopackIgnore: true*/ fs.writeFile(abs, lines.join("\n"), "utf8") - const relativeFilePath = file - const tagName = "div" // or get from the request context - await pushEntry({ - file: relativeFilePath, - line: lineNo, - oldValue: oldClasses, - newValue: newClasses, - description: `Changed ${tagName} class to '${newClasses.slice(0, 60)}'`, - }) - return NextResponse.json({ ok: true, file, line: lineNo }) + return NextResponse.json({ ok: true, file: result.file, line: result.line }) } diff --git a/app/api/workspace/files/route.ts b/app/api/workspace/files/route.ts index 76bb997..dfd4c8a 100644 --- a/app/api/workspace/files/route.ts +++ b/app/api/workspace/files/route.ts @@ -1,69 +1,24 @@ -import { NextResponse } from "next/server" -import { promises as fs } from "fs" -import path from "path" +import { NextResponse } from 'next/server' -let _root: string | null = null -// @turbopack-disable-next-line -function root(): string { - if (!_root) _root = process.env.SIN_WORKSPACE_DIR ?? (/*turbopackIgnore: true*/ process.cwd()) - return _root -} - -const IGNORE = new Set(["node_modules", ".git", ".next", ".sin-webui", "dist"]) -const MAX_FILE_SIZE = 512 * 1024 - -interface TreeNode { - name: string - path: string - type: "file" | "dir" - children?: TreeNode[] -} - -function safeResolve(rel: string): string { - const resolved = /*turbopackIgnore: true*/ path.resolve(root(), "." + path.sep + rel) - if (!resolved.startsWith(root())) throw new Error("Invalid path") - return resolved -} - -async function buildTree(dir: string, relBase = ""): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }) - const nodes: TreeNode[] = [] - for (const entry of entries) { - if (IGNORE.has(entry.name)) continue - const relPath = relBase ? `${relBase}/${entry.name}` : entry.name - if (entry.isDirectory()) { - nodes.push({ - name: entry.name, - path: relPath, - type: "dir", - children: await buildTree(/*turbopackIgnore: true*/ path.join(dir, entry.name), relPath), - }) - } else { - nodes.push({ name: entry.name, path: relPath, type: "file" }) - } - } - return nodes.sort((a, b) => - a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1, - ) -} +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' export async function GET(req: Request) { const { searchParams } = new URL(req.url) - const filePath = searchParams.get("path") + const filePath = searchParams.get('path') + + // Dynamic import keeps fs/path/cwd out of the route boundary the NFT + // tracer inspects (#59 / #60). + const { readWorkspaceFile, listWorkspaceTree } = await import('@/lib/workspace/files-fs') if (filePath) { try { - const abs = safeResolve(filePath) - const stat = await fs.stat(abs) - if (stat.size > MAX_FILE_SIZE) { - return NextResponse.json({ content: "// File too large to display" }) - } - const content = await fs.readFile(abs, "utf8") + const content = await readWorkspaceFile(filePath) return NextResponse.json({ content }) } catch { - return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) } } - return NextResponse.json({ nodes: await buildTree(root()) }) + return NextResponse.json({ nodes: await listWorkspaceTree() }) } diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index c3b074a..033addf 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -4,7 +4,7 @@ * POST /api/workspaces {…} — create/update a custom workspace * DELETE /api/workspaces { id } — delete own custom workspace */ -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' import { getSession } from '@/lib/session' import { deleteCustomWorkspace, diff --git a/components/workspace/preview-panel.tsx b/components/workspace/preview-panel.tsx index 204449e..bffb1b4 100644 --- a/components/workspace/preview-panel.tsx +++ b/components/workspace/preview-panel.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useRef, useEffect } from "react" +import { useState, useRef, useEffect, useCallback } from "react" import { ChevronLeft, ChevronRight, @@ -14,10 +14,19 @@ export function PreviewPanel({ src }: { src: string }) { const [path, setPath] = useState("/") const [mobile, setMobile] = useState(false) const [reloadKey, setReloadKey] = useState(0) + const [toast, setToast] = useState(null) const iframeRef = useRef(null) + const toastTimer = useRef | null>(null) const fullUrl = src.replace(/\/$/, "") + path + const flash = useCallback((msg: string) => { + setToast(msg) + if (toastTimer.current) clearTimeout(toastTimer.current) + toastTimer.current = setTimeout(() => setToast(null), 1400) + }, []) + + // Reload when the design history changes (undo/redo edits the source). useEffect(() => { function reload() { setReloadKey((k) => k + 1) @@ -26,6 +35,35 @@ export function PreviewPanel({ src }: { src: string }) { return () => window.removeEventListener("sin:design-history-changed", reload) }, []) + // Listen for undo/redo requests posted from the design-mode iframe agent (#60). + useEffect(() => { + async function onMessage(e: MessageEvent) { + const d = e.data + if (!d || d.source !== "sin-design") return + if (d.type !== "design-undo" && d.type !== "design-redo") return + + const action = d.type === "design-undo" ? "undo" : "redo" + try { + const res = await fetch("/api/workspace/design-history", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }) + const json = await res.json() + if (json?.ok && json.entry) { + flash(action === "undo" ? "Undid change" : "Redid change") + window.dispatchEvent(new Event("sin:design-history-changed")) + } else { + flash(action === "undo" ? "Nothing to undo" : "Nothing to redo") + } + } catch { + flash("Undo/redo failed") + } + } + window.addEventListener("message", onMessage) + return () => window.removeEventListener("message", onMessage) + }, [flash]) + return (
@@ -93,7 +131,7 @@ export function PreviewPanel({ src }: { src: string }) {
-
+