"use client"; import { useEffect, useState, useCallback, Suspense } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { useSession } from "next-auth/react"; // ── Types ───────────────────────────────────────────────────────────────────── interface FileItem { name: string; path: string; type: "file" | "dir" | "symlink"; size?: number; } interface TreeNode { name: string; path: string; type: "file" | "dir"; children?: TreeNode[]; expanded?: boolean; loaded?: boolean; } // ── Language detection ──────────────────────────────────────────────────────── function langFromName(name: string): string { const ext = name.split(".").pop()?.toLowerCase() ?? ""; const map: Record = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", json: "json", md: "markdown", mdx: "markdown", css: "css", scss: "css", html: "html", py: "python", sh: "shell", yaml: "yaml", yml: "yaml", toml: "toml", prisma: "prisma", sql: "sql", env: "dotenv", gitignore: "shell", dockerfile: "dockerfile", }; return map[ext] ?? "text"; } // ── Simple token highlighter ────────────────────────────────────────────────── function highlightCode(code: string, lang: string): React.ReactNode[] { return code.split("\n").map((line, i) => { if (lang === "text" || lang === "dotenv" || lang === "dockerfile") { return
{line || "\u00a0"}
; } const commentPrefixes = ["//", "#", "--"]; if (commentPrefixes.some(p => line.trimStart().startsWith(p))) { return
{line}
; } const kwRe = /\b(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|implements|new|default|null|undefined|true|false|void|string|number|boolean|object|Promise|React)\b/g; const parts = line.split(kwRe); const tokens = parts.map((part, j) => { if (!part) return null; if (/^(import|export|from|const|let|var|function|return|if|else|async|await|type|interface|class|extends|implements|new|default|null|undefined|true|false|void|string|number|boolean|object|Promise|React)$/.test(part)) { return {part}; } if (/^(['"`]).*\1$/.test(part.trim())) { return {part}; } return {part}; }); return
{tokens.length ? tokens : "\u00a0"}
; }); } // ── Tree row ────────────────────────────────────────────────────────────────── function TreeRow({ node, depth, selectedPath, onSelect, onToggle, }: { node: TreeNode; depth: number; selectedPath: string | null; onSelect: (path: string) => void; onToggle: (path: string) => void; }) { const isSelected = selectedPath === node.path; const isDir = node.type === "dir"; const ext = node.name.split(".").pop()?.toLowerCase() ?? ""; const fileColor = ext === "tsx" || ext === "ts" ? "#3178c6" : ext === "jsx" || ext === "js" ? "#f0db4f" : ext === "css" || ext === "scss" ? "#e879f9" : ext === "json" ? "#a09a90" : ext === "md" || ext === "mdx" ? "#6b6560" : "#b5b0a6"; return ( <> {isDir && node.expanded && node.children?.map(child => ( ))} ); } // ── Empty state ─────────────────────────────────────────────────────────────── function EmptyState() { return (
Select an app to browse
Choose one of your apps from the Build section in the left sidebar to explore its files.
); } // ── Inner page (needs useSearchParams) ─────────────────────────────────────── function BuildPageInner() { const params = useParams(); const searchParams = useSearchParams(); const projectId = params.projectId as string; const { status: authStatus } = useSession(); // Which app the user clicked (from sidebar link) const appName = searchParams.get("app") ?? ""; const rootPath = searchParams.get("root") ?? ""; const [tree, setTree] = useState([]); const [treeLoading, setTreeLoading] = useState(false); const [treeError, setTreeError] = useState(null); const [selectedPath, setSelectedPath] = useState(null); const [fileContent, setFileContent] = useState(null); const [fileLoading, setFileLoading] = useState(false); const [fileName, setFileName] = useState(null); const fetchDir = useCallback(async (path: string): Promise => { const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "Failed to load"); const items: FileItem[] = data.items ?? []; return items .filter(item => item.type !== "symlink") .sort((a, b) => { if (a.type === "dir" && b.type !== "dir") return -1; if (a.type !== "dir" && b.type === "dir") return 1; return a.name.localeCompare(b.name); }) .map(item => ({ name: item.name, path: item.path, type: item.type === "dir" ? "dir" : "file", expanded: false, loaded: item.type !== "dir", children: item.type === "dir" ? [] : undefined, })); }, [projectId]); // Load the app's root dir whenever app changes useEffect(() => { if (!rootPath || authStatus !== "authenticated") return; setTree([]); setSelectedPath(null); setFileContent(null); setTreeError(null); setTreeLoading(true); fetchDir(rootPath) .then(nodes => { setTree(nodes); setTreeLoading(false); }) .catch(e => { setTreeError(e.message); setTreeLoading(false); }); }, [rootPath, authStatus, fetchDir]); // Toggle dir expand/collapse with lazy-load const handleToggle = useCallback(async (path: string) => { setTree(prev => { const toggle = (nodes: TreeNode[]): TreeNode[] => nodes.map(n => { if (n.path === path) return { ...n, expanded: !n.expanded }; if (n.children) return { ...n, children: toggle(n.children) }; return n; }); return toggle(prev); }); const findNode = (nodes: TreeNode[], p: string): TreeNode | null => { for (const n of nodes) { if (n.path === p) return n; if (n.children) { const f = findNode(n.children, p); if (f) return f; } } return null; }; const node = findNode(tree, path); if (node && !node.loaded) { try { const children = await fetchDir(path); setTree(prev => { const update = (nodes: TreeNode[]): TreeNode[] => nodes.map(n => { if (n.path === path) return { ...n, children, loaded: true }; if (n.children) return { ...n, children: update(n.children) }; return n; }); return update(prev); }); } catch { /* silently fail */ } } }, [tree, fetchDir]); // Select a file and load its content const handleSelectFile = useCallback(async (path: string) => { setSelectedPath(path); setFileContent(null); setFileName(path.split("/").pop() ?? null); setFileLoading(true); try { const res = await fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`); const data = await res.json(); setFileContent(data.content ?? ""); } catch { setFileContent("// Failed to load file content"); } finally { setFileLoading(false); } }, [projectId]); const lang = fileName ? langFromName(fileName) : "text"; const lines = (fileContent ?? "").split("\n"); if (!appName || !rootPath) { return ; } return (
{/* ── File tree ── */}
{/* App name header */}
{appName}
{/* Tree */}
{treeLoading && (
Loading…
)} {treeError && (
{treeError}
)} {!treeLoading && !treeError && tree.length === 0 && (
Empty folder.
)} {tree.map(node => ( ))}
{/* ── Code preview ── */}
{/* Breadcrumb bar */}
{selectedPath ? ( {/* Show path relative to rootPath */} {(() => { const rel = selectedPath.startsWith(rootPath + "/") ? selectedPath.slice(rootPath.length + 1) : selectedPath; return rel.split("/").map((seg, i, arr) => ( {i > 0 && /} {seg} )); })()} ) : ( Select a file to view )} {fileName && ( {lang} )}
{/* Code area */}
{!selectedPath && !fileLoading && (
Select a file from the tree
)} {fileLoading && (
Loading…
)} {!fileLoading && fileContent !== null && (
{/* Line numbers */}
{lines.map((_, i) => (
{i + 1}
))}
{/* Code */}
{highlightCode(fileContent, lang)}
)}
); } // ── Page export (Suspense wraps useSearchParams) ────────────────────────────── export default function BuildPage() { return ( Loading…}> ); }