"use client"; // Shared repo picker used by the Loop + Research wizards. Fetches the // workspace's already-connected repos from /api/repos (+ /api/repos/connections // for the provider label) and lets the user pick one — or skip when optional. // // Emits a compact PickedRepo the caller can send in its create body; the // backend can persist the id later without another round-trip. import { useEffect, useMemo, useState } from "react"; import { GitBranch, Search, X } from "lucide-react"; const mono = "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; export interface PickedRepo { repo_id: string; connection_id: string; provider: string; owner: string; name: string; default_branch: string | null; } interface RepoSummary { id: string; connection_id: string; provider: string; owner: string; name: string; private: boolean; description: string | null; default_branch: string | null; stars: number; forks: number; updated_at: string | null; } interface ConnectionSummary { id: string; provider: string; owner: string | null; base_url?: string | null; label?: string; } export function RepoPicker({ value, onChange, optional = false, emptyHint, }: { value: PickedRepo | null; onChange: (v: PickedRepo | null) => void; optional?: boolean; emptyHint?: string; }) { const [repos, setRepos] = useState(null); const [connections, setConnections] = useState([]); const [error, setError] = useState(null); const [query, setQuery] = useState(""); useEffect(() => { let cancelled = false; async function load() { try { const [rRes, cRes] = await Promise.all([ fetch("/api/repos"), fetch("/api/repos/connections"), ]); if (cancelled) return; if (rRes.ok) setRepos(await rRes.json()); else setRepos([]); if (cRes.ok) setConnections(await cRes.json()); } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : "load failed"); setRepos([]); } } } void load(); return () => { cancelled = true; }; }, []); const connLabel = useMemo(() => { const m = new Map(); for (const c of connections) { m.set(c.id, c.label || c.owner || c.provider); } return m; }, [connections]); const filtered = useMemo(() => { if (!repos) return []; const q = query.trim().toLowerCase(); if (!q) return repos; return repos.filter( (r) => r.name.toLowerCase().includes(q) || r.owner.toLowerCase().includes(q) || (r.description ?? "").toLowerCase().includes(q), ); }, [repos, query]); const grouped = useMemo(() => groupByOwner(filtered), [filtered]); if (repos === null) { return (

Loading repositories…

); } if (repos.length === 0) { return (
No repositories connected yet.
{emptyHint ?? "Connect a GitHub/Gitea provider from the REPOS tier first, then come back."}
{optional ? ( ) : null}
); } return (
{value && (
{value.owner}/{value.name}
{value.provider} {value.default_branch ? ` · ${value.default_branch}` : ""} {connLabel.get(value.connection_id) ? ` · ${connLabel.get(value.connection_id)}` : ""}
)}
setQuery(e.target.value)} placeholder="Filter by owner, name, or description…" style={{ flex: 1, border: 0, outline: "none", background: "transparent", color: "#f3f3f5", fontSize: 13, }} /> {optional ? ( ) : null}
{grouped.length === 0 ? (

No repos match the filter.

) : ( grouped.map(([owner, list]) => (
{owner}
{list.map((r) => { const active = value?.repo_id === r.id; return ( ); })}
)) )}
{error && (

{error}

)}
); } function groupByOwner(repos: RepoSummary[]): [string, RepoSummary[]][] { const map = new Map(); for (const r of repos) { const key = r.owner || "(unknown)"; const bucket = map.get(key) ?? []; bucket.push(r); map.set(key, bucket); } const out = Array.from(map.entries()); out.sort(([a], [b]) => a.localeCompare(b)); for (const [, arr] of out) arr.sort((x, y) => x.name.localeCompare(y.name)); return out; } const hintStyle: React.CSSProperties = { fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.5, }; const secondaryBtn: React.CSSProperties = { padding: "6px 12px", borderRadius: 6, border: "1px solid rgba(255,255,255,.14)", background: "transparent", color: "#cfcfd5", fontSize: 12, fontWeight: 600, cursor: "pointer", };