From e08fcf674bb84375045ba40909ed62a1426c424e Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Fri, 6 Mar 2026 13:37:38 -0800 Subject: [PATCH] feat: VIBN-branded file browser on Build tab + sidebar status dot - Build page: full file tree (lazy-load dirs) + code preview panel with line numbers and token-level syntax colouring (VS Code dark theme) - New API route /api/projects/[id]/file proxies Gitea contents API returning directory listings or decoded file content - Sidebar Apps section now links to /build instead of raw Gitea URL - Status indicator replaced with a proper coloured dot (amber/blue/green) alongside the status label text Made-with: Cursor --- .../project/[projectId]/build/page.tsx | 785 ++++++++---------- app/api/projects/[projectId]/file/route.ts | 108 +++ components/layout/vibn-sidebar.tsx | 21 +- 3 files changed, 486 insertions(+), 428 deletions(-) create mode 100644 app/api/projects/[projectId]/file/route.ts diff --git a/app/[workspace]/project/[projectId]/build/page.tsx b/app/[workspace]/project/[projectId]/build/page.tsx index 8cc4194..bbe513d 100644 --- a/app/[workspace]/project/[projectId]/build/page.tsx +++ b/app/[workspace]/project/[projectId]/build/page.tsx @@ -1,475 +1,416 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { useParams } from "next/navigation"; -import Link from "next/link"; +import { useSession } from "next-auth/react"; -interface App { +// ── Types ───────────────────────────────────────────────────────────────────── + +interface FileItem { name: string; - type: string; - description: string; - tech: string[]; - screens: string[]; + path: string; + type: "file" | "dir" | "symlink"; + size?: number; } -interface Package { +interface TreeNode { name: string; - description: string; + path: string; + type: "file" | "dir"; + children?: TreeNode[]; + expanded?: boolean; + loaded?: boolean; } -interface Infra { - name: string; - reason: string; -} +// ── Language detection for syntax colouring hint ───────────────────────────── -interface Integration { - name: string; - required: boolean; - notes: string; -} - -interface Architecture { - productName: string; - productType: string; - summary: string; - apps: App[]; - packages: Package[]; - infrastructure: Infra[]; - integrations: Integration[]; - designSurfaces: string[]; - riskNotes: string[]; -} - -function SectionLabel({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -function AppCard({ app }: { app: App }) { - const [open, setOpen] = useState(false); - const icons: Record = { - web: "🌐", api: "⚡", simulator: "🎮", admin: "🔧", - mobile: "📱", worker: "⚙️", engine: "🎯", +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", }; - const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "📦"; + 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 ──────────────────────────────────────────── + +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 + const commentPrefixes = ["//", "#", "--"]; + for (const p of commentPrefixes) { + if (remaining.trimStart().startsWith(p)) { + tokens.push({remaining}); + remaining = ""; + break; + } + } + + 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}); + } + } + } + + return ( +
+ {tokens.length > 0 ? tokens : " "} +
+ ); + }); +} + +// ── File tree node ──────────────────────────────────────────────────────────── + +function TreeNodeRow({ + node, + depth, + selectedPath, + onSelect, + onToggle, + projectId, +}: { + node: TreeNode; + depth: number; + selectedPath: string | null; + onSelect: (path: string, type: "file" | "dir") => void; + onToggle: (path: string) => void; + projectId: string; +}) { + const isSelected = selectedPath === node.path; + const isDir = node.type === "dir"; return ( -
+ <> - - {open && ( -
- {app.tech.length > 0 && ( -
-
Stack
-
- {app.tech.map((t, i) => ( - {t} - ))} -
-
- )} - {app.screens.length > 0 && ( -
-
Key screens
-
- {app.screens.map((s, i) => ( -
- {s} -
- ))} -
-
- )} -
+ {isDir && node.expanded && node.children && ( + node.children.map(child => ( + + )) )} -
+ ); } +// ── Main page ───────────────────────────────────────────────────────────────── + export default function BuildPage() { const params = useParams(); const projectId = params.projectId as string; - const workspace = params.workspace as string; + const { status: authStatus } = useSession(); - const [prd, setPrd] = useState(null); - const [architecture, setArchitecture] = useState(null); - const [architectureConfirmed, setArchitectureConfirmed] = useState(false); - const [loading, setLoading] = useState(true); - const [generating, setGenerating] = useState(false); - const [confirming, setConfirming] = useState(false); - const [error, setError] = useState(null); + // File tree state + const [tree, setTree] = useState([]); + const [treeLoading, setTreeLoading] = useState(true); + const [treeError, setTreeError] = useState(null); - useEffect(() => { - fetch(`/api/projects/${projectId}/architecture`) - .then(r => r.json()) - .then(d => { - setPrd(d.prd); - setArchitecture(d.architecture ?? null); - setLoading(false); - }) - .catch(() => setLoading(false)); + // File content state + const [selectedPath, setSelectedPath] = useState(null); + const [fileContent, setFileContent] = useState(null); + const [fileLoading, setFileLoading] = useState(false); + const [fileName, setFileName] = useState(null); - // Also check confirmed flag - fetch(`/api/projects/${projectId}`) - .then(r => r.json()) - .then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true)) - .catch(() => {}); + // 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, + })); }, [projectId]); - const handleGenerate = async (force = false) => { - setGenerating(true); - setError(null); + // Load root tree on mount + useEffect(() => { + if (authStatus !== "authenticated") return; + setTreeLoading(true); + fetchDir("") + .then(nodes => { setTree(nodes); setTreeLoading(false); }) + .catch(e => { setTreeError(e.message); setTreeLoading(false); }); + }, [authStatus, fetchDir]); + + // Toggle dir expand/collapse + 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); + }); + + // 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; } + } + 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 file and load 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}/architecture`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ forceRegenerate: force }), - }); - const d = await res.json(); - if (!res.ok) throw new Error(d.error || "Generation failed"); - setArchitecture(d.architecture); - } catch (e) { - setError(e instanceof Error ? e.message : "Something went wrong"); + 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 { - setGenerating(false); + setFileLoading(false); } - }; + }, [projectId]); - const handleConfirm = async () => { - setConfirming(true); - try { - await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" }); - setArchitectureConfirmed(true); - } catch { /* swallow */ } finally { - setConfirming(false); - } - }; + const lang = fileName ? langFromName(fileName) : "text"; + const lines = (fileContent ?? "").split("\n"); - if (loading) { - return ( -
- Loading… -
- ); - } + return ( +
- // No PRD yet - if (!prd) { - return ( -
-
-
🔒
-

- Complete your PRD first -

-

- Finish your discovery conversation with Atlas, then the architect will unlock automatically. -

- - Continue with Atlas → - + {/* ── File tree panel ── */} +
+ {/* Tree header */} +
+ Files
-
- ); - } - // PRD exists but no architecture yet — prompt to generate - if (!architecture) { - return ( -
-
-
🏗️
-

