Support next.js for routes and handle long width address bar (#958)

This commit is contained in:
Will Chen
2025-08-15 17:56:44 -07:00
committed by GitHub
parent e554fd962b
commit 55cc5460e3
2 changed files with 173 additions and 50 deletions

View File

@@ -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 <Route path="/path">
const routePathsRegex = /<Route\s+(?:[^>]*\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 }) => {
</div>
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
<div className="relative flex-grow">
<div className="relative flex-grow min-w-20">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full">
<span>
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full min-w-0">
<span className="truncate flex-1 mr-2 min-w-0">
{navigationHistory[currentHistoryPosition]
? new URL(navigationHistory[currentHistoryPosition])
.pathname
: "/"}
</span>
<ChevronDown size={14} />
<ChevronDown size={14} className="flex-shrink-0" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">

167
src/hooks/useParseRouter.ts Normal file
View File

@@ -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<ParsedRoute[]>([]);
// 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<string>();
// 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 = /<Route\s+(?:[^>]*\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,
};
}