From a1bcef627b321a91473ab8be75f15933234d3a1b Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:01:06 +1000 Subject: [PATCH] feat: repo selector in breadcrumb (#19) https://github.com/user-attachments/assets/a7f58bae-6c11-4c55-b38f-2be493f1f632 --- .../app/(app)/repos/[owner]/[repo]/layout.tsx | 1 + .../[owner]/[repo]/pulls/[number]/loading.tsx | 7 +- apps/web/src/app/api/org-repos/route.ts | 28 +++ apps/web/src/app/page.tsx | 10 +- apps/web/src/components/command-menu.tsx | 8 +- .../src/components/repo/repo-breadcrumb.tsx | 207 ++++++++++++++++++ .../components/repo/repo-layout-wrapper.tsx | 26 +-- .../web/src/components/repo/repo-overview.tsx | 8 +- apps/web/src/components/repo/repo-sidebar.tsx | 21 +- 9 files changed, 264 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/app/api/org-repos/route.ts create mode 100644 apps/web/src/components/repo/repo-breadcrumb.tsx diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/layout.tsx b/apps/web/src/app/(app)/repos/[owner]/[repo]/layout.tsx index 043cd9d..7deb19a 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/layout.tsx +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/layout.tsx @@ -154,6 +154,7 @@ export default async function RepoLayout({
@@ -181,7 +183,8 @@ export default function PRDetailLoading() { className={`h-3 rounded ${ line.type === "add" ? "bg-green-500/10" - : line.type === "del" + : line.type === + "del" ? "bg-red-500/10" : "bg-muted/15" }`} diff --git a/apps/web/src/app/api/org-repos/route.ts b/apps/web/src/app/api/org-repos/route.ts new file mode 100644 index 0000000..b1d8fa4 --- /dev/null +++ b/apps/web/src/app/api/org-repos/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getOrgRepos } from "@/lib/github"; + +interface SlimRepo { + name: string; + full_name: string; + description: string | null; + stargazers_count: number; + private: boolean; +} + +export async function GET(request: NextRequest) { + const org = request.nextUrl.searchParams.get("org"); + if (!org) { + return NextResponse.json({ error: "Missing org parameter" }, { status: 400 }); + } + + const repos = await getOrgRepos(org, { perPage: 100, sort: "pushed" }); + const repoList = Array.isArray(repos) ? repos : []; + const slim: SlimRepo[] = repoList.map((r) => ({ + name: r.name, + full_name: r.full_name, + description: r.description ?? null, + stargazers_count: r.stargazers_count ?? 0, + private: r.private ?? false, + })); + return NextResponse.json({ repos: slim }); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index f83e938..5eced1c 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -66,10 +66,7 @@ export default async function HomePage() { fill="none" xmlns="http://www.w3.org/2000/svg" > - + - + diff --git a/apps/web/src/components/command-menu.tsx b/apps/web/src/components/command-menu.tsx index 2ae9b35..ac21edc 100644 --- a/apps/web/src/components/command-menu.tsx +++ b/apps/web/src/components/command-menu.tsx @@ -465,11 +465,11 @@ export function CommandMenu() { setOpen(true); } if (e.key === "t" && !e.metaKey && !e.ctrlKey && !e.altKey) { - const target = e.target as HTMLElement; + const active = document.activeElement as HTMLElement | null; if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable + active?.tagName === "INPUT" || + active?.tagName === "TEXTAREA" || + active?.isContentEditable ) return; if (!matchRepoFromPathname(window.location.pathname)) return; diff --git a/apps/web/src/components/repo/repo-breadcrumb.tsx b/apps/web/src/components/repo/repo-breadcrumb.tsx new file mode 100644 index 0000000..569e24b --- /dev/null +++ b/apps/web/src/components/repo/repo-breadcrumb.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useState, useCallback, useMemo, useRef } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ChevronDown, Lock, Star, Loader2, Search } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { formatNumber } from "@/lib/utils"; + +interface OrgRepo { + name: string; + full_name: string; + description: string | null; + stargazers_count: number; + private: boolean; +} + +interface RepoBreadcrumbProps { + owner: string; + repoName: string; + ownerType: string; +} + +export function RepoBreadcrumb({ owner, repoName, ownerType }: RepoBreadcrumbProps) { + const router = useRouter(); + const [repos, setRepos] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + const [filter, setFilter] = useState(""); + const fetchedRef = useRef(false); + const inputRef = useRef(null); + + const isOrg = ownerType === "Organization"; + + const fetchOrgRepos = useCallback(async () => { + if (fetchedRef.current) return; + fetchedRef.current = true; + + setIsLoading(true); + setHasError(false); + try { + const response = await fetch( + `/api/org-repos?org=${encodeURIComponent(owner)}`, + ); + if (!response.ok) throw new Error("Failed to fetch repos"); + const data = await response.json(); + setRepos(data.repos); + } catch { + setHasError(true); + fetchedRef.current = false; + } finally { + setIsLoading(false); + } + }, [owner]); + + const filteredRepos = useMemo(() => { + if (!repos) return null; + if (!filter.trim()) return repos; + const query = filter.toLowerCase(); + return repos.filter((repo) => repo.name.toLowerCase().includes(query)); + }, [repos, filter]); + + const handleRepoSelect = (selectedRepo: OrgRepo) => { + if (selectedRepo.name !== repoName) { + router.push(`/${owner}/${selectedRepo.name}`); + } + }; + + const handleOpenChange = (open: boolean) => { + if (open) { + fetchOrgRepos(); + } else { + setFilter(""); + } + }; + + return ( +
+ + {owner} + + / + {isOrg ? ( + + + + + e.preventDefault()} + > + {isLoading ? ( +
+ +
+ ) : hasError ? ( +
+ Failed to load repositories +
+ ) : repos && repos.length > 0 ? ( + <> +
+ + + setFilter( + e + .target + .value, + ) + } + onKeyDown={(e) => + e.stopPropagation() + } + className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50" + autoFocus + /> +
+
+ {filteredRepos && + filteredRepos.length > 0 ? ( + filteredRepos.map( + (repo) => ( + + handleRepoSelect( + repo, + ) + } + className={`flex flex-col items-start gap-0.5 py-2 cursor-pointer ${ + repo.name === + repoName + ? "bg-accent" + : "" + }`} + autoFocus={ + false + } + > +
+ + { + repo.name + } + + {repo.private && ( + + )} + + + {formatNumber( + repo.stargazers_count, + )} + +
+ + {repo.description ?? + null} + +
+ ), + ) + ) : ( +
+ No matching + repositories +
+ )} +
+ + ) : ( +
+ No repositories found +
+ )} +
+
+ ) : ( + + {repoName} + + )} +
+ ); +} diff --git a/apps/web/src/components/repo/repo-layout-wrapper.tsx b/apps/web/src/components/repo/repo-layout-wrapper.tsx index 2c93bde..bdb5cf0 100644 --- a/apps/web/src/components/repo/repo-layout-wrapper.tsx +++ b/apps/web/src/components/repo/repo-layout-wrapper.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, useCallback, useRef, useEffect, useTransition } from "react"; -import Link from "next/link"; +import { RepoBreadcrumb } from "@/components/repo/repo-breadcrumb"; +import { useState, useCallback, useRef, useTransition } from "react"; import { PanelLeft } from "lucide-react"; import { motion, AnimatePresence } from "motion/react"; import { setRepoSidebarState } from "./repo-sidebar-actions"; @@ -11,6 +11,7 @@ interface RepoLayoutWrapperProps { children: React.ReactNode; owner: string; repo: string; + ownerType: string; initialCollapsed?: boolean; initialWidth?: number; } @@ -26,6 +27,7 @@ export function RepoLayoutWrapper({ repo, initialCollapsed = false, initialWidth = DEFAULT_WIDTH, + ownerType, }: RepoLayoutWrapperProps) { const [sidebarWidth, setSidebarWidth] = useState(initialCollapsed ? 0 : initialWidth); const lastOpenWidthRef = useRef(initialWidth); @@ -199,21 +201,13 @@ export function RepoLayoutWrapper({ } >
- - {owner} - - / - - {repo} - +
{children}
diff --git a/apps/web/src/components/repo/repo-overview.tsx b/apps/web/src/components/repo/repo-overview.tsx index 95dcd63..6c27007 100644 --- a/apps/web/src/components/repo/repo-overview.tsx +++ b/apps/web/src/components/repo/repo-overview.tsx @@ -906,7 +906,6 @@ function HighlightedActivityTicker({ items }: { items: HotItem[] }) { ); } - export interface RepoOverviewProps { owner: string; repo: string; @@ -1069,12 +1068,7 @@ export function RepoOverview({ ); } - const { data: readmeHtml } = useReadme( - owner, - repo, - branch, - initialReadmeHtml ?? null, - ); + const { data: readmeHtml } = useReadme(owner, repo, branch, initialReadmeHtml ?? null); return (
diff --git a/apps/web/src/components/repo/repo-sidebar.tsx b/apps/web/src/components/repo/repo-sidebar.tsx index 52a0593..143675e 100644 --- a/apps/web/src/components/repo/repo-sidebar.tsx +++ b/apps/web/src/components/repo/repo-sidebar.tsx @@ -17,6 +17,7 @@ import { StarButton } from "@/components/repo/star-button"; import { SidebarLanguages } from "@/components/repo/sidebar-languages"; import { SidebarContributors } from "@/components/repo/sidebar-contributors"; import { LatestCommitSection } from "@/components/repo/latest-commit-section"; +import { RepoBreadcrumb } from "@/components/repo/repo-breadcrumb"; import type { ContributorAvatarsData } from "@/lib/repo-data-cache"; interface LatestCommit { @@ -94,21 +95,11 @@ export function RepoSidebar({