- Ready to architect {architecture ? (architecture as Architecture).productName : "your product"} -

-

- The AI will read your PRD and recommend the technical structure — apps, services, database, and integrations. You'll review it before anything gets built. -

- {error && ( -
- {error} + {/* Tree content */} +
+ {treeLoading && ( +
+ Loading…
)} - - {generating && ( -

- This takes about 15–30 seconds -

+ {treeError && ( +
+ {treeError === "No Gitea repo connected" + ? "No repository connected yet. Create a project to get started." + : treeError} +
)} + {!treeLoading && !treeError && tree.length === 0 && ( +
+ Repository is empty. +
+ )} + {tree.map(node => ( + + ))}
- ); - } - // Architecture loaded — show full review UI - return ( -
+ {/* ── Code preview panel ── */} +
- {/* Header */} -
-
-

- Architecture -

-

- {architecture.productType} -

-
-
- {architectureConfirmed && ( - - ✓ Confirmed + {/* File path breadcrumb */} +
+ {selectedPath ? ( + + {selectedPath.split("/").map((seg, i, arr) => ( + + {i > 0 && /} + {seg} + + ))} + + ) : ( + + Select a file to view )} - + {selectedPath && ( + + {lang} + + )} +
+ + {/* Code area */} +
+ {!selectedPath && !fileLoading && ( +
+ Select a file from the tree +
+ )} + {fileLoading && ( +
+ Loading… +
+ )} + {!fileLoading && fileContent !== null && ( +
+ {/* Line numbers */} +
+ {lines.map((_, i) => ( +
+ {i + 1} +
+ ))} +
+ {/* Code content */} +
+ {highlightCode(fileContent, lang)} +
+
+ )}
- - {/* Summary */} -
- {architecture.summary} -
- - {/* Apps */} - Apps — monorepo/apps/ - {architecture.apps.map((app, i) => )} - - {/* Packages */} - Shared packages — monorepo/packages/ -
- {architecture.packages.map((pkg, i) => ( -
-
- packages/{pkg.name} -
-
- {pkg.description} -
-
- ))} -
- - {/* Infrastructure */} - {architecture.infrastructure.length > 0 && ( - <> - Infrastructure -
- {architecture.infrastructure.map((infra, i) => ( -
- - {infra.name} - - - {infra.reason} - -
- ))} -
- - )} - - {/* Integrations */} - {architecture.integrations.length > 0 && ( - <> - External integrations -
- {architecture.integrations.map((intg, i) => ( -
- - {intg.required ? "required" : "optional"} - -
-
{intg.name}
-
{intg.notes}
-
-
- ))} -
- - )} - - {/* Risk notes */} - {architecture.riskNotes.length > 0 && ( - <> - Architecture risks -
- {architecture.riskNotes.map((risk, i) => ( -
- ⚠️{risk} -
- ))} -
- - )} - - {/* Confirm section */} -
- {architectureConfirmed ? ( -
-
- ✓ Architecture confirmed -
-

- You can still regenerate or adjust the architecture before scaffolding begins. Nothing has been built yet. -

- - Choose your design → - -
- ) : ( -
-
- Does this look right? -
-

- Review the structure above. You can regenerate if something's off, or confirm to move to design. - You can always come back and adjust before the build starts — nothing gets scaffolded yet. -

-
- - -
-
- )} -
- -
); } diff --git a/app/api/projects/[projectId]/file/route.ts b/app/api/projects/[projectId]/file/route.ts new file mode 100644 index 0000000..03a6eef --- /dev/null +++ b/app/api/projects/[projectId]/file/route.ts @@ -0,0 +1,108 @@ +/** + * GET /api/projects/[projectId]/file?path=apps/admin + * + * Returns directory listing or file content from the project's Gitea repo. + * Response for directory: { type: "dir", items: [{ name, path, type }] } + * Response for file: { type: "file", content: string, encoding: "utf8" | "base64" } + */ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/authOptions'; +import { query } from '@/lib/db-postgres'; + +const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; +const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ''; + +async function giteaGet(path: string) { + const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, { + headers: { Authorization: `token ${GITEA_API_TOKEN}` }, + next: { revalidate: 10 }, + }); + if (!res.ok) throw new Error(`Gitea ${res.status}: ${path}`); + return res.json(); +} + +const BINARY_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', + 'woff', 'woff2', 'ttf', 'eot', + 'zip', 'tar', 'gz', 'pdf', +]); + +function isBinary(name: string): boolean { + const ext = name.split('.').pop()?.toLowerCase() ?? ''; + return BINARY_EXTENSIONS.has(ext); +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const { projectId } = await params; + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const filePath = searchParams.get('path') ?? ''; + + // Verify ownership + get giteaRepo + const rows = await query<{ data: Record }>( + `SELECT p.data FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, + [projectId, session.user.email] + ); + if (rows.length === 0) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const giteaRepo = rows[0].data?.giteaRepo as string | undefined; + if (!giteaRepo) { + return NextResponse.json({ error: 'No Gitea repo connected' }, { status: 404 }); + } + + const encodedPath = filePath ? encodeURIComponent(filePath).replace(/%2F/g, '/') : ''; + const apiPath = `/repos/${giteaRepo}/contents/${encodedPath}`; + const data = await giteaGet(apiPath); + + // Directory listing + if (Array.isArray(data)) { + const items = data + .map((item: { name: string; path: string; type: string; size?: number }) => ({ + name: item.name, + path: item.path, + type: item.type, // "file" | "dir" | "symlink" + size: item.size, + })) + .sort((a, b) => { + // Dirs first + if (a.type === 'dir' && b.type !== 'dir') return -1; + if (a.type !== 'dir' && b.type === 'dir') return 1; + return a.name.localeCompare(b.name); + }); + return NextResponse.json({ type: 'dir', items }); + } + + // Single file + const item = data as { name: string; content?: string; encoding?: string; size?: number }; + if (isBinary(item.name)) { + return NextResponse.json({ type: 'file', content: '(binary file)', encoding: 'utf8' }); + } + + // Gitea returns base64-encoded content + const raw = item.content ?? ''; + let content: string; + try { + content = Buffer.from(raw.replace(/\n/g, ''), 'base64').toString('utf8'); + } catch { + content = raw; + } + + return NextResponse.json({ type: 'file', content, encoding: 'utf8', name: item.name }); + } catch (err) { + console.error('[file API]', err); + return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 }); + } +} diff --git a/components/layout/vibn-sidebar.tsx b/components/layout/vibn-sidebar.tsx index 357901e..8b40ec3 100644 --- a/components/layout/vibn-sidebar.tsx +++ b/components/layout/vibn-sidebar.tsx @@ -295,10 +295,19 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) {
{project.productName || project.name || "Project"}
-
- {project.status === "live" ? "● Live" - : project.status === "building" ? "● Building" - : "● Defining"} +
+ + + {project.status === "live" ? "Live" + : project.status === "building" ? "Building" + : "Defining"} +
)} @@ -311,12 +320,12 @@ export function VIBNSidebar({ workspace }: VIBNSidebarProps) { key={app.name} icon="▢" label={app.name} - href={project.giteaRepoUrl ? `${project.giteaRepoUrl}/src/branch/main/${app.path}` : undefined} + href={`${base}/build`} collapsed={collapsed} /> )) ) : ( - + )}