From 56d4cc36c7069dca9171d006f7e48c82e1f916a3 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Tue, 28 Apr 2026 17:08:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(project):=20IDE-style=20Product=20tab=20?= =?UTF-8?q?=E2=80=94=20codebase=20tile=20expands=20inline,=20files=20previ?= =?UTF-8?q?ew=20right?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../[projectId]/(home)/product/page.tsx | 319 +++++++++++++----- components/project/gitea-file-tree.tsx | 53 ++- components/project/gitea-file-viewer.tsx | 144 ++++++++ 3 files changed, 419 insertions(+), 97 deletions(-) create mode 100644 components/project/gitea-file-viewer.tsx diff --git a/app/[workspace]/project/[projectId]/(home)/product/page.tsx b/app/[workspace]/project/[projectId]/(home)/product/page.tsx index e4ec4c75..c5b9524d 100644 --- a/app/[workspace]/project/[projectId]/(home)/product/page.tsx +++ b/app/[workspace]/project/[projectId]/(home)/product/page.tsx @@ -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(null); const [reason, setReason] = useState(); - const [error, setError] = useState(null); - const [selectedId, setSelectedId] = useState(""); + const [listError, setListError] = useState(null); + + const [expanded, setExpanded] = useState>(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 ( - - - Loading codebases… - - - } - /> - ); - } - - // ── Error - if (error) { - return ( - - - {error} - - - } - /> - ); - } - - // ── Empty (no repo or empty repo) - if (!codebases || codebases.length === 0) { - return ( - - - {reason === "no_repo" - ? "No Gitea repo is connected to this project yet." - : "Repo is empty — push a first commit to see codebases here."} - - - } - /> - ); - } - - // ── Loaded return ( - ({ - label: cb.label, - hint: cb.hint ?? `apps/${cb.id}`, - onClick: () => setSelectedId(cb.id), - active: cb.id === selected?.id, - }))} - rightPanel={ - selected ? ( - - - - ) : null - } - /> +
+
+ {/* ── Left: codebases column ── */} +
+

Codebases

+
+ {codebases === null && !listError && ( + + Loading… + + )} + {listError && ( + + {listError} + + )} + {codebases && codebases.length === 0 && ( + + {reason === "no_repo" + ? "No Gitea repo connected to this project yet." + : "Repo is empty — push a first commit."} + + )} + {codebases?.map(cb => { + const isOpen = expanded.has(cb.id); + return ( +
+ + {isOpen && ( +
+ + setSelectedFile({ codebaseId: cb.id, path: p }) + } + /> +
+ )} +
+ ); + })} +
+
+ + {/* ── Right: file preview ── */} + +
+
); } -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 (
{children}
); } + +// ────────────────────────────────────────────────────────────────────── +// 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", +}; diff --git a/components/project/gitea-file-tree.tsx b/components/project/gitea-file-tree.tsx index a4a033f9..f6e804d5 100644 --- a/components/project/gitea-file-tree.tsx +++ b/components/project/gitea-file-tree.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -151,6 +161,8 @@ export function GiteaFileTree({ projectId, rootPath }: GiteaFileTreeProps) { loadingPaths={loadingPaths} childrenByPath={childrenByPath} onToggle={toggleDir} + onSelectFile={onSelectFile} + selectedPath={selectedPath} /> ))} @@ -164,13 +176,26 @@ interface NodeProps { loadingPaths: Set; childrenByPath: Record; 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 ( <>
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} > {Icon && } @@ -194,7 +233,7 @@ function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }: {isDir ? ( ) : ( - + )} {item.name} {isLoading && ( @@ -210,6 +249,8 @@ function Node({ item, depth, expanded, loadingPaths, childrenByPath, onToggle }: loadingPaths={loadingPaths} childrenByPath={childrenByPath} onToggle={onToggle} + onSelectFile={onSelectFile} + selectedPath={selectedPath} /> ))} diff --git a/components/project/gitea-file-viewer.tsx b/components/project/gitea-file-viewer.tsx new file mode 100644 index 00000000..693468a4 --- /dev/null +++ b/components/project/gitea-file-viewer.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + Pick a file from the codebase to preview it here. + + ); + } + + if (loading) { + return ( + + + Loading {basename(path)}… + + ); + } + + if (error) { + return ( + + + {error} + + ); + } + + return ( +
+
+        {content}
+      
+
+ ); +} + +function basename(p: string) { + return p.split("/").pop() || p; +} + +function Centered({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +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, +};