feat(project): IDE-style Product tab — codebase tile expands inline, files preview right

Each codebase becomes its own panel with a header and an expandable
Gitea file tree inside. Clicking any file selects it and renders its
content in the right-hand preview panel (monospaced; no syntax
highlight yet). Single-codebase projects auto-expand the only
codebase on load so the tree is visible immediately.

Tree leaves are now interactive when an onSelectFile callback is
provided; selected rows highlight subtly so the user can tell where
the right pane's content came from.

Made-with: Cursor
This commit is contained in:
2026-04-28 17:08:27 -07:00
parent 6e4e9c02ff
commit 56d4cc36c7
3 changed files with 419 additions and 97 deletions

View File

@@ -2,18 +2,17 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Loader2, AlertCircle } from "lucide-react";
import { SectionScaffold, StatusPanel } from "@/components/project/section-scaffold";
import { Loader2, AlertCircle, ChevronDown, ChevronRight, Box } from "lucide-react";
import { GiteaFileTree } from "@/components/project/gitea-file-tree";
import { GiteaFileViewer } from "@/components/project/gitea-file-viewer";
/**
* Product tab.
* Product tab — IDE-style.
*
* Each tile is a CODEBASE in this project's repo. The list is
* discovered server-side from Gitea by `/api/projects/[id]/codebases`:
* - Turborepo (`apps/*`) → one tile per app
* - Single-repo project → one tile pointing at the repo root
* - No repo connected → empty-state CTA
* Left column: codebases stack. Each codebase is a panel with its
* own header (name) and an inline expandable Gitea file tree below.
* Single-codebase projects auto-expand on load. Clicking a file in
* any tree updates the right column with that file's content.
*/
interface Codebase {
@@ -35,14 +34,17 @@ export default function ProductTab() {
const [codebases, setCodebases] = useState<Codebase[] | null>(null);
const [reason, setReason] = useState<CodebasesResponse["reason"]>();
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string>("");
const [listError, setListError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selectedFile, setSelectedFile] = useState<{ codebaseId: string; path: string } | null>(null);
useEffect(() => {
let cancelled = false;
setCodebases(null);
setError(null);
setListError(null);
setReason(undefined);
setSelectedFile(null);
fetch(`/api/projects/${projectId}/codebases`, { credentials: "include" })
.then(async r => {
@@ -54,10 +56,13 @@ export default function ProductTab() {
if (cancelled) return;
setCodebases(data.codebases);
setReason(data.reason);
if (data.codebases[0]) setSelectedId(data.codebases[0].id);
// Auto-expand the first codebase so users see something
if (data.codebases[0]) {
setExpanded(new Set([data.codebases[0].id]));
}
})
.catch(err => {
if (!cancelled) setError(err.message || "Failed to load");
if (!cancelled) setListError(err.message || "Failed to load");
});
return () => {
@@ -65,93 +70,225 @@ export default function ProductTab() {
};
}, [projectId]);
const selected = codebases?.find(c => c.id === selectedId) ?? codebases?.[0];
const toggleCodebase = (id: string) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
// ── Loading
if (codebases === null && !error) {
return (
<SectionScaffold
subAreasHeading="Codebases"
subAreas={[]}
rightHeading="Preview"
rightPanel={
<StatusPanel>
<Centered>
<Loader2 size={14} className="animate-spin" /> Loading codebases
</Centered>
</StatusPanel>
}
/>
);
}
// ── Error
if (error) {
return (
<SectionScaffold
subAreasHeading="Codebases"
subAreas={[]}
rightHeading="Preview"
rightPanel={
<StatusPanel>
<Centered>
<AlertCircle size={14} /> {error}
</Centered>
</StatusPanel>
}
/>
);
}
// ── Empty (no repo or empty repo)
if (!codebases || codebases.length === 0) {
return (
<SectionScaffold
subAreasHeading="Codebases"
subAreas={[]}
rightHeading="Preview"
rightPanel={
<StatusPanel>
<Centered>
{reason === "no_repo"
? "No Gitea repo is connected to this project yet."
: "Repo is empty — push a first commit to see codebases here."}
</Centered>
</StatusPanel>
}
/>
);
}
// ── Loaded
return (
<SectionScaffold
subAreasHeading="Codebases"
rightHeading={selected ? `Preview · ${selected.label}` : "Preview"}
subAreas={codebases.map(cb => ({
label: cb.label,
hint: cb.hint ?? `apps/${cb.id}`,
onClick: () => setSelectedId(cb.id),
active: cb.id === selected?.id,
}))}
rightPanel={
selected ? (
<StatusPanel>
<GiteaFileTree projectId={projectId} rootPath={selected.path} />
</StatusPanel>
) : null
}
/>
<div style={pageWrap}>
<div style={grid}>
{/* ── Left: codebases column ── */}
<section style={leftCol}>
<h3 style={heading}>Codebases</h3>
<div style={stack}>
{codebases === null && !listError && (
<Inline>
<Loader2 size={13} className="animate-spin" /> Loading
</Inline>
)}
{listError && (
<Inline>
<AlertCircle size={13} /> {listError}
</Inline>
)}
{codebases && codebases.length === 0 && (
<Inline>
{reason === "no_repo"
? "No Gitea repo connected to this project yet."
: "Repo is empty — push a first commit."}
</Inline>
)}
{codebases?.map(cb => {
const isOpen = expanded.has(cb.id);
return (
<article key={cb.id} style={codebaseTile}>
<button
type="button"
onClick={() => toggleCodebase(cb.id)}
style={tileHeader}
aria-expanded={isOpen}
>
<span style={chevronCell}>
{isOpen
? <ChevronDown size={13} style={{ color: "#5f5e5a" }} />
: <ChevronRight size={13} style={{ color: "#5f5e5a" }} />}
</span>
<Box size={13} style={{ color: "#5f5e5a", flexShrink: 0 }} />
<div style={{ minWidth: 0, textAlign: "left" }}>
<div style={tileLabel}>{cb.label}</div>
{cb.hint && <div style={tileHint}>{cb.hint}</div>}
</div>
</button>
{isOpen && (
<div style={tileBody}>
<GiteaFileTree
projectId={projectId}
rootPath={cb.path}
selectedPath={
selectedFile?.codebaseId === cb.id ? selectedFile.path : undefined
}
onSelectFile={(p) =>
setSelectedFile({ codebaseId: cb.id, path: p })
}
/>
</div>
)}
</article>
);
})}
</div>
</section>
{/* ── Right: file preview ── */}
<aside style={rightCol}>
<h3 style={heading}>
{selectedFile ? `Preview · ${shortPath(selectedFile.path)}` : "Preview"}
</h3>
<div style={previewPanel}>
<GiteaFileViewer
projectId={projectId}
path={selectedFile?.path ?? null}
/>
</div>
</aside>
</div>
</div>
);
}
function Centered({ children }: { children: React.ReactNode }) {
function shortPath(p: string) {
const parts = p.split("/");
if (parts.length <= 2) return p;
return ".../" + parts.slice(-2).join("/");
}
function Inline({ children }: { children: React.ReactNode }) {
return (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
color: "#5f5e5a", fontSize: "0.85rem", padding: "24px 12px", textAlign: "center",
display: "flex", alignItems: "center", gap: 8,
padding: "12px 14px", fontSize: "0.82rem", color: "#5f5e5a",
background: "#fff", border: "1px solid #efebe1", borderRadius: 8,
}}>
{children}
</div>
);
}
// ──────────────────────────────────────────────────────────────────────
// Styles
// ──────────────────────────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
borderSoft: "#efebe1",
cardBg: "#fff",
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
} as const;
const pageWrap: React.CSSProperties = {
padding: "28px 48px 48px",
fontFamily: INK.fontSans,
color: INK.ink,
};
const grid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "minmax(280px, 360px) minmax(0, 1fr)",
gap: 28,
maxWidth: 1400,
margin: "0 auto",
alignItems: "stretch",
};
const leftCol: React.CSSProperties = {
minWidth: 0,
display: "flex",
flexDirection: "column",
};
const rightCol: React.CSSProperties = {
minWidth: 0,
display: "flex",
flexDirection: "column",
};
const heading: React.CSSProperties = {
fontSize: "0.72rem",
fontWeight: 600,
letterSpacing: "0.12em",
textTransform: "uppercase",
color: INK.muted,
margin: "0 0 14px",
};
const stack: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 10,
};
const codebaseTile: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.borderSoft}`,
borderRadius: 10,
overflow: "hidden",
};
const tileHeader: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "12px 14px",
background: "transparent",
border: "none",
borderBottom: `1px solid transparent`,
cursor: "pointer",
font: "inherit",
color: "inherit",
};
const tileLabel: React.CSSProperties = {
fontSize: "0.85rem",
fontWeight: 600,
color: INK.ink,
marginBottom: 2,
};
const tileHint: React.CSSProperties = {
fontSize: "0.74rem",
color: INK.mid,
lineHeight: 1.4,
};
const tileBody: React.CSSProperties = {
padding: "8px 10px 12px",
borderTop: `1px solid ${INK.borderSoft}`,
};
const chevronCell: React.CSSProperties = {
width: 14,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
};
const previewPanel: React.CSSProperties = {
background: INK.cardBg,
border: `1px solid ${INK.border}`,
borderRadius: 10,
padding: 16,
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
};

View File

@@ -28,9 +28,19 @@ interface GiteaFileTreeProps {
projectId: string;
/** Repo path to root the tree at, e.g. "apps/web" */
rootPath: string;
/** Fires when the user clicks a file row. When omitted, files
* are not interactive. */
onSelectFile?: (path: string) => void;
/** Path of the currently-selected file, used to highlight the row. */
selectedPath?: string;
}
export function GiteaFileTree({ projectId, rootPath }: GiteaFileTreeProps) {
export function GiteaFileTree({
projectId,
rootPath,
onSelectFile,
selectedPath,
}: GiteaFileTreeProps) {
const [rootItems, setRootItems] = useState<TreeItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -151,6 +161,8 @@ export function GiteaFileTree({ projectId, rootPath }: GiteaFileTreeProps) {
loadingPaths={loadingPaths}
childrenByPath={childrenByPath}
onToggle={toggleDir}
onSelectFile={onSelectFile}
selectedPath={selectedPath}
/>
))}
</div>
@@ -164,13 +176,26 @@ interface NodeProps {
loadingPaths: Set<string>;
childrenByPath: Record<string, TreeItem[]>;
onToggle: (path: string) => void;
onSelectFile?: (path: string) => void;
selectedPath?: string;
}
function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }: NodeProps) {
function Node({
item,
depth,
expanded,
loadingPaths,
childrenByPath,
onToggle,
onSelectFile,
selectedPath,
}: NodeProps) {
const isDir = item.type === "dir";
const isOpen = expanded.has(item.path);
const isLoading = loadingPaths.has(item.path);
const children = childrenByPath[item.path];
const isSelected = !isDir && selectedPath === item.path;
const fileClickable = !isDir && typeof onSelectFile === "function";
const Icon = isDir
? isOpen
@@ -180,13 +205,27 @@ function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }:
const indent = depth * 14;
const interactive = isDir || fileClickable;
const handleClick = () => {
if (isDir) onToggle(item.path);
else if (fileClickable) onSelectFile!(item.path);
};
return (
<>
<div
style={{ ...rowStyle, paddingLeft: 6 + indent, cursor: isDir ? "pointer" : "default" }}
onClick={isDir ? () => onToggle(item.path) : undefined}
role={isDir ? "button" : undefined}
style={{
...rowStyle,
paddingLeft: 6 + indent,
cursor: interactive ? "pointer" : "default",
background: isSelected ? "rgba(26,26,26,0.06)" : "transparent",
color: isSelected ? INK.ink : undefined,
fontWeight: isSelected ? 600 : 400,
}}
onClick={interactive ? handleClick : undefined}
role={interactive ? "button" : undefined}
aria-expanded={isDir ? isOpen : undefined}
aria-current={isSelected ? "true" : undefined}
>
<span style={chevronCell}>
{Icon && <Icon size={12} style={{ color: INK.mid }} />}
@@ -194,7 +233,7 @@ function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }:
{isDir ? (
<Folder size={13} style={{ color: INK.stone, flexShrink: 0 }} />
) : (
<FileText size={13} style={{ color: INK.muted, flexShrink: 0 }} />
<FileText size={13} style={{ color: isSelected ? INK.ink : INK.muted, flexShrink: 0 }} />
)}
<span style={nameStyle}>{item.name}</span>
{isLoading && (
@@ -210,6 +249,8 @@ function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }:
loadingPaths={loadingPaths}
childrenByPath={childrenByPath}
onToggle={onToggle}
onSelectFile={onSelectFile}
selectedPath={selectedPath}
/>
))}
</>

View File

@@ -0,0 +1,144 @@
"use client";
/**
* Read-only file viewer that pulls a file's content from Gitea via
* `GET /api/projects/[projectId]/file?path=…`. No syntax highlight
* yet — just monospaced text. Phase 2 can swap in Shiki/Prism if
* the founders ever read enough code here to need it.
*/
import { useEffect, useState } from "react";
import { Loader2, AlertCircle, FileText } from "lucide-react";
interface GiteaFileViewerProps {
projectId: string;
/** Repo path of the file to view, e.g. "apps/web/package.json".
* When null, an empty state prompts the user to pick a file. */
path: string | null;
}
interface ApiResponse {
type: "file" | "dir";
content?: string;
encoding?: string;
name?: string;
error?: string;
}
export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!path) {
setContent(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setContent(null);
fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`, {
credentials: "include",
})
.then(async r => {
const data = (await r.json()) as ApiResponse;
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
if (data.type !== "file") throw new Error("Not a file");
return data.content ?? "";
})
.then(c => {
if (!cancelled) setContent(c);
})
.catch(err => {
if (!cancelled) setError(err.message || "Failed to load file");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId, path]);
if (!path) {
return (
<Centered>
<FileText size={18} style={{ color: INK.muted }} />
<span>Pick a file from the codebase to preview it here.</span>
</Centered>
);
}
if (loading) {
return (
<Centered>
<Loader2 size={14} className="animate-spin" style={{ color: INK.muted }} />
<span>Loading {basename(path)}</span>
</Centered>
);
}
if (error) {
return (
<Centered>
<AlertCircle size={14} style={{ color: INK.muted }} />
<span>{error}</span>
</Centered>
);
}
return (
<div style={wrap}>
<pre style={pre}>
<code>{content}</code>
</pre>
</div>
);
}
function basename(p: string) {
return p.split("/").pop() || p;
}
function Centered({ children }: { children: React.ReactNode }) {
return (
<div style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
gap: 10, color: INK.mid, fontSize: "0.85rem", padding: "32px 16px", textAlign: "center",
}}>
{children}
</div>
);
}
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
border: "#e8e4dc",
} as const;
const wrap: React.CSSProperties = {
flex: 1,
minHeight: 0,
overflow: "auto",
margin: "-4px -10px",
};
const pre: React.CSSProperties = {
margin: 0,
padding: "8px 10px",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.78rem",
lineHeight: 1.55,
color: INK.ink,
whiteSpace: "pre",
tabSize: 2,
};