diff --git a/apps/mesh/package.json b/apps/mesh/package.json index e1ae22155c..3db6a2c5a0 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -112,6 +112,7 @@ "marked": "^15.0.6", "mesh-plugin-object-storage": "workspace:*", "mesh-plugin-private-registry": "workspace:*", + "mesh-plugin-site-editor": "workspace:*", "mesh-plugin-user-sandbox": "workspace:*", "mesh-plugin-reports": "workspace:*", "mesh-plugin-workflows": "workspace:*", diff --git a/apps/mesh/src/server-plugins.ts b/apps/mesh/src/server-plugins.ts index 091664f5d4..6b2fe09e11 100644 --- a/apps/mesh/src/server-plugins.ts +++ b/apps/mesh/src/server-plugins.ts @@ -11,6 +11,7 @@ import type { ServerPlugin } from "@decocms/bindings/server-plugin"; import { serverPlugin as privateRegistryPlugin } from "mesh-plugin-private-registry/server"; import { serverPlugin as userSandboxPlugin } from "mesh-plugin-user-sandbox/server"; +import { serverPlugin as siteEditorPlugin } from "mesh-plugin-site-editor/server"; import { serverPlugin as workflowsPlugin } from "mesh-plugin-workflows/server"; /** @@ -21,4 +22,5 @@ export const serverPlugins: ServerPlugin[] = [ userSandboxPlugin, privateRegistryPlugin, workflowsPlugin, + siteEditorPlugin, ]; diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index 777c902a37..334fd96d72 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -28,6 +28,7 @@ import * as OrganizationTools from "./organization"; import * as ProjectTools from "./projects"; import * as TagTools from "./tags"; import * as ThreadTools from "./thread"; +import * as FilesystemTools from "./filesystem"; import * as UserTools from "./user"; import { ToolName } from "./registry"; @@ -128,6 +129,9 @@ const CORE_TOOLS = [ ProjectTools.PROJECT_DELETE, ProjectTools.PROJECT_PLUGIN_CONFIG_GET, ProjectTools.PROJECT_PLUGIN_CONFIG_UPDATE, + + // Filesystem tools + FilesystemTools.FILESYSTEM_PICK_DIRECTORY, ] as const satisfies { name: ToolName }[]; // Plugin tools - collected at startup, gated by org settings at runtime diff --git a/apps/mesh/src/tools/registry.ts b/apps/mesh/src/tools/registry.ts index f237ccff94..3b6398f0c8 100644 --- a/apps/mesh/src/tools/registry.ts +++ b/apps/mesh/src/tools/registry.ts @@ -30,7 +30,8 @@ export type ToolCategory = | "Event Bus" | "Code Execution" | "Tags" - | "Projects"; + | "Projects" + | "Filesystem"; /** * All tool names - keep in sync with ALL_TOOLS in index.ts @@ -120,6 +121,8 @@ const ALL_TOOL_NAMES = [ "PROJECT_DELETE", "PROJECT_PLUGIN_CONFIG_GET", "PROJECT_PLUGIN_CONFIG_UPDATE", + // Filesystem tools + "FILESYSTEM_PICK_DIRECTORY", ] as const; /** @@ -535,6 +538,12 @@ export const MANAGEMENT_TOOLS: ToolMetadata[] = [ description: "Update project plugin configuration", category: "Projects", }, + // Filesystem tools + { + name: "FILESYSTEM_PICK_DIRECTORY", + description: "Open native folder picker dialog", + category: "Filesystem", + }, ]; /** @@ -612,6 +621,7 @@ const TOOL_LABELS: Record = { PROJECT_DELETE: "Delete project", PROJECT_PLUGIN_CONFIG_GET: "View plugin config", PROJECT_PLUGIN_CONFIG_UPDATE: "Update plugin config", + FILESYSTEM_PICK_DIRECTORY: "Pick directory", }; // ============================================================================ @@ -635,6 +645,7 @@ export function getToolsByCategory() { "Code Execution": [], Tags: [], Projects: [], + Filesystem: [], }; for (const tool of MANAGEMENT_TOOLS) { diff --git a/apps/mesh/src/web/components/binding-selector.tsx b/apps/mesh/src/web/components/binding-selector.tsx index b380a04246..cf333617c0 100644 --- a/apps/mesh/src/web/components/binding-selector.tsx +++ b/apps/mesh/src/web/components/binding-selector.tsx @@ -3,12 +3,20 @@ * * A reusable connection selector that filters connections by binding. * Shows connection icons and supports inline installation from registry. + * When running locally, offers a "Choose local folder" option that creates + * a @modelcontextprotocol/server-filesystem STDIO connection. */ -import { useConnections } from "@decocms/mesh-sdk"; +import { + useConnections, + useConnectionActions, + useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, +} from "@decocms/mesh-sdk"; import { useBindingConnections } from "@/web/hooks/use-binding"; import { useInstallFromRegistry } from "@/web/hooks/use-install-from-registry"; -import { Loading01, Plus } from "@untitledui/icons"; +import { Folder, Loading01, Plus } from "@untitledui/icons"; import { Button } from "@deco/ui/components/button.tsx"; import { Select, @@ -59,11 +67,19 @@ export function BindingSelector({ disabled = false, }: BindingSelectorProps) { const [isLocalInstalling, setIsLocalInstalling] = useState(false); + const [isBrowsing, setIsBrowsing] = useState(false); const { installByBinding, isInstalling: isGlobalInstalling } = useInstallFromRegistry(); const isInstalling = isLocalInstalling || isGlobalInstalling; + const { org } = useProjectContext(); + const { create } = useConnectionActions(); + const selfClient = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + const allConnections = useConnections(); // Filter connections based on binding type @@ -149,8 +165,82 @@ export function BindingSelector({ onAddNew?.(); }; - // Get selected connection for display - const selectedConnection = connections.find((c) => c.id === value); + // Detect if binding requires object storage tools (LIST_OBJECTS, GET_PRESIGNED_URL, etc.) + const isObjectStorageBinding = (() => { + if (!binding) return false; + if (typeof binding === "string") { + return binding === "OBJECT_STORAGE"; + } + if (Array.isArray(binding)) { + return binding.some( + (b) => + b.name === "LIST_OBJECTS" || + b.name === "GET_PRESIGNED_URL" || + b.name === "PUT_PRESIGNED_URL", + ); + } + return false; + })(); + + // Virtual connection ID for dev-assets (local object storage routed through mesh) + const devAssetsConnectionId = `${org.id}_dev-assets`; + + const handleChooseLocalFolder = async () => { + setIsBrowsing(true); + try { + // Open native OS folder picker via the mesh server + const pickResult = (await selfClient.callTool({ + name: "FILESYSTEM_PICK_DIRECTORY", + arguments: {}, + })) as { structuredContent?: { path: string | null } }; + + const folderPath = pickResult.structuredContent?.path ?? null; + if (!folderPath) return; + + const folderName = + folderPath.split("/").filter(Boolean).pop() ?? "folder"; + + // For object storage bindings, use the local-object-storage bridge + // For file/site bindings, use the standard filesystem MCP + const connectionConfig = isObjectStorageBinding + ? { + title: `Local Files: ${folderName}`, + connection_type: "STDIO" as const, + connection_headers: { + command: "node", + args: [ + "--experimental-strip-types", + "../../packages/mcp-local-object-storage/src/index.ts", + folderPath, + ], + }, + } + : { + title: `Local: ${folderName}`, + connection_type: "STDIO" as const, + connection_headers: { + command: "npx", + args: [ + "-y", + "@modelcontextprotocol/server-filesystem", + folderPath, + ], + }, + }; + + const newConnection = await create.mutateAsync( + connectionConfig as Parameters[0], + ); + + onValueChange(newConnection.id); + } catch { + // Error is handled by the mutation toast + } finally { + setIsBrowsing(false); + } + }; + + const busy = isInstalling || isBrowsing; return ( ); diff --git a/apps/mesh/src/web/components/details/connection/settings-tab/mcp-configuration-form.tsx b/apps/mesh/src/web/components/details/connection/settings-tab/mcp-configuration-form.tsx index d4c8b6af0d..488f82798d 100644 --- a/apps/mesh/src/web/components/details/connection/settings-tab/mcp-configuration-form.tsx +++ b/apps/mesh/src/web/components/details/connection/settings-tab/mcp-configuration-form.tsx @@ -1,12 +1,15 @@ import { ORG_ADMIN_PROJECT_SLUG, useConnections, + useConnectionActions, useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, } from "@decocms/mesh-sdk"; import { useBindingConnections } from "@/web/hooks/use-binding"; import { useBindingSchemaFromRegistry } from "@/web/hooks/use-binding-schema-from-registry"; import { useInstallFromRegistry } from "@/web/hooks/use-install-from-registry"; -import { Loading01, Plus } from "@untitledui/icons"; +import { Folder, Loading01, Plus } from "@untitledui/icons"; import { Button } from "@deco/ui/components/button.tsx"; import { Select, @@ -179,11 +182,19 @@ function BindingSelector({ className, }: BindingSelectorProps) { const [isLocalInstalling, setIsLocalInstalling] = useState(false); + const [isBrowsing, setIsBrowsing] = useState(false); const { installByBinding, isInstalling: isGlobalInstalling } = useInstallFromRegistry(); const isInstalling = isLocalInstalling || isGlobalInstalling; + const { org } = useProjectContext(); + const { create } = useConnectionActions(); + const selfClient = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + const allConnections = useConnections(); const filteredConnections = useBindingConnections({ connections: allConnections, @@ -246,13 +257,102 @@ function BindingSelector({ onAddNew?.(); }; + // Detect if binding requires object storage tools + const isObjectStorageBinding = (() => { + if (!binding) return false; + if (typeof binding === "string") { + return binding === "OBJECT_STORAGE"; + } + if (Array.isArray(binding)) { + return binding.some( + (b) => + b.name === "LIST_OBJECTS" || + b.name === "GET_PRESIGNED_URL" || + b.name === "PUT_PRESIGNED_URL", + ); + } + return false; + })(); + + // Virtual connection ID for dev-assets (local object storage routed through mesh) + const devAssetsConnectionId = `${org.id}_dev-assets`; + + const handleChooseLocalFolder = async () => { + setIsBrowsing(true); + try { + const pickResult = (await selfClient.callTool({ + name: "FILESYSTEM_PICK_DIRECTORY", + arguments: {}, + })) as { structuredContent?: { path: string | null } }; + + const folderPath = pickResult.structuredContent?.path ?? null; + if (!folderPath) return; + + const folderName = + folderPath.split("/").filter(Boolean).pop() ?? "folder"; + + // For object storage bindings, use the local-object-storage bridge + // For file/site bindings, use the standard filesystem MCP + const connectionConfig = isObjectStorageBinding + ? { + title: `Local Files: ${folderName}`, + connection_type: "STDIO" as const, + connection_headers: { + command: "node", + args: [ + "--experimental-strip-types", + "../../packages/mcp-local-object-storage/src/index.ts", + folderPath, + ], + }, + } + : { + title: `Local: ${folderName}`, + connection_type: "STDIO" as const, + connection_headers: { + command: "npx", + args: [ + "-y", + "@modelcontextprotocol/server-filesystem", + folderPath, + ], + }, + }; + + const newConnection = await create.mutateAsync( + connectionConfig as Parameters[0], + ); + + onValueChange(newConnection.id); + } catch { + // Error handled by mutation toast + } finally { + setIsBrowsing(false); + } + }; + + const busy = isInstalling || isBrowsing; + return ( ); diff --git a/apps/mesh/src/web/index.tsx b/apps/mesh/src/web/index.tsx index e815650acd..98bf63a30c 100644 --- a/apps/mesh/src/web/index.tsx +++ b/apps/mesh/src/web/index.tsx @@ -345,15 +345,18 @@ const workflowsRoute = createRoute({ // PLUGIN ROUTES // ============================================ -const pluginLayoutRoute = createRoute({ - getParentRoute: () => projectLayout, // Changed from shellLayout +// Catch-all for unknown/legacy plugins (dynamic $pluginId param). +// Known plugins get their own static routes below, which TanStack Router +// prioritizes over this dynamic route. +const pluginCatchAllRoute = createRoute({ + getParentRoute: () => projectLayout, path: "/$pluginId", component: lazyRouteComponent( () => import("./layouts/dynamic-plugin-layout.tsx"), ), }); -// Plugin setup (same as before) +// Plugin setup export const pluginRootSidebarItems: { pluginId: string; icon: ReactNode; @@ -368,14 +371,28 @@ export const pluginSidebarGroups: { defaultExpanded?: boolean; }[] = []; -const pluginRoutes: AnyRoute[] = []; +// Each plugin gets its own static route under projectLayout. +// This avoids route collisions (e.g. both plugins registering path: "/") +// because each plugin's children are isolated under their own route. +const perPluginStaticRoutes: AnyRoute[] = []; sourcePlugins.forEach((plugin: AnyClientPlugin) => { - // Only invoke setup if the plugin provides it if (!plugin.setup) return; + // Static route for this plugin: /site-editor, /object-storage, etc. + // Static paths rank higher than $pluginId, so these match first. + const pluginRoute = createRoute({ + getParentRoute: () => projectLayout, + path: `/${plugin.id}`, + component: lazyRouteComponent( + () => import("./layouts/dynamic-plugin-layout.tsx"), + ), + }) as AnyRoute; + + const perPluginRoutes: AnyRoute[] = []; + const context: PluginSetupContext = { - parentRoute: pluginLayoutRoute as AnyRoute, + parentRoute: pluginRoute, routing: { createRoute: createRoute, lazyRouteComponent: lazyRouteComponent, @@ -385,15 +402,14 @@ sourcePlugins.forEach((plugin: AnyClientPlugin) => { registerSidebarGroup: (group) => pluginSidebarGroups.push({ pluginId: plugin.id, ...group }), registerPluginRoutes: (routes) => { - pluginRoutes.push(...routes); + perPluginRoutes.push(...routes); }, }; plugin.setup(context); -}); -// Add all plugin routes as children of the plugin layout -const pluginLayoutWithChildren = pluginLayoutRoute.addChildren(pluginRoutes); + perPluginStaticRoutes.push(pluginRoute.addChildren(perPluginRoutes)); +}); // ============================================ // ROUTE TREE @@ -417,7 +433,8 @@ const projectRoutes = [ agentsRoute, agentDetailRoute, workflowsRoute, - pluginLayoutWithChildren, + ...perPluginStaticRoutes, + pluginCatchAllRoute, // Must be last — $pluginId catch-all ]; const projectLayoutWithChildren = projectLayout.addChildren(projectRoutes); diff --git a/apps/mesh/src/web/layouts/dynamic-plugin-layout.tsx b/apps/mesh/src/web/layouts/dynamic-plugin-layout.tsx index 2d66016916..fe8c69f805 100644 --- a/apps/mesh/src/web/layouts/dynamic-plugin-layout.tsx +++ b/apps/mesh/src/web/layouts/dynamic-plugin-layout.tsx @@ -5,14 +5,27 @@ * Uses the plugin's renderHeader/renderEmptyState if defined, otherwise falls back to Outlet. */ -import { Outlet, useParams } from "@tanstack/react-router"; +import { Outlet, useLocation, useParams } from "@tanstack/react-router"; import { Suspense } from "react"; import { Loading01 } from "@untitledui/icons"; import { sourcePlugins } from "../plugins"; import { PluginLayout } from "./plugin-layout"; +/** + * Extracts the pluginId from URL params (catch-all $pluginId route) or from + * the pathname (per-plugin static routes like /site-editor, /object-storage). + * The pluginId is always the 3rd path segment: /$org/$project/$pluginId/... + */ +function usePluginId(): string { + const params = useParams({ strict: false }) as { pluginId?: string }; + const location = useLocation(); + if (params.pluginId) return params.pluginId; + const segments = location.pathname.split("/").filter(Boolean); + return segments[2] ?? ""; +} + export default function DynamicPluginLayout() { - const { pluginId } = useParams({ strict: false }) as { pluginId: string }; + const pluginId = usePluginId(); // Find the plugin by ID const plugin = sourcePlugins.find((p) => p.id === pluginId); diff --git a/apps/mesh/src/web/layouts/plugin-layout.tsx b/apps/mesh/src/web/layouts/plugin-layout.tsx index afc1821cb0..e565f05ca0 100644 --- a/apps/mesh/src/web/layouts/plugin-layout.tsx +++ b/apps/mesh/src/web/layouts/plugin-layout.tsx @@ -5,13 +5,14 @@ * and provides PluginContext to plugin routes. * * Connection selection is controlled by project settings (plugin bindings). - * If no connection is configured for the plugin, an empty state is shown - * prompting the user to configure it in project settings. + * If no connection is configured for the plugin, the plugin's own empty + * state is shown so it can guide the user through setup. */ import { Binder, connectionImplementsBinding, + resolveToolNames, PluginConnectionEntity, PluginContext, PluginContextPartial, @@ -28,12 +29,11 @@ import { type ConnectionEntity, } from "@decocms/mesh-sdk"; import { authClient } from "@/web/lib/auth-client"; -import { Outlet, useParams, Link } from "@tanstack/react-router"; -import { Loading01, Settings01 } from "@untitledui/icons"; -import { Suspense, type ReactNode } from "react"; +import { Outlet, useLocation, useParams } from "@tanstack/react-router"; +import { Loading01 } from "@untitledui/icons"; +import { Suspense, useRef, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { KEYS } from "@/web/lib/query-keys"; -import { Button } from "@deco/ui/components/button.tsx"; import { Page } from "@/web/components/page"; interface PluginLayoutProps { @@ -86,6 +86,110 @@ function toPluginConnectionEntity( }; } +/** + * Extracts text content from an MCP tool result. + * Standard MCP tools return { content: [{ type: "text", text: "..." }] }. + */ +function extractMCPText(result: unknown): string { + if (!result || typeof result !== "object") return ""; + const r = result as Record; + // Check structuredContent.content first (newer MCP tools return this) + if (r.structuredContent && typeof r.structuredContent === "object") { + const sc = r.structuredContent as Record; + if (typeof sc.content === "string") return sc.content; + } + // Fall back to content array + if (Array.isArray(r.content)) { + return (r.content as Array<{ type?: string; text?: string }>) + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text) + .join("\n"); + } + return ""; +} + +/** + * Make a relative path absolute by prepending the root directory. + * Matches the server-side site-proxy behavior. + */ +function toAbsolute(rootDir: string | null, relativePath: string): string { + if (!rootDir || relativePath.startsWith("/")) return relativePath; + return `${rootDir.replace(/\/$/, "")}/${relativePath}`; +} + +/** + * Adapts binding tool inputs to match the aliased tool's expected schema. + * E.g. LIST_FILES({ prefix }) → list_directory({ path }). + * Resolves relative paths to absolute using the discovered root directory. + */ +function adaptToolInput( + canonicalName: string, + args: Record, + rootDir: string | null, +): Record { + switch (canonicalName) { + case "LIST_FILES": + // LIST_FILES({ prefix }) → list_directory({ path }) + return { path: toAbsolute(rootDir, (args.prefix as string) || ".") }; + case "READ_FILE": + return { path: toAbsolute(rootDir, (args.path as string) || "") }; + case "PUT_FILE": + return { + path: toAbsolute(rootDir, (args.path as string) || ""), + content: args.content, + }; + default: + return args; + } +} + +/** + * Adapts standard MCP tool responses to match binding output schemas. + * This bridges the gap between e.g. @modelcontextprotocol/server-filesystem + * responses and the structured formats bindings expect. + */ +function adaptToolResponse( + canonicalName: string, + rawResult: unknown, + originalArgs?: Record, +): unknown { + const text = extractMCPText(rawResult); + + switch (canonicalName) { + case "READ_FILE": + // read_file returns text content → { content: string } + return { content: text }; + + case "PUT_FILE": + // write_file returns confirmation text → { success: boolean } + return { success: true }; + + case "LIST_FILES": { + // list_directory returns lines like "[FILE] name.json" and "[DIR] subdir" + // We need to reconstruct full paths relative to the queried prefix. + const lines = text + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const prefix = (originalArgs?.prefix as string) ?? ""; + const files = lines + .filter((l) => l.startsWith("[FILE]")) + .map((l) => { + const name = l.replace("[FILE]", "").trim(); + const fullPath = + prefix && prefix !== "." + ? `${prefix.replace(/\/$/, "")}/${name}` + : name; + return { path: fullPath, sizeInBytes: 0, mtime: 0 }; + }); + return { files, count: files.length }; + } + + default: + return rawResult; + } +} + /** * Plugin layout component that filters connections by binding * and provides PluginContext to children. @@ -110,13 +214,11 @@ export function PluginLayout({ renderEmptyState, }: PluginLayoutProps) { const { org, project } = useProjectContext(); - const { - org: orgParam, - project: projectParam, - pluginId, - } = useParams({ - strict: false, - }) as { org: string; project: string; pluginId: string }; + // Extract pluginId from params ($pluginId catch-all) or URL path (static routes) + const params = useParams({ strict: false }) as { pluginId?: string }; + const location = useLocation(); + const pluginId = + params.pluginId ?? location.pathname.split("/").filter(Boolean)[2] ?? ""; const allConnections = useConnections(); const { data: authSession } = authClient.useSession(); @@ -157,6 +259,11 @@ export function PluginLayout({ orgId: org.id, }); + // Cache the discovered root directory for the filesystem MCP. + // Must be called before early returns to satisfy React's Rules of Hooks. + // undefined = not yet discovered, null = discovery failed/not applicable, string = root path. + const rootDirRef = useRef(undefined); + // Build session for context (always available) const session: PluginSession | null = authSession?.user ? { @@ -189,8 +296,8 @@ export function PluginLayout({ ); } - // If no valid connections exist at all, show the plugin's empty state - if (validConnections.length === 0) { + // If no valid connections or no configured connection, show the plugin's empty state + if (validConnections.length === 0 || !configuredConnection) { const emptyContext: PluginContextPartial = { connectionId: null, connection: null, @@ -208,42 +315,35 @@ export function PluginLayout({ ); } - // If no connection is configured in project settings, prompt user to configure - if (!configuredConnection) { - const emptyContext: PluginContextPartial = { - connectionId: null, - connection: null, - toolCaller: null, - org: orgContext, - session, - }; + // Build tool name mapping for aliased connections (e.g. read_file → READ_FILE) + const toolNameMap = resolveToolNames(configuredConnection, binding); - return ( - -
-
- -

Plugin Not Configured

-

- This plugin requires a connection to be configured. Go to project - settings to select which integration to use. -

-
- -
-
- ); - } + /** + * Discovers the root directory by calling list_allowed_directories on the + * MCP connection. Caches the result so subsequent tool calls are instant. + */ + const discoverRootDir = async (): Promise => { + if (rootDirRef.current !== undefined) return rootDirRef.current; + if (!configuredClient) { + rootDirRef.current = null; + return null; + } + try { + const result = await configuredClient.callTool({ + name: "list_allowed_directories", + arguments: {}, + }); + const text = extractMCPText(result); + const lines = text + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + rootDirRef.current = lines.find((l) => l.startsWith("/")) ?? null; + } catch { + rootDirRef.current = null; + } + return rootDirRef.current; + }; // Create the plugin context with connection // TypedToolCaller is generic - the plugin will cast it to the correct binding type @@ -254,12 +354,32 @@ export function PluginLayout({ // Type safety is enforced by the plugin using usePluginContext() toolCaller: ((toolName: string, args: unknown) => configuredClient - ? configuredClient - .callTool({ - name: toolName, - arguments: args as Record, - }) - .then((result) => result.structuredContent ?? result) + ? (async () => { + const isAliased = !!toolNameMap[toolName]; + // Discover root directory on first aliased tool call + const rootDir = isAliased ? await discoverRootDir() : null; + + const result = await configuredClient.callTool({ + name: toolNameMap[toolName] ?? toolName, + arguments: (isAliased + ? adaptToolInput( + toolName, + args as Record, + rootDir, + ) + : args) as Record, + }); + + if (isAliased) { + return adaptToolResponse( + toolName, + result, + args as Record, + ); + } + const payload = result.structuredContent ?? result; + return payload; + })() : Promise.reject( new Error("MCP client is not available"), )) as PluginContext["toolCaller"], diff --git a/apps/mesh/src/web/plugins.ts b/apps/mesh/src/web/plugins.ts index 96516bd9ce..d9e1393126 100644 --- a/apps/mesh/src/web/plugins.ts +++ b/apps/mesh/src/web/plugins.ts @@ -3,6 +3,7 @@ import { objectStoragePlugin } from "mesh-plugin-object-storage"; import { clientPlugin as privateRegistryPlugin } from "mesh-plugin-private-registry/client"; import { reportsPlugin } from "mesh-plugin-reports"; import { clientPlugin as userSandboxPlugin } from "mesh-plugin-user-sandbox/client"; +import { clientPlugin as siteEditorPlugin } from "mesh-plugin-site-editor/client"; import { clientPlugin as workflowsPlugin } from "mesh-plugin-workflows/client"; // Registered plugins @@ -12,4 +13,5 @@ export const sourcePlugins: AnyClientPlugin[] = [ userSandboxPlugin, privateRegistryPlugin, workflowsPlugin, + siteEditorPlugin, ]; diff --git a/apps/mesh/src/web/routes/orgs/connections.tsx b/apps/mesh/src/web/routes/orgs/connections.tsx index 597af1106f..5d799abbfb 100644 --- a/apps/mesh/src/web/routes/orgs/connections.tsx +++ b/apps/mesh/src/web/routes/orgs/connections.tsx @@ -126,6 +126,7 @@ const connectionFormSchema = z connection_token: z.string().nullable().optional(), // For NPX npx_package: z.string().optional(), + npx_args: z.string().optional(), // For STDIO (custom command) stdio_command: z.string().optional(), stdio_args: z.string().optional(), @@ -192,7 +193,9 @@ function normalizeUrl(input: string): string { } } -function parseNpxLikeCommand(input: string): { packageName: string } | null { +function parseNpxLikeCommand( + input: string, +): { packageName: string; extraArgs: string } | null { const tokens = input.trim().split(/\s+/).filter(Boolean); if (tokens.length < 2) return null; @@ -201,10 +204,12 @@ function parseNpxLikeCommand(input: string): { packageName: string } | null { // Skip flags like -y, --yes const args = tokens.slice(1); - const firstNonFlag = args.find((a) => !a.startsWith("-")); - if (!firstNonFlag) return null; + const pkgIndex = args.findIndex((a) => !a.startsWith("-")); + if (pkgIndex < 0) return null; - return { packageName: firstNonFlag }; + const packageName = args[pkgIndex]!; + const extraArgs = args.slice(pkgIndex + 1).join(" "); + return { packageName, extraArgs }; } function inferHardcodedProviderHint(params: { @@ -294,10 +299,15 @@ function inferRegistryProviderHint(params: { function buildNpxParameters( packageName: string, envVars: EnvVar[], + extraArgs?: string, ): StdioConnectionParameters { + const args = ["-y", packageName]; + if (extraArgs?.trim()) { + args.push(...extraArgs.trim().split(/\s+/)); + } const params: StdioConnectionParameters = { command: "npx", - args: ["-y", packageName], + args, }; const envRecord = envVarsToRecord(envVars); if (Object.keys(envRecord).length > 0) { @@ -345,8 +355,16 @@ function isNpxCommand(params: StdioConnectionParameters): boolean { /** * Parse STDIO connection_headers back to NPX form fields */ -function parseStdioToNpx(params: StdioConnectionParameters): string { - return params.args?.find((a) => !a.startsWith("-")) ?? ""; +function parseStdioToNpx(params: StdioConnectionParameters): { + packageName: string; + extraArgs: string; +} { + const args = params.args ?? []; + const packageName = args.find((a) => !a.startsWith("-")) ?? ""; + // Extra args are everything after the package name (excluding flags like -y) + const pkgIndex = args.indexOf(packageName); + const extraArgs = pkgIndex >= 0 ? args.slice(pkgIndex + 1).join(" ") : ""; + return { packageName, extraArgs }; } /** @@ -470,6 +488,7 @@ function OrgMcpsContent() { connection_url: "", connection_token: null, npx_package: "", + npx_args: "", stdio_command: "", stdio_args: "", stdio_cwd: "", @@ -513,14 +532,15 @@ function OrgMcpsContent() { if (isNpxCommand(stdioParams)) { // NPX connection - const npxPackage = parseStdioToNpx(stdioParams); + const npxParsed = parseStdioToNpx(stdioParams); form.reset({ title: editingConnection.title, description: editingConnection.description, ui_type: "NPX", connection_url: "", connection_token: null, - npx_package: npxPackage, + npx_package: npxParsed.packageName, + npx_args: npxParsed.extraArgs, stdio_command: "", stdio_args: "", stdio_cwd: "", @@ -536,6 +556,7 @@ function OrgMcpsContent() { connection_url: "", connection_token: null, npx_package: "", + npx_args: "", stdio_command: customData.command, stdio_args: customData.args, stdio_cwd: customData.cwd, @@ -699,6 +720,7 @@ function OrgMcpsContent() { connectionParameters = buildNpxParameters( data.npx_package || "", data.env_vars || [], + data.npx_args, ); } else if (data.ui_type === "STDIO") { // Custom STDIO command @@ -826,6 +848,7 @@ function OrgMcpsContent() { if (npx && stdioEnabled) { form.setValue("ui_type", "NPX", { shouldDirty: true }); form.setValue("npx_package", npx.packageName, { shouldDirty: true }); + form.setValue("npx_args", npx.extraArgs, { shouldDirty: true }); // Clear HTTP fields for clarity form.setValue("connection_url", "", { shouldDirty: true }); form.setValue("connection_token", null, { shouldDirty: true }); @@ -1135,6 +1158,26 @@ function OrgMcpsContent() { )} /> + ( + + Arguments + + + +

