repos sidebar: fold repos under their org
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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user