Support next.js for routes and handle long width address bar (#958)
This commit is contained in:
@@ -23,7 +23,7 @@ import {
|
|||||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||||
import { IpcClient } from "@/ipc/ipc_client";
|
import { IpcClient } from "@/ipc/ipc_client";
|
||||||
|
|
||||||
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
|
import { useParseRouter } from "@/hooks/useParseRouter";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -128,52 +128,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
const [errorMessage, setErrorMessage] = useAtom(previewErrorMessageAtom);
|
const [errorMessage, setErrorMessage] = useAtom(previewErrorMessageAtom);
|
||||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||||
const { streamMessage } = useStreamChat();
|
const { streamMessage } = useStreamChat();
|
||||||
const [availableRoutes, setAvailableRoutes] = useState<
|
const { routes: availableRoutes } = useParseRouter(selectedAppId);
|
||||||
Array<{ path: string; label: string }>
|
|
||||||
>([]);
|
|
||||||
const { restartApp } = useRunApp();
|
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
|
// Navigation state
|
||||||
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
|
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
|
||||||
@@ -502,17 +458,17 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
|
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
|
||||||
<div className="relative flex-grow">
|
<div className="relative flex-grow min-w-20">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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">
|
<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>
|
<span className="truncate flex-1 mr-2 min-w-0">
|
||||||
{navigationHistory[currentHistoryPosition]
|
{navigationHistory[currentHistoryPosition]
|
||||||
? new URL(navigationHistory[currentHistoryPosition])
|
? new URL(navigationHistory[currentHistoryPosition])
|
||||||
.pathname
|
.pathname
|
||||||
: "/"}
|
: "/"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} className="flex-shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-full">
|
<DropdownMenuContent className="w-full">
|
||||||
|
|||||||
167
src/hooks/useParseRouter.ts
Normal file
167
src/hooks/useParseRouter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user