Files
vibn-agent-runner/vibn-frontend/app/[workspace]/project/[projectId]/(home)/code/page.tsx

514 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import {
Loader2,
AlertCircle,
ChevronDown,
ChevronRight,
Box,
Container,
CircleDot,
} from "lucide-react";
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
import { THEME, PageHeader, Card } from "@/components/project/dashboard-ui";
import { useAnatomy, type Anatomy } from "@/components/project/use-anatomy";
/**
* Product tab — everything that makes up the thing being shipped.
*
* Left rail (top → bottom):
* 1. Codebases — Gitea repos, each tile expands inline into a file
* tree; clicking a file previews it on the right.
* 2. Images — Coolify services backed by an upstream Docker image
* (Twenty CRM, n8n…). Clicking shows image meta on the right.
*
* Dev containers do not appear here — they are the AI's workshop, not
* part of the product surface.
*/
type Selection = { type: "file"; codebaseId: string; path: string } | null;
export default function CodeTab() {
const params = useParams();
const projectId = params.projectId as string;
const { anatomy, loading, error } = useAnatomy(projectId);
const codebases = anatomy?.product.codebases ?? null;
const reason = anatomy?.codebasesReason;
const [selection, setSelection] = useState<Selection>(null);
useEffect(() => {
setSelection(null);
}, [projectId]);
const showLoading = loading && !anatomy;
return (
<div
style={{
minHeight: "100vh",
background: THEME.canvasGradient,
fontFamily: THEME.font,
padding: "36px 48px",
}}
>
<div style={{ maxWidth: 1400, margin: "0 auto" }}>
<PageHeader title="Codebase" />
<Card
padding={0}
style={{
overflow: "hidden",
display: "flex",
flexDirection: "column",
minHeight: "calc(100vh - 150px)",
}}
>
<div style={grid}>
{/* ── Left rail ── */}
<section style={leftCol}>
{showLoading && (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: THEME.mid,
fontSize: "0.875rem",
}}
>
<Loader2 size={15} className="animate-spin" /> Loading
</div>
</Card>
)}
{error && !showLoading && (
<Card>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: THEME.danger,
fontSize: "0.875rem",
}}
>
<AlertCircle size={15} /> {error}
</div>
</Card>
)}
{anatomy && (
<>
{/* Code Files */}
<RailGroup title="Code files" count={codebases?.length ?? 0}>
{codebases && codebases.length === 0 && (
<RailEmpty>
{reason === "no_repo" ? (
<>
No codebase yet.{" "}
<span style={nudge}>
Try: &quot;Start building my app&quot;
</span>
</>
) : (
<>
Repo is empty push a first commit.{" "}
<span style={nudge}>
Try: &quot;Scaffold a Next.js app&quot;
</span>
</>
)}
</RailEmpty>
)}
{codebases?.map((cb) => {
return (
<article
key={cb.id}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
<div
style={{ ...tileHeader, padding: "20px 24px 12px" }}
>
<span style={chevronCell}>
<ChevronDown
size={13}
style={{ color: THEME.mid }}
/>
</span>
<Box
size={14}
style={{ color: THEME.mid, flexShrink: 0 }}
/>
<div style={{ minWidth: 0, textAlign: "left" }}>
<div
style={{
fontSize: "0.95rem",
fontWeight: 600,
color: THEME.ink,
}}
>
{cb.label}
</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
</div>
</div>
<div style={{ padding: "0 10px 24px 10px" }}>
<GiteaFileTree
projectId={projectId}
rootPath={cb.path}
selectedPath={
selection?.type === "file" &&
selection.codebaseId === cb.id
? selection.path
: undefined
}
onSelectFile={(p) =>
setSelection({
type: "file",
codebaseId: cb.id,
path: p,
})
}
/>
</div>
</article>
);
})}
</RailGroup>
</>
)}
</section>
{/* ── Right pane ── */}
<aside style={rightCol}>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
}}
>
{selection?.type === "file" && (
<GiteaFileViewer
projectId={projectId}
path={selection.path}
/>
)}
{!selection && (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: THEME.muted,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
Pick a codebase file on the left.
</div>
)}
</div>
</aside>
</div>
</Card>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
// Bits
// ──────────────────────────────────────────────────
function RailGroup({
title,
count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<div style={railGroup}>
<header style={railGroupHeader}>
<span style={railGroupTitle}>{title}</span>
<span style={countPill}>{count}</span>
</header>
<div style={railItems}>{children}</div>
</div>
);
}
function RailEmpty({ children }: { children: React.ReactNode }) {
return <div style={railEmpty}>{children}</div>;
}
function DetailRow({
label,
value,
dot,
href,
}: {
label: string;
value: string;
dot?: string;
href?: string;
}) {
return (
<div style={detailRow}>
<span style={detailLabel}>{label}</span>
<span style={detailValue}>
{dot && <CircleDot size={9} style={{ color: dot, marginRight: 6 }} />}
{href ? (
<a href={href} target="_blank" rel="noreferrer" style={detailLink}>
{value}
</a>
) : (
value
)}
</span>
</div>
);
}
function Inline({ children }: { children: React.ReactNode }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 14px",
fontSize: "0.82rem",
color: THEME.mid,
background: THEME.cardBg,
border: `1px solid ${THEME.borderSoft}`,
borderRadius: 8,
}}
>
{children}
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: THEME.mid,
fontSize: "0.85rem",
padding: "32px 16px",
textAlign: "center",
}}
>
{children}
</div>
);
}
// ──────────────────────────────────────────────────
function paneHeading(s: Selection): string {
if (!s) return "Preview";
if (s.type === "file") return `Preview · ${shortPath(s.path)}`;
return "Preview";
}
function shortPath(p: string) {
const parts = p.split("/");
if (parts.length <= 2) return p;
return ".../" + parts.slice(-2).join("/");
}
function statusColor(status: string) {
const s = status.toLowerCase();
if (s.includes("running") || s.includes("healthy")) return "#2e7d32";
if (s.includes("starting") || s.includes("deploying")) return "#d4a04a";
if (s.includes("exit") || s.includes("fail") || s.includes("unhealthy"))
return "#c5392b";
return "#a09a90";
}
// ──────────────────────────────────────────────────
// Tokens
// ──────────────────────────────────────────────────
const pageWrap: React.CSSProperties = {
padding: "28px 48px 48px",
fontFamily: THEME.font,
color: THEME.ink,
};
const grid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "300px minmax(0, 1fr)",
alignItems: "stretch",
};
const leftCol: React.CSSProperties = {
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 24,
borderRight: `1px solid ${THEME.borderSoft}`,
paddingRight: 24,
};
const rightCol: React.CSSProperties = {
minWidth: 0,
display: "flex",
flexDirection: "column",
};
const heading: React.CSSProperties = {
fontSize: "0.9rem",
fontWeight: 600,
color: THEME.ink,
margin: "0 0 14px",
};
const railGroup: React.CSSProperties = {
display: "flex",
flexDirection: "column",
};
const railGroupHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 4px 8px",
};
const railGroupTitle: React.CSSProperties = {
fontSize: "0.9rem",
fontWeight: 600,
color: THEME.ink,
};
const countPill: React.CSSProperties = {
fontSize: "0.7rem",
fontWeight: 600,
color: THEME.mid,
padding: "1px 7px",
borderRadius: 999,
background: "#f3eee4",
};
const railItems: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 10,
};
const railEmpty: React.CSSProperties = {
padding: "10px 12px",
fontSize: "0.74rem",
color: THEME.muted,
border: `1px dashed ${THEME.borderSoft}`,
borderRadius: 8,
lineHeight: 1.6,
};
const nudge: React.CSSProperties = {
display: "block",
marginTop: 6,
fontStyle: "normal",
background: "#f3eee4",
borderRadius: 4,
padding: "3px 8px",
fontSize: "0.72rem",
color: "#7a6a50",
};
const flatTile: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "12px 14px",
background: THEME.cardBg,
border: `1px solid ${THEME.borderSoft}`,
borderRadius: 10,
cursor: "pointer",
font: "inherit",
color: "inherit",
transition: "border-color 0.12s, background 0.12s, box-shadow 0.12s",
};
const codebaseTile: React.CSSProperties = {
background: THEME.cardBg,
border: `1px solid ${THEME.borderSoft}`,
borderRadius: 10,
overflow: "hidden",
};
const tileHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "12px 14px",
background: "transparent",
border: "none",
font: "inherit",
color: "inherit",
};
const tileLabel: React.CSSProperties = {
fontSize: "0.85rem",
fontWeight: 600,
color: THEME.ink,
marginBottom: 2,
};
const tileHint: React.CSSProperties = {
fontSize: "0.74rem",
color: THEME.mid,
lineHeight: 1.4,
};
const tileBody: React.CSSProperties = {
padding: "8px 10px 12px",
borderTop: `1px solid ${THEME.borderSoft}`,
};
const chevronCell: React.CSSProperties = {
width: 14,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
};
const panel: React.CSSProperties = {
background: THEME.cardBg,
border: `1px solid ${THEME.border}`,
borderRadius: 10,
padding: 16,
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
};
const detailRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 4px",
borderBottom: `1px solid ${THEME.borderSoft}`,
};
const detailLabel: React.CSSProperties = {
fontSize: "0.72rem",
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: THEME.muted,
};
const detailValue: React.CSSProperties = {
fontSize: "0.85rem",
color: THEME.ink,
display: "inline-flex",
alignItems: "center",
};
const detailLink: React.CSSProperties = {
color: THEME.ink,
textDecoration: "underline",
};