From d101fd6a8f9e01a2907b7f0e22dfa28d0ca3fb37 Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 15 May 2026 08:23:33 -0300 Subject: [PATCH 01/12] feat(preview): add sections editor panel and pages dropdown to preview toolbar Adds a schema-driven form editor for deco.cx page sections, integrated as a side panel in the Preview tab. The URL bar now shows a pages dropdown (from the decofile) for quick navigation. Saves go through a new vm-file API proxy that writes block JSON files to the sandbox. Co-Authored-By: Claude Opus 4.6 --- apps/mesh/src/api/routes/org-scoped.ts | 2 + apps/mesh/src/api/routes/vm-file.ts | 90 +++++ .../sections-editor/fields/any-of-field.tsx | 120 ++++++ .../sections-editor/fields/array-field.tsx | 79 ++++ .../sections-editor/fields/boolean-field.tsx | 29 ++ .../sections-editor/fields/enum-field.tsx | 42 +++ .../sections-editor/fields/field-props.ts | 9 + .../sections-editor/fields/image-field.tsx | 42 +++ .../sections-editor/fields/number-field.tsx | 31 ++ .../sections-editor/fields/object-field.tsx | 43 +++ .../sections-editor/fields/string-field.tsx | 78 ++++ .../components/sections-editor/page-list.tsx | 80 ++++ .../sections-editor/resolve-schema.ts | 198 ++++++++++ .../components/sections-editor/save-bar.tsx | 28 ++ .../sections-editor/schema-form.tsx | 97 +++++ .../sections-editor/section-list.tsx | 63 ++++ .../sections-editor/sections-editor.tsx | 253 +++++++++++++ .../sections-editor/use-decofile.ts | 15 + .../sections-editor/use-live-meta.ts | 16 + .../sections-editor/use-save-block.ts | 52 +++ .../src/web/components/vm/preview/preview.tsx | 344 ++++++++++++------ apps/mesh/src/web/lib/query-keys.ts | 4 + 22 files changed, 1606 insertions(+), 109 deletions(-) create mode 100644 apps/mesh/src/api/routes/vm-file.ts create mode 100644 apps/mesh/src/web/components/sections-editor/fields/any-of-field.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/fields/array-field.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/fields/boolean-field.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/fields/enum-field.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/fields/field-props.ts create mode 100644 apps/mesh/src/web/components/sections-editor/fields/image-field.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/fields/number-field.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/fields/object-field.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/fields/string-field.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/page-list.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/resolve-schema.ts create mode 100644 apps/mesh/src/web/components/sections-editor/save-bar.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/schema-form.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/section-list.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/sections-editor.tsx create mode 100644 apps/mesh/src/web/components/sections-editor/use-decofile.ts create mode 100644 apps/mesh/src/web/components/sections-editor/use-live-meta.ts create mode 100644 apps/mesh/src/web/components/sections-editor/use-save-block.ts diff --git a/apps/mesh/src/api/routes/org-scoped.ts b/apps/mesh/src/api/routes/org-scoped.ts index 132454c521..c1045c2b6c 100644 --- a/apps/mesh/src/api/routes/org-scoped.ts +++ b/apps/mesh/src/api/routes/org-scoped.ts @@ -19,6 +19,7 @@ import { createTriggerCallbackRoutes } from "./trigger-callback"; import { createVirtualMcpRoutes } from "./virtual-mcp"; import { createVmEventsRoutes } from "./vm-events"; import { createVmExecRoutes } from "./vm-exec"; +import { createVmFileRoutes } from "./vm-file"; import { createVmSetupRoutes } from "./vm-setup"; interface OrgScopedDeps { @@ -66,6 +67,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { app.route("/", createKVRoutes({ kvStorage: deps.kvStorage })); app.route("/vm-events", createVmEventsRoutes()); // /api/:org/vm-events app.route("/vm-exec", createVmExecRoutes()); // /api/:org/vm-exec/{exec,kill}/:script + app.route("/vm-file", createVmFileRoutes()); // /api/:org/vm-file/{write,read} app.route("/vm-setup", createVmSetupRoutes()); // /api/:org/vm-setup/:step app.route("/deco-sites", createDecoSitesOrgRoutes()); // /api/:org/deco-sites app.route("/sso", createSsoRoutes()); // /api/:org/sso/* (renamed from /api/org-sso) diff --git a/apps/mesh/src/api/routes/vm-file.ts b/apps/mesh/src/api/routes/vm-file.ts new file mode 100644 index 0000000000..865ba349cb --- /dev/null +++ b/apps/mesh/src/api/routes/vm-file.ts @@ -0,0 +1,90 @@ +/** + * Browser-facing proxy for the daemon's file read/write endpoints. + * + * Same auth + claim + proxy pattern as `vm-exec.ts`. The browser doesn't hold + * the daemon bearer token, so we authenticate the user and forward through + * `runner.proxyDaemonRequest`. + */ + +import { Hono, type Context } from "hono"; +import { composeSandboxRef } from "@decocms/sandbox/runner"; +import { computeClaimHandle } from "../../sandbox/claim-handle"; +import { getOrInitSharedRunner } from "../../sandbox/lifecycle"; +import { + getUserId, + requireAuth, + requireOrganization, +} from "../../core/mesh-context"; +import type { Env } from "../hono-env"; + +async function proxy(c: Context, daemonPath: string) { + const ctx = c.var.meshContext; + try { + requireAuth(ctx); + } catch { + return c.json({ error: "Unauthorized" }, 401); + } + const userId = getUserId(ctx); + if (!userId) return c.json({ error: "Unauthorized" }, 401); + + let organization: ReturnType; + try { + organization = requireOrganization(ctx); + } catch { + return c.json({ error: "Organization scope required" }, 403); + } + + const virtualMcpId = c.req.query("virtualMcpId"); + const branch = c.req.query("branch"); + if (!virtualMcpId || !branch) { + return c.json({ error: "virtualMcpId and branch are required" }, 400); + } + + const virtualMcp = await ctx.storage.virtualMcps.findById(virtualMcpId); + if (!virtualMcp || virtualMcp.organization_id !== organization.id) { + return c.json({ error: "Virtual MCP not found" }, 404); + } + + const projectRef = composeSandboxRef({ + orgId: organization.id, + virtualMcpId, + branch, + }); + const claimName = computeClaimHandle({ userId, projectRef }, branch); + + const runner = await getOrInitSharedRunner(); + if (!runner) { + return c.json({ error: "No sandbox runner configured" }, 503); + } + + // Daemon expects base64-encoded JSON bodies (Cloudflare WAF bypass). + const rawBody = await c.req.text(); + const encodedBody = Buffer.from(rawBody, "utf-8").toString("base64"); + + let upstream: Response; + try { + upstream = await runner.proxyDaemonRequest(claimName, daemonPath, { + method: "POST", + headers: new Headers({ "content-type": "application/json" }), + body: encodedBody, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.json({ error: `Daemon unreachable: ${message}` }, 502); + } + + const text = await upstream.text(); + return new Response(text, { + status: upstream.status, + headers: { "content-type": "application/json" }, + }); +} + +export const createVmFileRoutes = () => { + const app = new Hono(); + + app.post("/write", (c) => proxy(c, "/_decopilot_vm/write")); + app.post("/read", (c) => proxy(c, "/_decopilot_vm/read")); + + return app; +}; diff --git a/apps/mesh/src/web/components/sections-editor/fields/any-of-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/any-of-field.tsx new file mode 100644 index 0000000000..7cbe369b06 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/any-of-field.tsx @@ -0,0 +1,120 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@deco/ui/components/select.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import { useState } from "react"; +import type { FieldProps } from "./field-props"; +import { SchemaForm } from "../schema-form"; + +export function AnyOfField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const variants = schema.anyOf ?? []; + + // Try to figure out which variant is active based on current value + const detectVariantIndex = (): number => { + if (value == null) return 0; + + // If value has __resolveType, match on that + if (typeof value === "object" && !Array.isArray(value)) { + const rt = (value as Record).__resolveType; + if (typeof rt === "string") { + const idx = variants.findIndex( + (v) => v.const === rt || v.enum?.[0] === rt, + ); + if (idx >= 0) return idx; + } + } + + // Match on const value + const idx = variants.findIndex((v) => v.const === value); + if (idx >= 0) return idx; + + return 0; + }; + + const [selectedIdx, setSelectedIdx] = useState(detectVariantIndex); + const activeVariant = variants[selectedIdx]; + + const handleVariantChange = (idx: string) => { + const i = Number(idx); + setSelectedIdx(i); + const v = variants[i]; + // Reset to default for this variant + if (v?.const !== undefined) { + onChange(v.const); + } else if (v?.default !== undefined) { + onChange(v.default); + } else if (v?.type === "object") { + onChange({}); + } else { + onChange(undefined); + } + }; + + if (variants.length === 0) return null; + + // Simple enum-like anyOf (all variants are const strings) + const allConst = variants.every((v) => v.const !== undefined); + if (allConst) { + return ( +
+ + +
+ ); + } + + return ( +
+ + {variants.length > 1 && ( + + )} + {activeVariant?.properties && ( +
+ ) + : {} + } + onChange={onChange} + basePath={`${path}.$${selectedIdx}`} + /> +
+ )} +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx new file mode 100644 index 0000000000..bd785dd673 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx @@ -0,0 +1,79 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { Plus, Trash01 } from "@untitledui/icons"; +import type { FieldProps } from "./field-props"; +import { SchemaForm } from "../schema-form"; +import { renderField } from "../schema-form"; + +export function ArrayField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const items = Array.isArray(value) ? value : []; + const itemSchema = schema.items; + + const addItem = () => { + const defaultVal = + itemSchema?.type === "object" ? {} : (itemSchema?.default ?? ""); + onChange([...items, defaultVal]); + }; + + const removeItem = (index: number) => { + onChange(items.filter((_, i) => i !== index)); + }; + + const updateItem = (index: number, val: unknown) => { + const next = [...items]; + next[index] = val; + onChange(next); + }; + + return ( +
+
+ {label} + +
+ {schema.description && ( +

{schema.description}

+ )} + {items.map((item, i) => ( +
+ + {itemSchema?.type === "object" && itemSchema.properties ? ( + updateItem(i, val)} + basePath={`${path}.${i}`} + /> + ) : itemSchema ? ( + renderField({ + schema: itemSchema, + value: item, + onChange: (val) => updateItem(i, val), + path: `${path}.${i}`, + label: itemSchema.title ?? `Item ${i + 1}`, + }) + ) : null} +
+ ))} + {items.length === 0 && ( +

No items yet.

+ )} +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/boolean-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/boolean-field.tsx new file mode 100644 index 0000000000..416e280c46 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/boolean-field.tsx @@ -0,0 +1,29 @@ +import { Switch } from "@deco/ui/components/switch.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function BooleanField({ + value, + onChange, + path, + label, + schema, +}: FieldProps) { + const checked = typeof value === "boolean" ? value : false; + + return ( +
+
+ + {schema.description && ( +

{schema.description}

+ )} +
+ onChange(v)} + /> +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/enum-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/enum-field.tsx new file mode 100644 index 0000000000..ce061e577a --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/enum-field.tsx @@ -0,0 +1,42 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@deco/ui/components/select.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function EnumField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const options = schema.enum ?? []; + const names = schema.enumNames ?? []; + const strValue = value != null ? String(value) : ""; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} + +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/field-props.ts b/apps/mesh/src/web/components/sections-editor/fields/field-props.ts new file mode 100644 index 0000000000..2bcc99cd63 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/field-props.ts @@ -0,0 +1,9 @@ +import type { SchemaProperty } from "../resolve-schema"; + +export interface FieldProps { + schema: SchemaProperty; + value: unknown; + onChange: (value: unknown) => void; + path: string; + label: string; +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/image-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/image-field.tsx new file mode 100644 index 0000000000..51d76df5b6 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/image-field.tsx @@ -0,0 +1,42 @@ +import { Input } from "@deco/ui/components/input.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function ImageField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const strValue = typeof value === "string" ? value : ""; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} +
+ onChange(e.target.value)} + placeholder="https://..." + className="flex-1" + /> + {strValue && ( + {label} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} +
+
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/number-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/number-field.tsx new file mode 100644 index 0000000000..9caeac0876 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/number-field.tsx @@ -0,0 +1,31 @@ +import { Input } from "@deco/ui/components/input.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function NumberField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const numValue = typeof value === "number" ? value : ""; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} + { + const v = e.target.value; + onChange(v === "" ? undefined : Number(v)); + }} + /> +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx new file mode 100644 index 0000000000..ed1de46528 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "@untitledui/icons"; +import type { FieldProps } from "./field-props"; +import { SchemaForm } from "../schema-form"; + +export function ObjectField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const [open, setOpen] = useState(false); + const objValue = + value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + + if (!schema.properties) return null; + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/string-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/string-field.tsx new file mode 100644 index 0000000000..2558a0f00e --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/string-field.tsx @@ -0,0 +1,78 @@ +import { Input } from "@deco/ui/components/input.tsx"; +import { Textarea } from "@deco/ui/components/textarea.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function StringField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const strValue = typeof value === "string" ? value : ""; + const format = schema.format; + + if ( + format === "textarea" || + format === "rich-text" || + format === "rich-text-inline" || + format === "markdown" + ) { + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} +