+ Additional arguments passed after the package name +

+ +
+ )} + /> )} diff --git a/packages/bindings/package.json b/packages/bindings/package.json index 79386ff3ca..a4d5688308 100644 --- a/packages/bindings/package.json +++ b/packages/bindings/package.json @@ -22,6 +22,7 @@ "./collections": "./src/well-known/collections.ts", "./llm": "./src/well-known/language-model.ts", "./object-storage": "./src/well-known/object-storage.ts", + "./site": "./src/well-known/site.ts", "./connection": "./src/core/connection.ts", "./client": "./src/core/client/index.ts", "./mcp": "./src/well-known/mcp.ts", diff --git a/packages/bindings/src/core/binder.ts b/packages/bindings/src/core/binder.ts index e85dfb22f1..d0bf206f3d 100644 --- a/packages/bindings/src/core/binder.ts +++ b/packages/bindings/src/core/binder.ts @@ -45,6 +45,13 @@ export interface ToolBinder< * If true, an implementation doesn't need to provide this tool. */ opt?: true; + + /** + * Alternative tool names that also satisfy this binding slot. + * Used for compatibility with standard MCP servers that use different naming. + * E.g., READ_FILE might accept ["read_file"] as an alias. + */ + aliases?: string[]; } /** @@ -154,13 +161,20 @@ export function createBindingChecker( return { isImplementedBy: (tools: ToolWithSchemas[]): boolean => { for (const binderTool of binderTools) { - // Find matching tool by name (exact or regex) + // Find matching tool by name (exact or regex), including aliases const pattern = typeof binderTool.name === "string" ? new RegExp(`^${binderTool.name}$`) : binderTool.name; - const matchedTool = tools.find((t) => pattern.test(t.name)); + const aliasPatterns = (binderTool.aliases ?? []).map( + (a) => new RegExp(`^${a}$`), + ); + + const matchedTool = tools.find( + (t) => + pattern.test(t.name) || aliasPatterns.some((ap) => ap.test(t.name)), + ); // Skip optional tools that aren't present if (!matchedTool && binderTool.opt) { @@ -215,8 +229,41 @@ export function connectionImplementsBinding( name: b.name, inputSchema: b.inputSchema, opt: b.opt, + aliases: b.aliases, })); const checker = createBindingChecker(bindingForChecker); return checker.isImplementedBy(toolsForChecker); } + +/** + * Builds a mapping from canonical binding tool names to actual tool names + * on the connection. If the connection uses an alias (e.g. "read_file" instead + * of "READ_FILE"), the map will contain { READ_FILE: "read_file" }. + * + * Returns empty map if no aliasing is needed (all canonical names match directly). + */ +export function resolveToolNames( + connection: ConnectionForBinding, + binding: Binder, +): Record { + const toolNames = new Set((connection.tools ?? []).map((t) => t.name)); + const map: Record = {}; + + for (const binderTool of binding) { + const canonicalName = + typeof binderTool.name === "string" ? binderTool.name : null; + if (!canonicalName) continue; + + // If canonical name exists directly, no mapping needed + if (toolNames.has(canonicalName)) continue; + + // Check aliases + const matched = (binderTool.aliases ?? []).find((a) => toolNames.has(a)); + if (matched) { + map[canonicalName] = matched; + } + } + + return map; +} diff --git a/packages/bindings/src/core/plugin-router.tsx b/packages/bindings/src/core/plugin-router.tsx index 367e0e73d6..2731e06d07 100644 --- a/packages/bindings/src/core/plugin-router.tsx +++ b/packages/bindings/src/core/plugin-router.tsx @@ -19,6 +19,11 @@ import type { PluginSetupContext } from "./plugins"; /** * Prepends the plugin base path (/$org/$project/$pluginId) to a route path. * Handles both absolute plugin paths (starting with /) and relative paths. + * + * Plugin routes use ID-only layout routes (e.g., "site-editor-layout") as their + * root. Route references include this ID (e.g., "/site-editor-layout/pages/$pageId") + * but the ID doesn't correspond to a URL segment. This function strips the ID + * prefix so the URL is correct (e.g., /$org/$project/site-editor/pages/$pageId). */ function prependBasePath( to: string | undefined, @@ -28,15 +33,36 @@ function prependBasePath( ): string { if (!to) return `/${org}/${project}/${pluginId}`; - // If path starts with /, it's relative to the plugin root if (to.startsWith("/")) { - return `/${org}/${project}/${pluginId}${to}`; + // Strip the ID-only layout route prefix. + // e.g., "/site-editor-layout/pages/$pageId" → "/pages/$pageId" + // e.g., "/site-editor-layout/" → "/" + // e.g., "/site-editor-layout" → "" + const rest = to.substring(1); // "site-editor-layout/pages/$pageId" + const slashIdx = rest.indexOf("/"); + const urlPath = slashIdx >= 0 ? rest.substring(slashIdx) : ""; + return `/${org}/${project}/${pluginId}${urlPath}`; } - // Otherwise, it's already a full path or relative return to; } +/** + * Extracts pluginId from params (catch-all $pluginId route) or from the + * pathname (per-plugin static routes). The pluginId is always the 3rd segment. + */ +function usePluginId(): { org: string; project: string; pluginId: string } { + const params = useParams({ strict: false }) as { + org: string; + project: string; + pluginId?: string; + }; + const location = useLocation(); + const pluginId = + params.pluginId ?? location.pathname.split("/").filter(Boolean)[2] ?? ""; + return { org: params.org, project: params.project, pluginId }; +} + /** * Creates a typed plugin router from a route factory function. * @@ -123,11 +149,7 @@ export function createPluginRouter( */ useNavigate: () => { const navigate = useNavigate(); - const { org, project, pluginId } = useParams({ strict: false }) as { - org: string; - project: string; - pluginId: string; - }; + const { org, project, pluginId } = usePluginId(); return ( options: Omit & { @@ -171,11 +193,7 @@ export function createPluginRouter( children?: ReactNode; }, ) { - const { org, project, pluginId } = useParams({ strict: false }) as { - org: string; - project: string; - pluginId: string; - }; + const { org, project, pluginId } = usePluginId(); const to = prependBasePath(props.to as string, org, project, pluginId); diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts index eee481f77b..bc5523ad40 100644 --- a/packages/bindings/src/index.ts +++ b/packages/bindings/src/index.ts @@ -10,6 +10,7 @@ export { createBindingChecker, bindingClient, connectionImplementsBinding, + resolveToolNames, type Binder, type BindingChecker, type ToolBinder, @@ -102,6 +103,18 @@ export { type DeleteObjectsOutput, } from "./well-known/object-storage"; +// Re-export site binding types +export { + SITE_BINDING, + type SiteBinding, + type ReadFileInput, + type ReadFileOutput, + type PutFileInput, + type PutFileOutput, + type ListFilesInput, + type ListFilesOutput, +} from "./well-known/site"; + // Re-export workflow binding types export { WORKFLOWS_COLLECTION_BINDING } from "./well-known/workflow"; diff --git a/packages/bindings/src/well-known/site.ts b/packages/bindings/src/well-known/site.ts new file mode 100644 index 0000000000..e76666bde7 --- /dev/null +++ b/packages/bindings/src/well-known/site.ts @@ -0,0 +1,302 @@ +/** + * Site Well-Known Binding + * + * Defines the interface for site file operations (read, write, list) + * and branch lifecycle operations (create, list, merge, delete). + * Any MCP that implements this binding can provide file management + * for a site's pages, sections, and loaders. + * + * This binding includes: + * - READ_FILE: Read a file's content by path + * - PUT_FILE: Write content to a file by path + * - LIST_FILES: List files with optional prefix filtering + * - CREATE_BRANCH: Create a new branch (optional) + * - LIST_BRANCHES: List all branches (optional) + * - MERGE_BRANCH: Merge a source branch into target (optional) + * - DELETE_BRANCH: Delete a branch (optional) + * - GET_FILE_HISTORY: Get commit history for a file (optional) + * - READ_FILE_AT: Read a file at a specific commit (optional) + */ + +import { z } from "zod"; +import type { Binder, ToolBinder } from "../core/binder"; + +// ============================================================================ +// Tool Schemas +// ============================================================================ + +/** + * READ_FILE - Read a file's content by path + */ +const ReadFileInputSchema = z.object({ + path: z.string().describe("File path relative to project root"), +}); + +const ReadFileOutputSchema = z.object({ + content: z.string().describe("File content as UTF-8 string"), +}); + +export type ReadFileInput = z.infer; +export type ReadFileOutput = z.infer; + +/** + * PUT_FILE - Write content to a file by path + */ +const PutFileInputSchema = z.object({ + path: z.string().describe("File path relative to project root"), + content: z.string().describe("File content as UTF-8 string"), +}); + +const PutFileOutputSchema = z.object({ + success: z.boolean().describe("Whether the write succeeded"), +}); + +export type PutFileInput = z.infer; +export type PutFileOutput = z.infer; + +/** + * LIST_FILES - List files with optional prefix filtering + */ +const ListFilesInputSchema = z.object({ + prefix: z + .string() + .optional() + .describe("Path prefix filter (e.g., '.deco/pages/')"), +}); + +const ListFilesOutputSchema = z.object({ + files: z.array( + z.object({ + path: z.string().describe("File path relative to project root"), + sizeInBytes: z.number().describe("File size"), + mtime: z.number().describe("Last modified timestamp (epoch ms)"), + }), + ), + count: z.number().describe("Total file count"), +}); + +export type ListFilesInput = z.infer; +export type ListFilesOutput = z.infer; + +// ============================================================================ +// Branch Tool Schemas (optional -- not all MCPs support branches) +// ============================================================================ + +/** + * CREATE_BRANCH - Create a new branch + */ +const CreateBranchInputSchema = z.object({ + name: z.string().describe("Branch name to create"), + from: z + .string() + .optional() + .describe("Source branch to create from (defaults to 'main')"), +}); + +const CreateBranchOutputSchema = z.object({ + success: z.boolean().describe("Whether the branch was created"), + branch: z.string().describe("Name of the created branch"), +}); + +export type CreateBranchInput = z.infer; +export type CreateBranchOutput = z.infer; + +/** + * LIST_BRANCHES - List all branches + */ +const ListBranchesInputSchema = z.object({}); + +const ListBranchesOutputSchema = z.object({ + branches: z.array( + z.object({ + name: z.string().describe("Branch name"), + isDefault: z.boolean().describe("Whether this is the default branch"), + }), + ), +}); + +export type ListBranchesInput = z.infer; +export type ListBranchesOutput = z.infer; + +/** + * MERGE_BRANCH - Merge a source branch into a target branch + */ +const MergeBranchInputSchema = z.object({ + source: z.string().describe("Source branch to merge from"), + target: z + .string() + .optional() + .describe("Target branch to merge into (defaults to 'main')"), + deleteSource: z + .boolean() + .optional() + .describe("Whether to delete the source branch after merge"), +}); + +const MergeBranchOutputSchema = z.object({ + success: z.boolean().describe("Whether the merge succeeded"), + message: z.string().optional().describe("Additional merge details"), +}); + +export type MergeBranchInput = z.infer; +export type MergeBranchOutput = z.infer; + +/** + * DELETE_BRANCH - Delete a branch + */ +const DeleteBranchInputSchema = z.object({ + name: z.string().describe("Branch name to delete"), +}); + +const DeleteBranchOutputSchema = z.object({ + success: z.boolean().describe("Whether the branch was deleted"), +}); + +export type DeleteBranchInput = z.infer; +export type DeleteBranchOutput = z.infer; + +// ============================================================================ +// History Tool Schemas (optional -- not all MCPs support file history) +// ============================================================================ + +/** + * GET_FILE_HISTORY - Get commit history for a file + */ +const GetFileHistoryInputSchema = z.object({ + path: z.string().describe("File path relative to project root"), + branch: z.string().optional().describe("Branch name (defaults to current)"), + limit: z + .number() + .optional() + .describe("Max entries to return (defaults to 50)"), +}); + +const GetFileHistoryOutputSchema = z.object({ + entries: z.array( + z.object({ + commitHash: z.string().describe("Git commit SHA"), + timestamp: z.number().describe("Commit timestamp (epoch ms)"), + author: z.string().describe("Commit author name"), + message: z.string().describe("Commit message"), + }), + ), +}); + +export type GetFileHistoryInput = z.infer; +export type GetFileHistoryOutput = z.infer; + +/** + * READ_FILE_AT - Read a file's content at a specific commit + */ +const ReadFileAtInputSchema = z.object({ + path: z.string().describe("File path relative to project root"), + commitHash: z.string().describe("Git commit SHA to read from"), +}); + +const ReadFileAtOutputSchema = z.object({ + content: z.string().describe("File content at the specified commit"), +}); + +export type ReadFileAtInput = z.infer; +export type ReadFileAtOutput = z.infer; + +// ============================================================================ +// Binding Definition +// ============================================================================ + +/** + * Site Binding + * + * Defines the interface for site file operations and branch lifecycle. + * Any MCP that implements this binding can be used with the Site Editor plugin + * to provide a CMS UI for managing pages, sections, and loaders. + * + * Required tools: + * - READ_FILE: Read a file's content + * - PUT_FILE: Write content to a file + * - LIST_FILES: List files with prefix filtering + * + * Optional tools (branch lifecycle): + * - CREATE_BRANCH: Create a new branch + * - LIST_BRANCHES: List all branches + * - MERGE_BRANCH: Merge source branch into target + * - DELETE_BRANCH: Delete a branch + * + * Optional tools (file history): + * - GET_FILE_HISTORY: Get commit history for a file + * - READ_FILE_AT: Read a file at a specific commit + */ +export const SITE_BINDING = [ + { + name: "READ_FILE" as const, + inputSchema: ReadFileInputSchema, + outputSchema: ReadFileOutputSchema, + aliases: ["read_file"], + } satisfies ToolBinder<"READ_FILE", ReadFileInput, ReadFileOutput>, + { + name: "PUT_FILE" as const, + inputSchema: PutFileInputSchema, + outputSchema: PutFileOutputSchema, + aliases: ["write_file"], + } satisfies ToolBinder<"PUT_FILE", PutFileInput, PutFileOutput>, + { + name: "LIST_FILES" as const, + inputSchema: ListFilesInputSchema, + outputSchema: ListFilesOutputSchema, + aliases: ["list_directory"], + } satisfies ToolBinder<"LIST_FILES", ListFilesInput, ListFilesOutput>, + { + name: "CREATE_BRANCH" as const, + inputSchema: CreateBranchInputSchema, + outputSchema: CreateBranchOutputSchema, + opt: true, + } satisfies ToolBinder< + "CREATE_BRANCH", + CreateBranchInput, + CreateBranchOutput + >, + { + name: "LIST_BRANCHES" as const, + inputSchema: ListBranchesInputSchema, + outputSchema: ListBranchesOutputSchema, + opt: true, + } satisfies ToolBinder< + "LIST_BRANCHES", + ListBranchesInput, + ListBranchesOutput + >, + { + name: "MERGE_BRANCH" as const, + inputSchema: MergeBranchInputSchema, + outputSchema: MergeBranchOutputSchema, + opt: true, + } satisfies ToolBinder<"MERGE_BRANCH", MergeBranchInput, MergeBranchOutput>, + { + name: "DELETE_BRANCH" as const, + inputSchema: DeleteBranchInputSchema, + outputSchema: DeleteBranchOutputSchema, + opt: true, + } satisfies ToolBinder< + "DELETE_BRANCH", + DeleteBranchInput, + DeleteBranchOutput + >, + { + name: "GET_FILE_HISTORY" as const, + inputSchema: GetFileHistoryInputSchema, + outputSchema: GetFileHistoryOutputSchema, + opt: true, + } satisfies ToolBinder< + "GET_FILE_HISTORY", + GetFileHistoryInput, + GetFileHistoryOutput + >, + { + name: "READ_FILE_AT" as const, + inputSchema: ReadFileAtInputSchema, + outputSchema: ReadFileAtOutputSchema, + opt: true, + } satisfies ToolBinder<"READ_FILE_AT", ReadFileAtInput, ReadFileAtOutput>, +] as const satisfies Binder; + +export type SiteBinding = typeof SITE_BINDING; diff --git a/packages/mesh-plugin-object-storage/components/file-browser.tsx b/packages/mesh-plugin-object-storage/components/file-browser.tsx index 0a185020b9..a7670bee30 100644 --- a/packages/mesh-plugin-object-storage/components/file-browser.tsx +++ b/packages/mesh-plugin-object-storage/components/file-browser.tsx @@ -168,7 +168,7 @@ export default function FileBrowser() { flat = false, view = "table", } = objectStorageRouter.useSearch({ - from: "/", + from: "/object-storage-layout", }); const navigate = objectStorageRouter.useNavigate(); @@ -176,14 +176,17 @@ export default function FileBrowser() { const setPrefix = (newPath: string) => { setSelectedKeys(new Set()); // Clear selection when navigating folders - navigate({ to: "/", search: { path: newPath || undefined, flat, view } }); + navigate({ + to: "/object-storage-layout/", + search: { path: newPath || undefined, flat, view }, + }); }; const setFlat = (newFlat: boolean) => { setSelectedKeys(new Set()); // Clear selection when switching view mode // Reset to root when switching to flat mode, preserve path in directory mode navigate({ - to: "/", + to: "/object-storage-layout/", search: { path: newFlat ? undefined : prefix || undefined, flat: newFlat, @@ -195,7 +198,7 @@ export default function FileBrowser() { const setView = (newView: "table" | "grid") => { setSelectedKeys(new Set()); // Clear selection when switching view navigate({ - to: "/", + to: "/object-storage-layout/", search: { path: prefix || undefined, flat, diff --git a/packages/mesh-plugin-object-storage/lib/router.ts b/packages/mesh-plugin-object-storage/lib/router.ts index 1abc642503..c931450aec 100644 --- a/packages/mesh-plugin-object-storage/lib/router.ts +++ b/packages/mesh-plugin-object-storage/lib/router.ts @@ -6,6 +6,7 @@ */ import { createPluginRouter } from "@decocms/bindings/plugins"; +import { Outlet } from "@tanstack/react-router"; import * as z from "zod"; /** @@ -26,12 +27,20 @@ export type FileBrowserSearch = z.infer; export const objectStorageRouter = createPluginRouter((ctx) => { const { createRoute, lazyRouteComponent } = ctx.routing; - const indexRoute = createRoute({ + // Pathless layout route — uses id instead of path to avoid + // duplicate "/" collision with other plugins (e.g. site-editor). + const layoutRoute = createRoute({ getParentRoute: () => ctx.parentRoute, + id: "object-storage-layout", + component: Outlet, + }); + + const indexRoute = createRoute({ + getParentRoute: () => layoutRoute, path: "/", component: lazyRouteComponent(() => import("../components/file-browser")), validateSearch: fileBrowserSearchSchema, }); - return [indexRoute]; + return [layoutRoute.addChildren([indexRoute])]; }); diff --git a/packages/mesh-plugin-site-editor/client/components/block-detail.tsx b/packages/mesh-plugin-site-editor/client/components/block-detail.tsx new file mode 100644 index 0000000000..fca0d20ec2 --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/block-detail.tsx @@ -0,0 +1,237 @@ +/** + * Block Detail Component + * + * Shows block metadata and an @rjsf prop editor form. + * Uses SITE_BINDING tools via block-api helpers. + * Local formData state only -- Phase 3 will wire saves to page block instances. + */ + +import { useRef, useState } from "react"; +import { SITE_BINDING } from "@decocms/bindings/site"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { ArrowLeft, Loading01, AlertCircle } from "@untitledui/icons"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { blockKeys } from "../lib/query-keys"; +import { siteEditorRouter } from "../lib/router"; +import { getBlock } from "../lib/block-api"; +import { PropEditor } from "./prop-editor"; + +function formatTimestamp(dateStr: string): string { + if (!dateStr) return "-"; + try { + return new Date(dateStr).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateStr; + } +} + +function scanMethodLabel(method: string): string { + switch (method) { + case "ts-morph": + return "ts-morph"; + case "manual": + return "Manual"; + case "ai-agent": + return "AI Agent"; + default: + return method; + } +} + +export default function BlockDetail() { + const { toolCaller, connectionId } = usePluginContext(); + const navigate = siteEditorRouter.useNavigate(); + const { blockId } = siteEditorRouter.useParams({ + from: "/site-editor-layout/sections/$blockId", + }); + + const [formData, setFormData] = useState>({}); + const [schemaExpanded, setSchemaExpanded] = useState(false); + const lastSyncedBlockId = useRef(null); + + const { + data: block, + isLoading, + error, + } = useQuery({ + queryKey: blockKeys.detail(connectionId, blockId), + queryFn: () => getBlock(toolCaller, blockId), + }); + + // Sync formData when block data loads (ref-based, not useEffect) + if (block && lastSyncedBlockId.current !== block.id) { + lastSyncedBlockId.current = block.id; + setFormData(block.defaults ?? {}); + } + + if (isLoading) { + return ( +
+ +

Loading block...

+
+ ); + } + + if (error) { + return ( +
+ +

Error loading block

+

+ {error instanceof Error ? error.message : "Unknown error"} +

+
+ ); + } + + if (!block) { + return ( +
+ +

Block not found

+

+ The block "{blockId}" could not be found. +

+ +
+ ); + } + + const propsCount = Object.keys(block.schema?.properties ?? {}).length; + const hasSchema = + block.schema && + typeof block.schema === "object" && + Object.keys(block.schema).length > 0; + + return ( +
+ {/* Header with breadcrumb */} +
+
+ + / + {block.label} +
+
+ + {/* Content */} +
+
+ {/* Block info */} +
+

{block.label}

+

+ {block.component} +

+ {block.description && ( +

+ {block.description} +

+ )} +
+ + {/* Metadata section */} +
+

+ Metadata +

+
+
+ Scan Method +
+ + {scanMethodLabel(block.metadata.scanMethod)} + +
+
+
+ Scanned +

+ {formatTimestamp(block.metadata.scannedAt)} +

+
+
+ Props Type +

+ {block.metadata.propsTypeName ?? "unknown"} +

+
+
+ Properties +

{propsCount} props

+
+
+
+ + {/* Raw schema (collapsible) */} + {hasSchema && ( +
+ + {schemaExpanded && ( +
+
+                    {JSON.stringify(block.schema, null, 2)}
+                  
+
+ )} +
+ )} + + {/* Props editor */} + {hasSchema ? ( +
+

Props Editor

+
+ +
+
+ ) : ( +
+ No schema available for this block. +
+ )} +
+
+
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/block-picker.tsx b/packages/mesh-plugin-site-editor/client/components/block-picker.tsx new file mode 100644 index 0000000000..79b258b8b6 --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/block-picker.tsx @@ -0,0 +1,161 @@ +/** + * Block Picker Component + * + * Modal dialog for selecting a block type from the library to add to a page. + * Lists available blocks grouped by category with a search filter. + * Uses @deco/ui Dialog and fetches blocks via block-api helpers. + */ + +import { useState } from "react"; +import { SITE_BINDING } from "@decocms/bindings/site"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { useQuery } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { Box, Search } from "lucide-react"; +import { Loading01 } from "@untitledui/icons"; +import { queryKeys } from "../lib/query-keys"; +import { listBlocks } from "../lib/block-api"; +import type { BlockSummary } from "../lib/block-api"; + +interface BlockPickerProps { + open: boolean; + onClose: () => void; + onSelect: (blockType: string, defaults: Record) => void; +} + +/** + * Group blocks by category, sorted alphabetically within each group. + */ +function groupByCategory( + blocks: BlockSummary[], +): Record { + const groups: Record = {}; + for (const block of blocks) { + const category = block.category || "Other"; + if (!groups[category]) { + groups[category] = []; + } + groups[category].push(block); + } + for (const category of Object.keys(groups)) { + groups[category].sort((a, b) => a.label.localeCompare(b.label)); + } + return groups; +} + +export function BlockPicker({ open, onClose, onSelect }: BlockPickerProps) { + const { toolCaller, connectionId } = usePluginContext(); + const [filter, setFilter] = useState(""); + + const { data: blocks = [], isLoading } = useQuery({ + queryKey: queryKeys.blocks.all(connectionId), + queryFn: () => listBlocks(toolCaller), + enabled: open, + }); + + // Filter blocks by search term + const filtered = filter + ? blocks.filter( + (b) => + b.label.toLowerCase().includes(filter.toLowerCase()) || + b.component.toLowerCase().includes(filter.toLowerCase()) || + b.category.toLowerCase().includes(filter.toLowerCase()), + ) + : blocks; + + const grouped = groupByCategory(filtered); + const categoryNames = Object.keys(grouped).sort(); + + const handleSelect = (block: BlockSummary) => { + onSelect(block.id, {}); + onClose(); + setFilter(""); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + onClose(); + setFilter(""); + } + }; + + return ( + + + + Add Section + + + {/* Search filter */} +
+ + setFilter(e.target.value)} + className="pl-9" + /> +
+ + {/* Block list */} +
+ {isLoading ? ( +
+ +

Loading blocks...

+
+ ) : filtered.length === 0 ? ( +
+ +

+ {filter ? "No blocks match your search" : "No blocks available"} +

+
+ ) : ( + categoryNames.map((category) => ( +
+
+ {category} +
+ {grouped[category].map((block) => ( + + ))} +
+ )) + )} +
+
+
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/branch-switcher.tsx b/packages/mesh-plugin-site-editor/client/components/branch-switcher.tsx new file mode 100644 index 0000000000..b745db2dec --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/branch-switcher.tsx @@ -0,0 +1,159 @@ +/** + * Branch Switcher Component + * + * Dropdown for switching between branches and creating new drafts. + * Uses module-level branch store to share the active branch across the plugin. + */ + +import { useState, useRef } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { SITE_BINDING } from "@decocms/bindings/site"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { ChevronDown, Loading01 } from "@untitledui/icons"; +import { listBranches, createBranch } from "../lib/branch-api"; +import { queryKeys } from "../lib/query-keys"; +import { useBranch } from "../lib/branch-context"; +import { DRAFT_BRANCH_PREFIX } from "../../shared"; + +export default function BranchSwitcher() { + const { toolCaller, connectionId } = usePluginContext(); + const { currentBranch, setCurrentBranch } = useBranch(); + const queryClient = useQueryClient(); + + const [isOpen, setIsOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [draftName, setDraftName] = useState(""); + const dropdownRef = useRef(null); + + const { data: branchData } = useQuery({ + queryKey: queryKeys.branches.all(connectionId), + queryFn: () => listBranches(toolCaller), + }); + + const createMutation = useMutation({ + mutationFn: (name: string) => createBranch(toolCaller, name), + onSuccess: (result) => { + if (result?.success) { + setCurrentBranch(result.branch); + setIsCreating(false); + setDraftName(""); + queryClient.invalidateQueries({ + queryKey: queryKeys.branches.all(connectionId), + }); + } + }, + }); + + const branches = branchData?.branches ?? [{ name: "main", isDefault: true }]; + const isDraft = currentBranch !== "main"; + + const handleBlur = (e: React.FocusEvent) => { + if (!dropdownRef.current?.contains(e.relatedTarget as Node)) { + setIsOpen(false); + setIsCreating(false); + } + }; + + const handleCreate = () => { + const trimmed = draftName.trim(); + if (!trimmed) return; + createMutation.mutate(trimmed); + }; + + return ( +
+ + + {isOpen && ( +
+ {branches.map((branch) => ( + + ))} + +
+ {isCreating ? ( +
+ + {DRAFT_BRANCH_PREFIX} + + setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + if (e.key === "Escape") { + setIsCreating(false); + setDraftName(""); + } + }} + placeholder="draft-name" + className="flex-1 min-w-0 px-1 py-0.5 text-xs border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-ring" + autoFocus + /> + +
+ ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/loader-detail.tsx b/packages/mesh-plugin-site-editor/client/components/loader-detail.tsx new file mode 100644 index 0000000000..ee69ff9259 --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/loader-detail.tsx @@ -0,0 +1,276 @@ +/** + * Loader Detail Component + * + * Shows loader metadata, output schema, and an @rjsf prop editor for input parameters. + * Uses SITE_BINDING tools via loader-api helpers. + * This is a browsing/exploration view -- actual parameter configuration happens + * when binding a loader to a section prop via the loader picker. + */ + +import { useRef, useState } from "react"; +import { SITE_BINDING } from "@decocms/bindings/site"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { ArrowLeft, Loading01, AlertCircle } from "@untitledui/icons"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { loaderKeys } from "../lib/query-keys"; +import { siteEditorRouter } from "../lib/router"; +import { getLoader } from "../lib/loader-api"; +import { PropEditor } from "./prop-editor"; + +function formatTimestamp(dateStr: string): string { + if (!dateStr) return "-"; + try { + return new Date(dateStr).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateStr; + } +} + +function scanMethodLabel(method: string): string { + switch (method) { + case "ts-morph": + return "ts-morph"; + case "manual": + return "Manual"; + case "ai-agent": + return "AI Agent"; + default: + return method; + } +} + +export default function LoaderDetail() { + const { toolCaller, connectionId } = usePluginContext(); + const navigate = siteEditorRouter.useNavigate(); + const { loaderId } = siteEditorRouter.useParams({ + from: "/site-editor-layout/loaders/$loaderId", + }); + + const [formData, setFormData] = useState>({}); + const [outputSchemaExpanded, setOutputSchemaExpanded] = useState(false); + const [inputSchemaExpanded, setInputSchemaExpanded] = useState(false); + const lastSyncedLoaderId = useRef(null); + + const { + data: loader, + isLoading, + error, + } = useQuery({ + queryKey: loaderKeys.detail(connectionId, loaderId), + queryFn: () => getLoader(toolCaller, loaderId), + }); + + // Sync formData when loader data loads (ref-based, not useEffect) + if (loader && lastSyncedLoaderId.current !== loader.id) { + lastSyncedLoaderId.current = loader.id; + setFormData(loader.defaults ?? {}); + } + + if (isLoading) { + return ( +
+ +

Loading loader...

+
+ ); + } + + if (error) { + return ( +
+ +

Error loading loader

+

+ {error instanceof Error ? error.message : "Unknown error"} +

+
+ ); + } + + if (!loader) { + return ( +
+ +

Loader not found

+

+ The loader "{loaderId}" could not be found. +

+ +
+ ); + } + + const inputParamsCount = Object.keys( + loader.inputSchema?.properties ?? {}, + ).length; + const hasInputSchema = + loader.inputSchema && + typeof loader.inputSchema === "object" && + Object.keys(loader.inputSchema).length > 0; + const hasOutputSchema = + loader.outputSchema && + typeof loader.outputSchema === "object" && + Object.keys(loader.outputSchema).length > 0; + + return ( +
+ {/* Header with breadcrumb */} +
+
+ + / + {loader.label} +
+
+ + {/* Content */} +
+
+ {/* Loader info */} +
+

{loader.label}

+

+ {loader.source} +

+ {loader.description && ( +

+ {loader.description} +

+ )} +
+ + {/* Metadata section */} +
+

+ Metadata +

+
+
+ Scan Method +
+ + {scanMethodLabel(loader.metadata.scanMethod)} + +
+
+
+ Scanned +

+ {formatTimestamp(loader.metadata.scannedAt)} +

+
+
+ Props Type +

+ {loader.metadata.propsTypeName ?? "none"} +

+
+
+ Return Type +

+ {loader.metadata.returnTypeName ?? "unknown"} +

+
+
+ Input Params +

{inputParamsCount} params

+
+
+
+ + {/* Output schema (collapsible) */} + {hasOutputSchema && ( +
+ + {outputSchemaExpanded && ( +
+
+                    {JSON.stringify(loader.outputSchema, null, 2)}
+                  
+
+ )} +
+ )} + + {/* Input schema raw view (collapsible) */} + {hasInputSchema && ( +
+ + {inputSchemaExpanded && ( +
+
+                    {JSON.stringify(loader.inputSchema, null, 2)}
+                  
+
+ )} +
+ )} + + {/* Input Parameters editor */} + {hasInputSchema && inputParamsCount > 0 ? ( +
+

Input Parameters

+
+ +
+
+ ) : ( +
+ This loader has no input parameters. +
+ )} +
+
+
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/loader-picker.tsx b/packages/mesh-plugin-site-editor/client/components/loader-picker.tsx new file mode 100644 index 0000000000..c73dda2ad0 --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/loader-picker.tsx @@ -0,0 +1,183 @@ +/** + * Loader Picker Component + * + * Modal dialog for selecting a loader to bind to a section prop. + * Lists available loaders grouped by category with a search filter. + * Uses @deco/ui Dialog and fetches loaders via loader-api helpers. + */ + +import { useState } from "react"; +import { SITE_BINDING } from "@decocms/bindings/site"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { useQuery } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { Database, Search } from "lucide-react"; +import { Loading01 } from "@untitledui/icons"; +import { loaderKeys } from "../lib/query-keys"; +import { listLoaders } from "../lib/loader-api"; +import type { LoaderSummary } from "../lib/loader-api"; +import type { LoaderRef } from "../lib/page-api"; + +interface LoaderPickerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (loaderRef: LoaderRef) => void; + propName: string; +} + +/** + * Group loaders by category, sorted alphabetically within each group. + */ +function groupByCategory( + loaders: LoaderSummary[], +): Record { + const groups: Record = {}; + for (const loader of loaders) { + const category = loader.category || "Other"; + if (!groups[category]) { + groups[category] = []; + } + groups[category].push(loader); + } + for (const category of Object.keys(groups)) { + groups[category].sort((a, b) => a.label.localeCompare(b.label)); + } + return groups; +} + +export function LoaderPicker({ + open, + onOpenChange, + onSelect, + propName, +}: LoaderPickerProps) { + const { toolCaller, connectionId } = usePluginContext(); + const [filter, setFilter] = useState(""); + + const { data: loaders = [], isLoading } = useQuery({ + queryKey: loaderKeys.all(connectionId), + queryFn: () => listLoaders(toolCaller), + enabled: open, + }); + + // Filter loaders by search term + const filtered = filter + ? loaders.filter( + (l) => + l.label.toLowerCase().includes(filter.toLowerCase()) || + l.source.toLowerCase().includes(filter.toLowerCase()) || + l.category.toLowerCase().includes(filter.toLowerCase()), + ) + : loaders; + + const grouped = groupByCategory(filtered); + const categoryNames = Object.keys(grouped).sort(); + + const handleSelect = (loader: LoaderSummary) => { + const loaderRef: LoaderRef = { + __loaderRef: loader.id, + params: {}, + }; + onSelect(loaderRef); + onOpenChange(false); + setFilter(""); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setFilter(""); + } + onOpenChange(isOpen); + }; + + return ( + + + + Bind Loader to {propName} + + + {/* Search filter */} +
+ + setFilter(e.target.value)} + className="pl-9" + /> +
+ + {/* Loader list */} +
+ {isLoading ? ( +
+ +

+ Loading loaders... +

+
+ ) : filtered.length === 0 ? ( +
+ +

+ {filter + ? "No loaders match your search" + : "No loaders available"} +

+
+ ) : ( + categoryNames.map((category) => ( +
+
+ {category} +
+ {grouped[category].map((loader) => ( + + ))} +
+ )) + )} +
+
+
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/loaders-list.tsx b/packages/mesh-plugin-site-editor/client/components/loaders-list.tsx new file mode 100644 index 0000000000..f99c2bf8dd --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/loaders-list.tsx @@ -0,0 +1,154 @@ +/** + * Loaders List Component + * + * Displays all scanned loaders grouped by category. + * Navigates to loader detail view on click. + * Uses SITE_BINDING tools (LIST_FILES, READ_FILE) via loader-api helpers. + */ + +import { SITE_BINDING } from "@decocms/bindings/site"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { AlertCircle, Loading01 } from "@untitledui/icons"; +import { Database, Search } from "lucide-react"; +import { loaderKeys } from "../lib/query-keys"; +import { siteEditorRouter } from "../lib/router"; +import { listLoaders } from "../lib/loader-api"; +import type { LoaderSummary } from "../lib/loader-api"; + +/** + * Group loaders by category, sorted alphabetically within each group. + */ +function groupByCategory( + loaders: LoaderSummary[], +): Record { + const groups: Record = {}; + for (const loader of loaders) { + const category = loader.category || "Other"; + if (!groups[category]) { + groups[category] = []; + } + groups[category].push(loader); + } + // Sort loaders within each category + for (const category of Object.keys(groups)) { + groups[category].sort((a, b) => a.label.localeCompare(b.label)); + } + return groups; +} + +export default function LoadersList() { + const { toolCaller, connectionId } = usePluginContext(); + const navigate = siteEditorRouter.useNavigate(); + + const { + data: loaders = [], + isLoading, + error, + } = useQuery({ + queryKey: loaderKeys.all(connectionId), + queryFn: () => listLoaders(toolCaller), + }); + + if (error) { + return ( +
+ +

Error loading loaders

+

+ {error instanceof Error ? error.message : "Unknown error"} +

+
+ ); + } + + const grouped = groupByCategory(loaders); + const categoryNames = Object.keys(grouped).sort(); + + return ( +
+ {/* Header */} +
+

Loaders

+ + {loaders.length} loaders + +
+ + {/* Loader list */} +
+ {isLoading ? ( +
+ +

Loading loaders...

+
+ ) : loaders.length === 0 ? ( +
+ +

No loaders found

+

+ Scan your codebase to discover data loaders and generate editable + parameter forms automatically. +

+ +
+ ) : ( + categoryNames.map((category) => ( +
+ {/* Category header */} +
+ {category} +
+ + {/* Loaders in category */} + {grouped[category].map((loader) => ( + + ))} +
+ )) + )} +
+
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/page-composer.tsx b/packages/mesh-plugin-site-editor/client/components/page-composer.tsx new file mode 100644 index 0000000000..a2cef1a22b --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/page-composer.tsx @@ -0,0 +1,543 @@ +/** + * Page Composer Component + * + * Three-panel visual editor layout: + * - Left: sortable section list sidebar with DnD reordering + * - Center: iframe preview with viewport toggle + * - Right: prop editor for selected block + * + * Fetches page data, manages selected block state, wires postMessage + * communication for live preview updates, and debounce-saves to git. + */ + +import { useRef, useState, useSyncExternalStore } from "react"; +import { nanoid } from "nanoid"; +import { SITE_BINDING } from "@decocms/bindings/site"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { arrayMove } from "@dnd-kit/sortable"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + ArrowLeft, + Save01, + Loading01, + AlertCircle, + ReverseLeft, + ReverseRight, + Clock, +} from "@untitledui/icons"; +import { toast } from "sonner"; +import { queryKeys } from "../lib/query-keys"; +import { siteEditorRouter } from "../lib/router"; +import { + getPage, + updatePage, + isLoaderRef, + type BlockInstance, + type LoaderRef, +} from "../lib/page-api"; +import { getBlock } from "../lib/block-api"; +import { useEditorMessages } from "../lib/use-editor-messages"; +import { useUndoRedo } from "../lib/use-undo-redo"; +import { PreviewPanel } from "./preview-panel"; +import { ViewportToggle, type ViewportKey } from "./viewport-toggle"; +import { PropEditor } from "./prop-editor"; +import { SectionListSidebar } from "./section-list-sidebar"; +import { BlockPicker } from "./block-picker"; +import { LoaderPicker } from "./loader-picker"; +import PageHistory from "./page-history"; + +export default function PageComposer() { + const { toolCaller, connectionId } = usePluginContext(); + const queryClient = useQueryClient(); + const navigate = siteEditorRouter.useNavigate(); + const { pageId } = siteEditorRouter.useParams({ + from: "/site-editor-layout/pages/$pageId", + }); + + const [selectedBlockId, setSelectedBlockId] = useState(null); + const [viewport, setViewport] = useState("desktop"); + const [showBlockPicker, setShowBlockPicker] = useState(false); + const [loaderPickerState, setLoaderPickerState] = useState<{ + open: boolean; + propName: string | null; + }>({ open: false, propName: null }); + const [showHistory, setShowHistory] = useState(false); + const saveTimerRef = useRef | null>(null); + const iframeRef = useRef(null); + const { send } = useEditorMessages(iframeRef); + + // Fetch page data + const { + data: page, + isLoading, + error, + } = useQuery({ + queryKey: queryKeys.pages.detail(connectionId, pageId), + queryFn: () => getPage(toolCaller, pageId), + }); + + // Undo/redo state for blocks + const { + value: blocks, + push: pushBlocks, + undo, + redo, + canUndo, + canRedo, + reset: resetBlocks, + clearFuture, + } = useUndoRedo(page?.blocks ?? []); + + // Sync blocks when server data loads + const lastSyncedRef = useRef(null); + if (page && lastSyncedRef.current !== page.metadata.updatedAt) { + lastSyncedRef.current = page.metadata.updatedAt; + resetBlocks(page.blocks); + } + + // Build the local page object from query data + undo/redo blocks + const localPage = page ? { ...page, blocks } : null; + + // Find selected block + const selectedBlock = blocks.find((b) => b.id === selectedBlockId); + + // Fetch block definition (schema) for the selected block + const { data: blockDef } = useQuery({ + queryKey: queryKeys.blocks.detail( + connectionId, + selectedBlock?.blockType ?? "", + ), + queryFn: () => getBlock(toolCaller, selectedBlock!.blockType), + enabled: !!selectedBlock, + }); + + // Send page-config to iframe whenever blocks change (undo/redo or direct edit) + const prevBlocksRef = useRef(blocks); + if (localPage && blocks !== prevBlocksRef.current) { + prevBlocksRef.current = blocks; + queueMicrotask(() => { + if (localPage) { + send({ type: "deco:page-config", page: localPage }); + } + }); + } + + // Keyboard shortcuts for undo/redo via useSyncExternalStore + const undoRef = useRef(undo); + const redoRef = useRef(redo); + undoRef.current = undo; + redoRef.current = redo; + + useSyncExternalStore( + (notify) => { + const handler = (e: KeyboardEvent) => { + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + + // Redo: Cmd+Shift+Z or Cmd+Y + if ((e.key === "z" || e.key === "Z") && e.shiftKey && mod) { + e.preventDefault(); + redoRef.current(); + notify(); + return; + } + if ((e.key === "y" || e.key === "Y") && mod && !e.shiftKey) { + e.preventDefault(); + redoRef.current(); + notify(); + return; + } + // Undo: Cmd+Z (no shift) + if ((e.key === "z" || e.key === "Z") && mod && !e.shiftKey) { + e.preventDefault(); + undoRef.current(); + notify(); + return; + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, + () => null, + () => null, + ); + + // Debounced save to git + const debouncedSave = (updatedBlocks: BlockInstance[]) => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + saveTimerRef.current = setTimeout(async () => { + try { + await updatePage(toolCaller, pageId, { + blocks: updatedBlocks, + }); + clearFuture(); + queryClient.invalidateQueries({ + queryKey: queryKeys.pages.detail(connectionId, pageId), + }); + } catch (err) { + toast.error( + `Failed to save: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } + }, 2000); + }; + + // Handle prop changes from PropEditor + const handlePropChange = (newProps: Record) => { + if (!selectedBlockId) return; + + const updatedBlocks = blocks.map((block) => + block.id === selectedBlockId ? { ...block, props: newProps } : block, + ); + + pushBlocks(updatedBlocks); + + // Immediately send to iframe for live preview + send({ + type: "deco:update-block", + blockId: selectedBlockId, + props: newProps, + }); + + // Debounce save to git + debouncedSave(updatedBlocks); + }; + + // Handle block deletion + const handleDeleteBlock = (blockId: string) => { + const updatedBlocks = blocks.filter((b) => b.id !== blockId); + pushBlocks(updatedBlocks); + debouncedSave(updatedBlocks); + if (selectedBlockId === blockId) { + setSelectedBlockId(null); + } + }; + + // Handle block reordering via DnD + const handleReorder = (activeId: string, overId: string) => { + const oldIndex = blocks.findIndex((b) => b.id === activeId); + const newIndex = blocks.findIndex((b) => b.id === overId); + if (oldIndex === -1 || newIndex === -1) return; + + const reorderedBlocks = arrayMove(blocks, oldIndex, newIndex); + pushBlocks(reorderedBlocks); + debouncedSave(reorderedBlocks); + }; + + // Handle adding a new block from the picker + const handleAddBlock = ( + blockType: string, + defaults: Record, + ) => { + const newBlock: BlockInstance = { + id: nanoid(8), + blockType, + props: defaults, + }; + + const updatedBlocks = [...blocks, newBlock]; + pushBlocks(updatedBlocks); + debouncedSave(updatedBlocks); + + setSelectedBlockId(newBlock.id); + setShowBlockPicker(false); + }; + + // Handle binding a loader to a prop on the selected block + const handleBindLoader = (loaderRef: LoaderRef) => { + if (!selectedBlockId || !loaderPickerState.propName) return; + + const propName = loaderPickerState.propName; + const updatedBlocks = blocks.map((block) => + block.id === selectedBlockId + ? { ...block, props: { ...block.props, [propName]: loaderRef } } + : block, + ); + + pushBlocks(updatedBlocks); + send({ + type: "deco:update-block", + blockId: selectedBlockId, + props: updatedBlocks.find((b) => b.id === selectedBlockId)!.props, + }); + debouncedSave(updatedBlocks); + setLoaderPickerState({ open: false, propName: null }); + }; + + // Handle removing a loader binding from a prop + const handleRemoveLoaderBinding = (propName: string) => { + if (!selectedBlockId) return; + + const updatedBlocks = blocks.map((block) => { + if (block.id !== selectedBlockId) return block; + const newProps = { ...block.props }; + delete newProps[propName]; + return { ...block, props: newProps }; + }); + + pushBlocks(updatedBlocks); + send({ + type: "deco:update-block", + blockId: selectedBlockId, + props: updatedBlocks.find((b) => b.id === selectedBlockId)!.props, + }); + debouncedSave(updatedBlocks); + }; + + // Manual save (flush debounce) + const handleSave = async () => { + if (!localPage) return; + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + try { + await updatePage(toolCaller, pageId, { + title: localPage.title, + path: localPage.path, + blocks: localPage.blocks, + }); + clearFuture(); + toast.success("Page saved"); + queryClient.invalidateQueries({ + queryKey: queryKeys.pages.detail(connectionId, pageId), + }); + } catch (err) { + toast.error( + `Failed to save: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } + }; + + if (isLoading) { + return ( +
+ +

Loading page...

+
+ ); + } + + if (error) { + return ( +
+ +

Error loading page

+

+ {error instanceof Error ? error.message : "Unknown error"} +

+
+ ); + } + + if (!localPage) { + return ( +
+ +

Page not found

+

+ The page "{pageId}" could not be found. +

+ +
+ ); + } + + return ( +
+ {/* Top bar: breadcrumb, save button, viewport toggle */} +
+
+ + / + {localPage.title} +
+ +
+
+ + +
+ + + +
+
+ + {/* Three-panel layout */} +
+ {/* Left panel: sortable section list */} +
+ setShowBlockPicker(true)} + /> +
+ + {/* Center panel: preview */} +
+ +
+ + {/* Right panel: prop editor or history */} +
+ {showHistory ? ( + + ) : selectedBlock && blockDef?.schema ? ( +
+

+ {blockDef.label ?? + selectedBlock.blockType.replace("sections--", "")} +

+ + + {/* Loader bindings section */} +
+

+ Loader Bindings +

+ + {/* Show existing loader bindings */} + {Object.entries(selectedBlock.props) + .filter(([, value]) => isLoaderRef(value)) + .map(([propName, value]) => { + const ref = value as LoaderRef; + return ( +
+
+ {propName} + + ← {ref.__loaderRef} + +
+ +
+ ); + })} + + {/* Bind loader button for each schema prop */} + {Object.keys( + (blockDef.schema as Record)?.properties ?? + {}, + ) + .filter( + (propName) => !isLoaderRef(selectedBlock.props[propName]), + ) + .map((propName) => ( + + ))} +
+
+ ) : selectedBlockId ? ( +
+

+ Loading block schema... +

+
+ ) : ( +
+

+ Select a section to edit +

+
+ )} +
+
+ + {/* Block picker modal */} + setShowBlockPicker(false)} + onSelect={handleAddBlock} + /> + + {/* Loader picker modal */} + + setLoaderPickerState((prev) => ({ ...prev, open })) + } + onSelect={handleBindLoader} + propName={loaderPickerState.propName ?? ""} + /> +
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/page-diff.tsx b/packages/mesh-plugin-site-editor/client/components/page-diff.tsx new file mode 100644 index 0000000000..aaea2ae182 --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/page-diff.tsx @@ -0,0 +1,270 @@ +/** + * Page Diff Component + * + * Shows a readable diff between a historical version and the current page. + * Compares scalar fields (title, path) and block-level changes (added, removed, modified). + * Uses structured comparison rather than raw JSON diff. + */ + +import { SITE_BINDING } from "@decocms/bindings/site"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { useQuery } from "@tanstack/react-query"; +import { Loading01 } from "@untitledui/icons"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { queryKeys } from "../lib/query-keys"; +import { getPage, type Page, type BlockInstance } from "../lib/page-api"; +import { readFileAt } from "../lib/history-api"; + +interface ScalarChange { + field: string; + oldValue: string; + newValue: string; +} + +interface BlockChange { + type: "added" | "removed" | "modified"; + blockId: string; + blockType: string; + propChanges?: Array<{ + key: string; + oldValue: string; + newValue: string; + }>; +} + +interface PageDiffResult { + scalarChanges: ScalarChange[]; + blockChanges: BlockChange[]; +} + +function truncateValue(value: unknown, maxLen = 60): string { + const str = + typeof value === "string" ? value : (JSON.stringify(value) ?? "undefined"); + return str.length > maxLen ? `${str.slice(0, maxLen)}...` : str; +} + +/** + * Compute a property-level diff between two page versions. + */ +function diffPages(oldPage: Page, newPage: Page): PageDiffResult { + const scalarChanges: ScalarChange[] = []; + const blockChanges: BlockChange[] = []; + + // Compare scalar fields + if (oldPage.title !== newPage.title) { + scalarChanges.push({ + field: "title", + oldValue: oldPage.title, + newValue: newPage.title, + }); + } + if (oldPage.path !== newPage.path) { + scalarChanges.push({ + field: "path", + oldValue: oldPage.path, + newValue: newPage.path, + }); + } + + // Compare blocks by ID + const oldBlockMap = new Map(oldPage.blocks.map((b) => [b.id, b])); + const newBlockMap = new Map(newPage.blocks.map((b) => [b.id, b])); + + // Find removed blocks + for (const [id, block] of oldBlockMap) { + if (!newBlockMap.has(id)) { + blockChanges.push({ + type: "removed", + blockId: id, + blockType: block.blockType, + }); + } + } + + // Find added blocks + for (const [id, block] of newBlockMap) { + if (!oldBlockMap.has(id)) { + blockChanges.push({ + type: "added", + blockId: id, + blockType: block.blockType, + }); + } + } + + // Find modified blocks + for (const [id, newBlock] of newBlockMap) { + const oldBlock = oldBlockMap.get(id); + if (!oldBlock) continue; + + const propChanges = diffBlockProps(oldBlock, newBlock); + if (propChanges.length > 0) { + blockChanges.push({ + type: "modified", + blockId: id, + blockType: newBlock.blockType, + propChanges, + }); + } + } + + return { scalarChanges, blockChanges }; +} + +/** + * Shallow comparison of block props, returning changed keys. + */ +function diffBlockProps( + oldBlock: BlockInstance, + newBlock: BlockInstance, +): Array<{ key: string; oldValue: string; newValue: string }> { + const changes: Array<{ key: string; oldValue: string; newValue: string }> = + []; + const allKeys = new Set([ + ...Object.keys(oldBlock.props), + ...Object.keys(newBlock.props), + ]); + + for (const key of allKeys) { + const oldVal = oldBlock.props[key]; + const newVal = newBlock.props[key]; + + if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + changes.push({ + key, + oldValue: truncateValue(oldVal), + newValue: truncateValue(newVal), + }); + } + } + + return changes; +} + +interface PageDiffProps { + pageId: string; + commitHash: string; +} + +export default function PageDiff({ pageId, commitHash }: PageDiffProps) { + const { toolCaller, connectionId } = usePluginContext(); + + const path = `.deco/pages/${pageId}.json`; + + // Fetch old version at commit + const { data: oldContent, isLoading: isLoadingOld } = useQuery({ + queryKey: queryKeys.history.diff(connectionId, pageId, commitHash), + queryFn: () => readFileAt(toolCaller, path, commitHash), + }); + + // Fetch current version + const { data: currentPage, isLoading: isLoadingCurrent } = useQuery({ + queryKey: queryKeys.pages.detail(connectionId, pageId), + queryFn: () => getPage(toolCaller, pageId), + }); + + if (isLoadingOld || isLoadingCurrent) { + return ( +
+ + Loading diff... +
+ ); + } + + if (!oldContent || !currentPage) { + return ( +

+ Could not load version for comparison. +

+ ); + } + + let oldPage: Page; + try { + oldPage = JSON.parse(oldContent); + } catch { + return ( +

+ Could not parse historical version. +

+ ); + } + + const diff = diffPages(oldPage, currentPage); + + if (diff.scalarChanges.length === 0 && diff.blockChanges.length === 0) { + return ( +

+ No differences found. +

+ ); + } + + return ( +
+ {/* Scalar changes */} + {diff.scalarChanges.map((change) => ( +
+ {change.field} +
+
+ - {change.oldValue} +
+
+ + {change.newValue} +
+
+
+ ))} + + {/* Block changes */} + {diff.blockChanges.map((change) => ( +
+
+ + {change.type} + + + {change.blockType.replace("sections--", "")} + +
+ + {/* Modified block prop changes */} + {change.propChanges && change.propChanges.length > 0 && ( +
+ {change.propChanges.map((pc) => ( +
+ {pc.key}: +
+
+ - {pc.oldValue} +
+
+ + {pc.newValue} +
+
+
+ ))} +
+ )} +
+ ))} +
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/page-editor.tsx b/packages/mesh-plugin-site-editor/client/components/page-editor.tsx new file mode 100644 index 0000000000..06a9eaf541 --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/page-editor.tsx @@ -0,0 +1,16 @@ +/** + * Page Editor Component + * + * Route component for /pages/$pageId. + * Renders the visual PageComposer for block editing with live preview. + */ + +import { siteEditorRouter } from "../lib/router"; +import PageComposer from "./page-composer"; + +export default function PageEditor() { + // Validate route params are available + siteEditorRouter.useParams({ from: "/site-editor-layout/pages/$pageId" }); + + return ; +} diff --git a/packages/mesh-plugin-site-editor/client/components/page-history.tsx b/packages/mesh-plugin-site-editor/client/components/page-history.tsx new file mode 100644 index 0000000000..8769100704 --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/page-history.tsx @@ -0,0 +1,209 @@ +/** + * Page History Panel + * + * Vertical timeline showing version history for a page. + * Each entry displays timestamp, author, message, and actions (view diff, revert). + * Revert creates a new version (non-destructive). + */ + +import { useState } from "react"; +import { SITE_BINDING } from "@decocms/bindings/site"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loading01, RefreshCcw01, AlertCircle } from "@untitledui/icons"; +import { toast } from "sonner"; +import { queryKeys } from "../lib/query-keys"; +import { + getFileHistory, + revertPage, + type HistoryEntry, +} from "../lib/history-api"; +import PageDiff from "./page-diff"; + +/** + * Format a timestamp as a relative time string (e.g., "2 hours ago", "Yesterday"). + */ +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days === 1) return "Yesterday"; + if (days < 30) return `${days}d ago`; + + return new Date(timestamp).toLocaleDateString(); +} + +interface PageHistoryProps { + pageId: string; +} + +export default function PageHistory({ pageId }: PageHistoryProps) { + const { toolCaller, connectionId } = usePluginContext(); + const queryClient = useQueryClient(); + + const [selectedEntry, setSelectedEntry] = useState(null); + const [revertingHash, setRevertingHash] = useState(null); + const [confirmingHash, setConfirmingHash] = useState(null); + + const { + data: entries, + isLoading, + error, + } = useQuery({ + queryKey: queryKeys.history.page(connectionId, pageId), + queryFn: () => + getFileHistory(toolCaller, `.deco/pages/${pageId}.json`, { limit: 50 }), + }); + + const handleRevert = async (entry: HistoryEntry) => { + setRevertingHash(entry.commitHash); + try { + const success = await revertPage(toolCaller, pageId, entry.commitHash); + if (success) { + toast.success("Page reverted successfully"); + // Invalidate both page detail and history queries + queryClient.invalidateQueries({ + queryKey: queryKeys.pages.detail(connectionId, pageId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.history.page(connectionId, pageId), + }); + } else { + toast.error("Failed to revert page"); + } + } catch { + toast.error("Failed to revert page"); + } finally { + setRevertingHash(null); + setConfirmingHash(null); + } + }; + + if (isLoading) { + return ( +
+ +

Loading history...

+
+ ); + } + + if (error) { + return ( +
+ +

Failed to load history

+
+ ); + } + + if (!entries || entries.length === 0) { + return ( +
+

+ No version history available +

+
+ ); + } + + return ( +
+

Version History

+ + {/* Vertical timeline */} +
+ {entries.map((entry) => { + const isSelected = selectedEntry === entry.commitHash; + const isConfirming = confirmingHash === entry.commitHash; + const isReverting = revertingHash === entry.commitHash; + + return ( +
+ {/* Timeline dot */} +
+ + {/* Entry content */} +
+
+ {formatRelativeTime(entry.timestamp)} + | + {entry.author} +
+ +

+ {entry.message.length > 60 + ? `${entry.message.slice(0, 60)}...` + : entry.message} +

+ + {/* Action buttons */} +
+ + + {isConfirming ? ( + + + Are you sure? + + + + + ) : ( + + )} +
+ + {/* Inline diff view */} + {isSelected && ( +
+ +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/pages-list.tsx b/packages/mesh-plugin-site-editor/client/components/pages-list.tsx new file mode 100644 index 0000000000..8b2d5be62f --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/pages-list.tsx @@ -0,0 +1,268 @@ +/** + * Pages List Component + * + * Displays all CMS pages with create and delete actions. + * Navigates to page editor on row click. + * Uses SITE_BINDING tools (READ_FILE, PUT_FILE, LIST_FILES) via page-api helpers. + */ + +import { useState } from "react"; +import { SITE_BINDING } from "@decocms/bindings/site"; +import { usePluginContext } from "@decocms/mesh-sdk/plugins"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@deco/ui/components/dialog.tsx"; +import { + File06, + Plus, + Trash01, + Loading01, + AlertCircle, +} from "@untitledui/icons"; +import { toast } from "sonner"; +import { queryKeys } from "../lib/query-keys"; +import { siteEditorRouter } from "../lib/router"; +import { listPages, createPage, deletePage } from "../lib/page-api"; + +function formatDate(dateStr: string): string { + if (!dateStr) return "-"; + try { + return new Date(dateStr).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateStr; + } +} + +export default function PagesList() { + const { toolCaller, connectionId } = usePluginContext(); + const queryClient = useQueryClient(); + const navigate = siteEditorRouter.useNavigate(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [newTitle, setNewTitle] = useState(""); + const [newPath, setNewPath] = useState("/"); + + // Fetch pages list + const { + data: pages = [], + isLoading, + error, + } = useQuery({ + queryKey: queryKeys.pages.all(connectionId), + queryFn: () => listPages(toolCaller), + }); + + // Create page mutation + const createMutation = useMutation({ + mutationFn: (input: { title: string; path: string }) => + createPage(toolCaller, input), + onSuccess: (page) => { + toast.success(`Created page "${page.title}"`); + queryClient.invalidateQueries({ + queryKey: queryKeys.pages.all(connectionId), + }); + setDialogOpen(false); + setNewTitle(""); + setNewPath("/"); + navigate({ + to: "/site-editor-layout/pages/$pageId", + params: { pageId: page.id }, + }); + }, + onError: (err) => { + toast.error( + `Failed to create page: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + }, + }); + + // Delete page mutation + const deleteMutation = useMutation({ + mutationFn: (pageId: string) => deletePage(toolCaller, pageId), + onSuccess: () => { + toast.success("Page deleted"); + queryClient.invalidateQueries({ + queryKey: queryKeys.pages.all(connectionId), + }); + }, + onError: (err) => { + toast.error( + `Failed to delete page: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + }, + }); + + const handleCreate = () => { + if (!newTitle.trim()) return; + createMutation.mutate({ title: newTitle.trim(), path: newPath.trim() }); + }; + + const handleDelete = (e: React.MouseEvent, pageId: string) => { + e.stopPropagation(); + if (confirm("Are you sure you want to delete this page?")) { + deleteMutation.mutate(pageId); + } + }; + + if (error) { + return ( +
+ +

Error loading pages

+

+ {error instanceof Error ? error.message : "Unknown error"} +

+
+ ); + } + + return ( +
+ {/* Header */} +
+

Pages

+ + + + + + + Create New Page + + Add a new page to your site. You can edit its content after + creation. + + +
+
+ + setNewTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> +
+
+ + setNewPath(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> +
+
+ + + +
+
+
+ + {/* Table header */} +
+ Title + Path + Last Updated + +
+ + {/* Page list */} +
+ {isLoading ? ( +
+ +

Loading pages...

+
+ ) : pages.length === 0 ? ( +
+ +

No pages yet

+

+ Create your first page to get started. +

+ +
+ ) : ( + pages.map((page) => ( + +
+ + )) + )} +
+
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/plugin-empty-state.tsx b/packages/mesh-plugin-site-editor/client/components/plugin-empty-state.tsx new file mode 100644 index 0000000000..ac8235a36d --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/plugin-empty-state.tsx @@ -0,0 +1,171 @@ +/** + * Plugin Empty State Component + * + * Shown when no site connections are available or configured. + * Provides an inline setup wizard to connect a local project folder. + */ + +import { Folder } from "@untitledui/icons"; +import { useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; +import { + useConnectionActions, + useProjectContext, + useMCPClient, + SELF_MCP_ALIAS_ID, + KEYS, + Locator, +} from "@decocms/mesh-sdk"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; + +export default function PluginEmptyState() { + const [path, setPath] = useState(""); + const [isConnecting, setIsConnecting] = useState(false); + const [isBrowsing, setIsBrowsing] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + const { org, project } = useProjectContext(); + const { pluginId } = useParams({ strict: false }) as { pluginId: string }; + const { create } = useConnectionActions(); + const queryClient = useQueryClient(); + + const selfClient = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + + const handleBrowse = async () => { + setIsBrowsing(true); + setError(null); + try { + const result = (await selfClient.callTool({ + name: "FILESYSTEM_PICK_DIRECTORY", + arguments: {}, + })) as { structuredContent?: { path: string | null } }; + + const selected = + (result.structuredContent?.path as string | null) ?? null; + if (selected) { + setPath(selected); + } + } catch { + setError("Could not open folder picker"); + } finally { + setIsBrowsing(false); + } + }; + + const handleConnect = async (e: React.FormEvent) => { + e.preventDefault(); + + const trimmed = path.trim(); + if (!trimmed) return; + + setIsConnecting(true); + setError(null); + + try { + const folderName = trimmed.split("/").filter(Boolean).pop() ?? "site"; + + // 1. Create the STDIO connection + const newConnection = await create.mutateAsync({ + title: `Site: ${folderName}`, + connection_type: "STDIO", + connection_headers: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", trimmed], + }, + } as Parameters[0]); + + // 2. Bind the new connection to this plugin via project config + await selfClient.callTool({ + name: "PROJECT_PLUGIN_CONFIG_UPDATE", + arguments: { + projectId: project.id, + pluginId, + connectionId: newConnection.id, + }, + }); + + // 3. Invalidate queries so PluginLayout re-renders with the connection + const locator = Locator.from({ + org: org.slug, + project: project.slug ?? "", + }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: KEYS.connections(locator), + }), + queryClient.invalidateQueries({ + queryKey: ["project-plugin-config", project.id, pluginId], + }), + ]); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to create connection", + ); + } finally { + setIsConnecting(false); + } + }; + + const busy = isConnecting || isBrowsing; + + return ( +
+
+ {/* Clickable browse area */} + + + {/* Divider */} +
+
+ + or enter path manually + +
+
+ + {/* Manual path input */} + setPath(e.target.value)} + disabled={busy} + className="w-full" + /> + + {error && ( +

{error}

+ )} + + + +
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/plugin-header.tsx b/packages/mesh-plugin-site-editor/client/components/plugin-header.tsx new file mode 100644 index 0000000000..9cf93fd3d6 --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/plugin-header.tsx @@ -0,0 +1,117 @@ +/** + * Plugin Header Component + * + * Connection selector and branch switcher for the site editor plugin. + * Uses native HTML elements to avoid type conflicts with UI package. + */ + +import type { PluginRenderHeaderProps } from "@decocms/bindings/plugins"; +import { File06, ChevronDown, Check } from "@untitledui/icons"; +import { useState, useRef, lazy, Suspense } from "react"; + +const BranchSwitcher = lazy(() => import("./branch-switcher")); +const PublishBar = lazy(() => import("./publish-bar")); + +/** + * Simple dropdown menu using native elements. + */ +function ConnectionSelector({ + connections, + selectedConnectionId, + onConnectionChange, +}: PluginRenderHeaderProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedConnection = connections.find( + (c) => c.id === selectedConnectionId, + ); + + // Close dropdown when clicking outside + const handleBlur = (e: React.FocusEvent) => { + if (!dropdownRef.current?.contains(e.relatedTarget as Node)) { + setIsOpen(false); + } + }; + + if (connections.length === 1) { + return ( +
+ {selectedConnection?.icon ? ( + + ) : ( + + )} + {selectedConnection?.title || "Site"} +
+ ); + } + + return ( +
+ + + {isOpen && ( +
+ {connections.map((connection) => ( + + ))} +
+ )} +
+ ); +} + +export default function PluginHeader(props: PluginRenderHeaderProps) { + return ( +
+
+ + + + +
+ + + +
+ ); +} diff --git a/packages/mesh-plugin-site-editor/client/components/preview-panel.tsx b/packages/mesh-plugin-site-editor/client/components/preview-panel.tsx new file mode 100644 index 0000000000..bdd5f82fc9 --- /dev/null +++ b/packages/mesh-plugin-site-editor/client/components/preview-panel.tsx @@ -0,0 +1,121 @@ +/** + * Preview Panel Component + * + * Renders a full-size iframe pointing to the user's running local dev server. + * When no preview URL is configured, shows a form to enter the dev server URL + * (e.g., http://localhost:5173 or a deco link tunnel URL). + * + * The URL is persisted in connection metadata so it survives page reloads. + */ + +import { useState } from "react"; +import { useTunnelUrl } from "../lib/use-tunnel-url"; +import { useIframeBridge } from "../lib/use-iframe-bridge"; +import { VIEWPORTS, type ViewportKey } from "./viewport-toggle"; +import type { Page } from "../lib/page-api"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import { Globe02, Loading01, LinkExternal01 } from "@untitledui/icons"; + +interface PreviewPanelProps { + /** Page path to preview (e.g., "/", "/about") */ + path?: string; + /** Current page data to send to the iframe */ + page: Page | null; + /** Currently selected block ID for highlighting */ + selectedBlockId: string | null; + /** Current viewport size */ + viewport: ViewportKey; + /** Called when user clicks a block in the preview */ + onBlockClicked: (blockId: string) => void; +} + +export function PreviewPanel({ + path = "/", + page, + selectedBlockId, + viewport, + onBlockClicked, +}: PreviewPanelProps) { + const { url, isLoading, setPreviewUrl, isSaving } = useTunnelUrl(); + const { setIframeRef } = useIframeBridge({ + page, + selectedBlockId, + onBlockClicked, + }); + const [inputUrl, setInputUrl] = useState("http://localhost:5173"); + + if (isLoading) { + return ( +
+ + Loading preview... + +
+ ); + } + + if (!url) { + return ( +
+ +
+

+ Connect your dev server +

+

+ Enter your local dev server URL to see a live preview +

+
+
{ + e.preventDefault(); + if (inputUrl.trim()) { + setPreviewUrl(inputUrl.trim()); + } + }} + > + setInputUrl(e.target.value)} + placeholder="http://localhost:5173" + className="flex-1" + /> + +
+

+ Or use a tunnel URL from{" "} + + deco link + +

+
+ ); + } + + const previewUrl = path !== "/" ? `${url}${path}` : url; + const viewportWidth = VIEWPORTS[viewport]?.width; + + return ( +
+