From 55cc5460e35bee7e02302fe1ba3b6e1be586ef7c Mon Sep 17 00:00:00 2001 From: Will Chen Date: Fri, 15 Aug 2025 17:56:44 -0700 Subject: [PATCH] Support next.js for routes and handle long width address bar (#958) --- .../preview_panel/PreviewIframe.tsx | 56 +----- src/hooks/useParseRouter.ts | 167 ++++++++++++++++++ 2 files changed, 173 insertions(+), 50 deletions(-) create mode 100644 src/hooks/useParseRouter.ts diff --git a/src/components/preview_panel/PreviewIframe.tsx b/src/components/preview_panel/PreviewIframe.tsx index 1ab3dc5..4238a07 100644 --- a/src/components/preview_panel/PreviewIframe.tsx +++ b/src/components/preview_panel/PreviewIframe.tsx @@ -23,7 +23,7 @@ import { import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { IpcClient } from "@/ipc/ipc_client"; -import { useLoadAppFile } from "@/hooks/useLoadAppFile"; +import { useParseRouter } from "@/hooks/useParseRouter"; import { DropdownMenu, DropdownMenuContent, @@ -128,52 +128,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { const [errorMessage, setErrorMessage] = useAtom(previewErrorMessageAtom); const selectedChatId = useAtomValue(selectedChatIdAtom); const { streamMessage } = useStreamChat(); - const [availableRoutes, setAvailableRoutes] = useState< - Array<{ path: string; label: string }> - >([]); + const { routes: availableRoutes } = useParseRouter(selectedAppId); const { restartApp } = useRunApp(); - // Load router related files to extract routes - const { content: routerContent } = useLoadAppFile( - selectedAppId, - "src/App.tsx", - ); - - // Effect to parse routes from the router file - useEffect(() => { - if (routerContent) { - try { - const routes: Array<{ path: string; label: string }> = []; - - // Extract route imports and paths using regex for React Router syntax - // Match - const routePathsRegex = /]*\s+)?path=["']([^"']+)["']/g; - let match; - - // Find all route paths in the router content - while ((match = routePathsRegex.exec(routerContent)) !== null) { - const path = match[1]; - // Create a readable label from the path - const label = - path === "/" - ? "Home" - : path - .split("/") - .filter((segment) => segment && !segment.startsWith(":")) - .pop() - ?.replace(/[-_]/g, " ") - .replace(/^\w/, (c) => c.toUpperCase()) || path; - - if (!routes.some((r) => r.path === path)) { - routes.push({ path, label }); - } - } - - setAvailableRoutes(routes); - } catch (e) { - console.error("Error parsing router file:", e); - } - } - }, [routerContent]); // Navigation state const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] = @@ -502,17 +458,17 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { {/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */} -
+
-
- +
+ {navigationHistory[currentHistoryPosition] ? new URL(navigationHistory[currentHistoryPosition]) .pathname : "/"} - +
diff --git a/src/hooks/useParseRouter.ts b/src/hooks/useParseRouter.ts new file mode 100644 index 0000000..f1c4ae1 --- /dev/null +++ b/src/hooks/useParseRouter.ts @@ -0,0 +1,167 @@ +import { useEffect, useMemo, useState } from "react"; +import { useLoadAppFile } from "@/hooks/useLoadAppFile"; +import { useLoadApp } from "@/hooks/useLoadApp"; + +export interface ParsedRoute { + path: string; + label: string; +} + +/** + * Loads the app router file and parses available routes for quick navigation. + */ +export function useParseRouter(appId: number | null) { + const [routes, setRoutes] = useState([]); + + // Load app to access the file list + const { + app, + loading: appLoading, + error: appError, + refreshApp, + } = useLoadApp(appId); + + // Load router related file to extract routes for non-Next apps + const { + content: routerContent, + loading: routerFileLoading, + error: routerFileError, + refreshFile, + } = useLoadAppFile(appId, "src/App.tsx"); + + // Detect Next.js app by presence of next.config.* in file list + const isNextApp = useMemo(() => { + if (!app?.files) return false; + return app.files.some((f) => f.toLowerCase().includes("next.config")); + }, [app?.files]); + + // Parse routes either from Next.js file-based routing or from router file + useEffect(() => { + const buildLabel = (path: string) => + path === "/" + ? "Home" + : path + .split("/") + .filter((segment) => segment && !segment.startsWith(":")) + .pop() + ?.replace(/[-_]/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()) || path; + + const setFromNextFiles = (files: string[]) => { + const nextRoutes = new Set(); + + // pages directory (pages router) + const pageFileRegex = /^(?:pages)\/(.+)\.(?:js|jsx|ts|tsx|mdx)$/i; + for (const file of files) { + if (!file.startsWith("pages/")) continue; + if (file.startsWith("pages/api/")) continue; // skip api routes + const baseName = file.split("/").pop() || ""; + if (baseName.startsWith("_")) continue; // _app, _document, etc. + + const m = file.match(pageFileRegex); + if (!m) continue; + let routePath = m[1]; + + // Ignore dynamic routes containing [ ] + if (routePath.includes("[")) continue; + + // Normalize index files + if (routePath === "index") { + nextRoutes.add("/"); + continue; + } + if (routePath.endsWith("/index")) { + routePath = routePath.slice(0, -"/index".length); + } + + nextRoutes.add("/" + routePath); + } + + // app directory (app router) + const appPageRegex = + /^(?:src\/)?app\/(.*)\/page\.(?:js|jsx|ts|tsx|mdx)$/i; + for (const file of files) { + const lower = file.toLowerCase(); + if ( + lower === "app/page.tsx" || + lower === "app/page.jsx" || + lower === "app/page.js" || + lower === "app/page.mdx" || + lower === "app/page.ts" || + lower === "src/app/page.tsx" || + lower === "src/app/page.jsx" || + lower === "src/app/page.js" || + lower === "src/app/page.mdx" || + lower === "src/app/page.ts" + ) { + nextRoutes.add("/"); + continue; + } + const m = file.match(appPageRegex); + if (!m) continue; + const routeSeg = m[1]; + // Ignore dynamic segments and grouping folders like (marketing) + if (routeSeg.includes("[")) continue; + const cleaned = routeSeg + .split("/") + .filter((s) => s && !s.startsWith("(")) + .join("/"); + if (!cleaned) { + nextRoutes.add("/"); + } else { + nextRoutes.add("/" + cleaned); + } + } + + const parsed = Array.from(nextRoutes).map((path) => ({ + path, + label: buildLabel(path), + })); + setRoutes(parsed); + }; + + const setFromRouterFile = (content: string | null) => { + if (!content) { + setRoutes([]); + return; + } + + try { + const parsedRoutes: ParsedRoute[] = []; + const routePathsRegex = /]*\s+)?path=["']([^"']+)["']/g; + let match: RegExpExecArray | null; + + while ((match = routePathsRegex.exec(content)) !== null) { + const path = match[1]; + const label = buildLabel(path); + if (!parsedRoutes.some((r) => r.path === path)) { + parsedRoutes.push({ path, label }); + } + } + setRoutes(parsedRoutes); + } catch (e) { + console.error("Error parsing router file:", e); + setRoutes([]); + } + }; + + if (isNextApp && app?.files) { + setFromNextFiles(app.files); + } else { + setFromRouterFile(routerContent ?? null); + } + }, [isNextApp, app?.files, routerContent]); + + const combinedLoading = appLoading || routerFileLoading; + const combinedError = appError || routerFileError || null; + const refresh = async () => { + await Promise.allSettled([refreshApp(), refreshFile()]); + }; + + return { + routes, + loading: combinedLoading, + error: combinedError, + refreshFile: refresh, + }; +}