"use client"; import { useEffect, useState, useCallback } from "react"; import { useParams } 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 for syntax colouring hint ───────────────────────────── 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"; } // ── 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 ( <> {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 { status: authStatus } = useSession(); // File tree state const [tree, setTree] = useState([]); const [treeLoading, setTreeLoading] = useState(true); const [treeError, setTreeError] = useState(null); // File content state 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, })); }, [projectId]); // 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}/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"); return (
{/* ── File tree panel ── */}
{/* Tree header */}
Files
{/* Tree content */}
{treeLoading && (
Loading…
)} {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 => ( ))}
{/* ── Code preview panel ── */}
{/* 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)}
)}
); }