diff --git a/app/[workspace]/project/[projectId]/build/page.tsx b/app/[workspace]/project/[projectId]/build/page.tsx index bbe513d..3e05f7d 100644 --- a/app/[workspace]/project/[projectId]/build/page.tsx +++ b/app/[workspace]/project/[projectId]/build/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; -import { useParams } from "next/navigation"; +import { useEffect, useState, useCallback, Suspense } from "react"; +import { useParams, useSearchParams } from "next/navigation"; import { useSession } from "next-auth/react"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -22,7 +22,7 @@ interface TreeNode { loaded?: boolean; } -// ── Language detection for syntax colouring hint ───────────────────────────── +// ── Language detection ──────────────────────────────────────────────────────── function langFromName(name: string): string { const ext = name.split(".").pop()?.toLowerCase() ?? ""; @@ -37,199 +37,195 @@ function langFromName(name: string): string { return map[ext] ?? "text"; } -// ── Icons ───────────────────────────────────────────────────────────────────── - -function FileIcon({ name, type }: { name: string; type: "file" | "dir" }) { - if (type === "dir") return ; - const ext = name.split(".").pop()?.toLowerCase() ?? ""; - const color = - ext === "tsx" || ext === "ts" ? "#3178c6" - : ext === "jsx" || ext === "js" ? "#f0db4f" - : ext === "json" ? "#a09a90" - : ext === "css" || ext === "scss" ? "#e879f9" - : ext === "md" || ext === "mdx" ? "#6b6560" - : ext === "prisma" ? "#5a67d8" - : "#b5b0a6"; - return ; -} - -// ── Simple token-based highlighter ──────────────────────────────────────────── +// ── Simple token highlighter ────────────────────────────────────────────────── function highlightCode(code: string, lang: string): React.ReactNode[] { - if (lang === "text" || lang === "dotenv" || lang === "dockerfile") { - return code.split("\n").map((line, i) => ( -
{line || " "}
- )); - } - - // Split into lines and apply simple token colouring per line return code.split("\n").map((line, i) => { - const tokens: React.ReactNode[] = []; - let remaining = line; - let ki = 0; - - // Comment + if (lang === "text" || lang === "dotenv" || lang === "dockerfile") { + return
{line || "\u00a0"}
; + } const commentPrefixes = ["//", "#", "--"]; - for (const p of commentPrefixes) { - if (remaining.trimStart().startsWith(p)) { - tokens.push({remaining}); - remaining = ""; - break; - } + if (commentPrefixes.some(p => line.trimStart().startsWith(p))) { + return
{line}
; } - - if (remaining) { - // Keywords - 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 = remaining.split(kwRe); - for (let j = 0; j < parts.length; j++) { - const part = parts[j]; - if (!part) continue; - if (kwRe.test(part) || /^(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)) { - tokens.push({part}); - } else if (/^(['"`]).*\1$/.test(part.trim())) { - tokens.push({part}); - } else { - tokens.push({part}); - } + 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}; } - } - - return ( -
- {tokens.length > 0 ? tokens : " "} -
- ); + if (/^(['"`]).*\1$/.test(part.trim())) { + return {part}; + } + return {part}; + }); + return
{tokens.length ? tokens : "\u00a0"}
; }); } -// ── File tree node ──────────────────────────────────────────────────────────── +// ── Tree row ────────────────────────────────────────────────────────────────── -function TreeNodeRow({ - node, - depth, - selectedPath, - onSelect, - onToggle, - projectId, +function TreeRow({ + node, depth, selectedPath, onSelect, onToggle, }: { node: TreeNode; depth: number; selectedPath: string | null; - onSelect: (path: string, type: "file" | "dir") => void; + onSelect: (path: string) => void; onToggle: (path: string) => void; - projectId: string; }) { 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 && ( - node.children.map(child => ( - - )) - )} + {isDir && node.expanded && node.children?.map(child => ( + + ))} ); } -// ── Main page ───────────────────────────────────────────────────────────────── +// ── Empty state ─────────────────────────────────────────────────────────────── -export default function BuildPage() { +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(); - // File tree state - const [tree, setTree] = useState([]); - const [treeLoading, setTreeLoading] = useState(true); - const [treeError, setTreeError] = useState(null); + // Which app the user clicked (from sidebar link) + const appName = searchParams.get("app") ?? ""; + const rootPath = searchParams.get("root") ?? ""; - // File content state + 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); - // Fetch a directory listing and return items 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.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, - })); + 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 root tree on mount + // Load the app's root dir whenever app changes useEffect(() => { - if (authStatus !== "authenticated") return; + if (!rootPath || authStatus !== "authenticated") return; + setTree([]); + setSelectedPath(null); + setFileContent(null); + setTreeError(null); setTreeLoading(true); - fetchDir("") + fetchDir(rootPath) .then(nodes => { setTree(nodes); setTreeLoading(false); }) .catch(e => { setTreeError(e.message); setTreeLoading(false); }); - }, [authStatus, fetchDir]); + }, [rootPath, authStatus, fetchDir]); - // Toggle dir expand/collapse + // 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.path === path) return { ...n, expanded: !n.expanded }; if (n.children) return { ...n, children: toggle(n.children) }; return n; }); return toggle(prev); }); - // Lazy-load children if not yet fetched const findNode = (nodes: TreeNode[], p: string): TreeNode | null => { for (const n of nodes) { if (n.path === p) return n; - if (n.children) { const found = findNode(n.children, p); if (found) return found; } + if (n.children) { const f = findNode(n.children, p); if (f) return f; } } return null; }; @@ -251,7 +247,7 @@ export default function BuildPage() { } }, [tree, fetchDir]); - // Select file and load content + // Select a file and load its content const handleSelectFile = useCallback(async (path: string) => { setSelectedPath(path); setFileContent(null); @@ -271,88 +267,89 @@ export default function BuildPage() { const lang = fileName ? langFromName(fileName) : "text"; const lines = (fileContent ?? "").split("\n"); - return ( -
+ if (!appName || !rootPath) { + return ; + } - {/* ── File tree panel ── */} + return ( +
+ + {/* ── File tree ── */}
- {/* Tree header */} + {/* App name header */}
- Files + + + {appName} +
- {/* Tree content */} + {/* Tree */}
{treeLoading && ( -
- Loading… -
+
Loading…
)} {treeError && ( -
- {treeError === "No Gitea repo connected" - ? "No repository connected yet. Create a project to get started." - : treeError} -
+
{treeError}
)} {!treeLoading && !treeError && tree.length === 0 && ( -
- Repository is empty. -
+
Empty folder.
)} {tree.map(node => ( - ))}
- {/* ── Code preview panel ── */} + {/* ── Code preview ── */}
- {/* File path breadcrumb */} + {/* Breadcrumb bar */}
{selectedPath ? ( - {selectedPath.split("/").map((seg, i, arr) => ( - - {i > 0 && /} - {seg} - - ))} + {/* 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 )} - {selectedPath && ( - + {fileName && ( + {lang} )} @@ -361,12 +358,12 @@ export default function BuildPage() { {/* Code area */}
{!selectedPath && !fileLoading && ( -
+
Select a file from the tree
)} {fileLoading && ( -
+
Loading…
)} @@ -374,36 +371,25 @@ export default function BuildPage() {
{/* Line numbers */}
{lines.map((_, i) => (
{i + 1}
))}
- {/* Code content */} + {/* Code */}
{highlightCode(fileContent, lang)}
@@ -414,3 +400,13 @@ export default function BuildPage() {
); } + +// ── Page export (Suspense wraps useSearchParams) ────────────────────────────── + +export default function BuildPage() { + return ( + Loading…
}> + + + ); +} diff --git a/components/layout/vibn-sidebar.tsx b/components/layout/vibn-sidebar.tsx index 8b40ec3..75d008c 100644 --- a/components/layout/vibn-sidebar.tsx +++ b/components/layout/vibn-sidebar.tsx @@ -312,15 +312,15 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
)} - {/* ── Apps ── */} - + {/* ── Build ── */} + {apps.length > 0 ? ( apps.map(app => ( ))