repos sidebar: fold repos under their org
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 27s
ci / rust (push) Successful in 3m53s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m40s

Within a provider connection, repos now group under a foldable org card
(the repo's owner login). Each org shows chevron + owner + count, with
repos indented under a subtle left rail so the tree reads visually.

Sorting: orgs alphabetical, repos within an org alphabetical — makes
scanning stable when a re-sync reorders provider output.

Collapsed state lives per (connection_id, owner) so the same org name
appearing under two providers folds independently. Default is expanded
so the first pass after connecting shows everything.
This commit is contained in:
Omar Sobh
2026-07-07 18:37:48 -07:00
parent 637e1bdd69
commit 2562541f5b
+183 -47
View File
@@ -7,7 +7,7 @@
// v1 shows a placeholder until the backend routes land — see tasks #12–14. // v1 shows a placeholder until the backend routes land — see tasks #12–14.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { GitBranch, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { ChevronDown, ChevronRight, GitBranch, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -59,6 +59,16 @@ export function RepoList({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
// Collapsed-org state is per-<connection_id>::<owner> — the same owner
// string appearing under two different providers folds independently.
const [collapsedOrgs, setCollapsedOrgs] = useState<Set<string>>(new Set());
const toggleOrg = (key: string) =>
setCollapsedOrgs((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
async function sync(id: string) { async function sync(id: string) {
if (busyId) return; if (busyId) return;
@@ -329,55 +339,23 @@ export function RepoList({
No repos synced yet. No repos synced yet.
</p> </p>
) : ( ) : (
<ul style={{ margin: 0, padding: 0, listStyle: "none", display: "flex", flexDirection: "column", gap: 4 }}> <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{list.map((r) => { {groupByOwner(list).map(([owner, orgRepos]) => {
const active = selectedId === r.id; const key = `${c.id}::${owner}`;
const collapsed = collapsedOrgs.has(key);
return ( return (
<li key={r.id}> <OrgGroup
<button key={key}
type="button" owner={owner}
onClick={() => onSelect(r.id)} repos={orgRepos}
style={{ collapsed={collapsed}
width: "100%", onToggle={() => toggleOrg(key)}
textAlign: "left", selectedId={selectedId}
display: "flex", onSelect={onSelect}
alignItems: "center", />
gap: 8,
padding: "7px 10px",
borderRadius: 8,
border: active
? "1px solid rgba(255,111,97,.4)"
: "1px solid transparent",
background: active ? "rgba(255,111,97,.08)" : "transparent",
color: "#eaeaee",
fontFamily: "inherit",
fontSize: 12.5,
cursor: "pointer",
}}
>
<GitBranch size={12} style={{ color: active ? "#ff8a7a" : "#5a5a62" }} />
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{r.name}
</span>
{r.private ? (
<span
style={{
fontFamily: mono,
fontSize: 9,
color: "#c8a464",
padding: "1px 5px",
borderRadius: 4,
background: "rgba(200,164,100,.1)",
}}
>
priv
</span>
) : null}
</button>
</li>
); );
})} })}
</ul> </div>
)} )}
</div> </div>
); );
@@ -446,6 +424,164 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
); );
} }
/** Group a connection's repos by their owner login and return them sorted
* by owner, then by name — so the sidebar reads like an org tree. */
function groupByOwner(repos: RepoSummary[]): [string, RepoSummary[]][] {
const map = new Map<string, RepoSummary[]>();
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;
}
function OrgGroup({
owner,
repos,
collapsed,
onToggle,
selectedId,
onSelect,
}: {
owner: string;
repos: RepoSummary[];
collapsed: boolean;
onToggle: () => void;
selectedId: string | null;
onSelect: (id: string | null) => void;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<button
type="button"
onClick={onToggle}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 8px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.06)",
background: "rgba(255,255,255,.03)",
color: "#eaeaee",
fontFamily: "inherit",
fontSize: 12,
cursor: "pointer",
textAlign: "left",
width: "100%",
}}
>
{collapsed ? (
<ChevronRight size={12} style={{ color: "#8a8a92" }} />
) : (
<ChevronDown size={12} style={{ color: "#8a8a92" }} />
)}
<span
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 600,
}}
>
{owner}
</span>
<span
style={{
fontFamily: mono,
fontSize: 9.5,
color: "#6a6a72",
padding: "1px 6px",
borderRadius: 4,
background: "rgba(255,255,255,.04)",
}}
>
{repos.length}
</span>
</button>
{collapsed ? null : (
<ul
style={{
margin: 0,
padding: 0,
listStyle: "none",
display: "flex",
flexDirection: "column",
gap: 2,
// Indent under the org card so the tree reads visually.
paddingLeft: 12,
borderLeft: "1px solid rgba(255,255,255,.06)",
marginLeft: 8,
}}
>
{repos.map((r) => {
const active = selectedId === r.id;
return (
<li key={r.id}>
<button
type="button"
onClick={() => onSelect(r.id)}
style={{
width: "100%",
textAlign: "left",
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 8px",
borderRadius: 6,
border: active
? "1px solid rgba(255,111,97,.4)"
: "1px solid transparent",
background: active ? "rgba(255,111,97,.08)" : "transparent",
color: "#eaeaee",
fontFamily: "inherit",
fontSize: 12,
cursor: "pointer",
}}
>
<GitBranch size={11} style={{ color: active ? "#ff8a7a" : "#5a5a62" }} />
<span
style={{
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{r.name}
</span>
{r.private ? (
<span
style={{
fontFamily: mono,
fontSize: 9,
color: "#c8a464",
padding: "1px 5px",
borderRadius: 4,
background: "rgba(200,164,100,.1)",
}}
>
priv
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
function IconBtn({ function IconBtn({
children, children,
onClick, onClick,