534 lines
15 KiB
TypeScript
534 lines
15 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={{
|
|
flex: 1,
|
|
minHeight: 0,
|
|
boxSizing: "border-box",
|
|
background: THEME.canvasGradient,
|
|
fontFamily: THEME.font,
|
|
padding: "16px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<Card
|
|
padding={0}
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
flex: 1,
|
|
minHeight: 0,
|
|
}}
|
|
>
|
|
<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={
|
|
codebases?.length === 1 ? codebases[0].label : "Code files"
|
|
}
|
|
count={codebases?.length ?? 0}
|
|
>
|
|
{codebases && codebases.length === 0 && (
|
|
<RailEmpty>
|
|
{reason === "no_repo" ? (
|
|
<>
|
|
No codebase yet.{" "}
|
|
<span style={nudge}>
|
|
Try: "Start building my app"
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
Repo is empty — push a first commit.{" "}
|
|
<span style={nudge}>
|
|
Try: "Scaffold a Next.js app"
|
|
</span>
|
|
</>
|
|
)}
|
|
</RailEmpty>
|
|
)}
|
|
{codebases?.map((cb) => {
|
|
return (
|
|
<article
|
|
key={cb.id}
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{codebases.length > 1 && (
|
|
<div
|
|
style={{
|
|
...tileHeader,
|
|
padding: "16px 20px 8px",
|
|
}}
|
|
>
|
|
<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:
|
|
codebases.length > 1
|
|
? "0 0 16px 0"
|
|
: "0 0 16px 0",
|
|
}}
|
|
>
|
|
<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,
|
|
minHeight: 0,
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 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",
|
|
flex: 1,
|
|
minHeight: 0,
|
|
};
|
|
|
|
const leftCol: React.CSSProperties = {
|
|
minWidth: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 24,
|
|
borderRight: `1px solid ${THEME.borderSoft}`,
|
|
padding: "20px",
|
|
overflowY: "auto",
|
|
};
|
|
|
|
const rightCol: React.CSSProperties = {
|
|
minWidth: 0,
|
|
minHeight: 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",
|
|
paddingBottom: 12,
|
|
};
|
|
const railGroupTitle: React.CSSProperties = {
|
|
fontSize: "0.95rem",
|
|
fontWeight: 600,
|
|
color: THEME.ink,
|
|
};
|
|
const countPill: React.CSSProperties = {
|
|
fontSize: "0.75rem",
|
|
fontWeight: 600,
|
|
color: THEME.mid,
|
|
padding: "2px 8px",
|
|
borderRadius: 999,
|
|
background: THEME.borderSoft,
|
|
};
|
|
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",
|
|
};
|