feat: repo selector in breadcrumb (#19)

https://github.com/user-attachments/assets/a7f58bae-6c11-4c55-b38f-2be493f1f632
This commit is contained in:
Maxwell
2026-02-23 10:01:06 -08:00
committed by GitHub
parent d3f7d1295d
commit a1bcef627b
9 changed files with 264 additions and 52 deletions
@@ -154,6 +154,7 @@ export default async function RepoLayout({
<RepoLayoutWrapper
owner={owner}
repo={repoName}
ownerType={repoData.owner.type}
initialCollapsed={sidebarState?.collapsed}
initialWidth={sidebarState?.width}
sidebar={
@@ -69,7 +69,9 @@ export default function PRDetailLoading() {
<div className="h-3.5 w-3.5 rounded bg-muted/25 shrink-0" />
<div
className="h-3.5 rounded bg-muted/30"
style={{ width: file.nameW }}
style={{
width: file.nameW,
}}
/>
<div className="flex items-center gap-1 ml-auto shrink-0">
<div className="h-3 w-10 rounded bg-green-500/15" />
@@ -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"
}`}
+28
View File
@@ -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 });
}
+2 -8
View File
@@ -66,10 +66,7 @@ export default async function HomePage() {
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={LOGO_SVG_PATH}
fill="url(#paint0_hero)"
/>
<path d={LOGO_SVG_PATH} fill="url(#paint0_hero)" />
<defs>
<linearGradient
id="paint0_hero"
@@ -80,10 +77,7 @@ export default async function HomePage() {
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop
offset="1"
stopColor="#999999"
/>
<stop offset="1" stopColor="#999999" />
</linearGradient>
</defs>
</svg>
+4 -4
View File
@@ -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;
@@ -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<OrgRepo[] | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [filter, setFilter] = useState("");
const fetchedRef = useRef(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex items-center gap-1 text-xs">
<Link
href={`/${owner}`}
className="text-muted-foreground/60 hover:text-foreground transition-colors tracking-tight"
>
{owner}
</Link>
<span className="text-muted-foreground/30">/</span>
{isOrg ? (
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 font-medium text-foreground hover:text-foreground/80 transition-colors tracking-tight outline-none">
{repoName}
<ChevronDown className="w-3 h-3 mt-0.5 text-muted-foreground/50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-72 max-h-80 overflow-hidden flex flex-col p-2"
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
) : hasError ? (
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
Failed to load repositories
</div>
) : repos && repos.length > 0 ? (
<>
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border mb-2">
<Search className="w-3 h-3 text-muted-foreground/50 shrink-0" />
<input
ref={inputRef}
type="text"
placeholder="Filter repositories..."
value={filter}
onChange={(e) =>
setFilter(
e
.target
.value,
)
}
onKeyDown={(e) =>
e.stopPropagation()
}
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
autoFocus
/>
</div>
<div className="overflow-y-auto flex-1">
{filteredRepos &&
filteredRepos.length > 0 ? (
filteredRepos.map(
(repo) => (
<DropdownMenuItem
key={
repo.name
}
onClick={() =>
handleRepoSelect(
repo,
)
}
className={`flex flex-col items-start gap-0.5 py-2 cursor-pointer ${
repo.name ===
repoName
? "bg-accent"
: ""
}`}
autoFocus={
false
}
>
<div className="flex items-center gap-1.5 w-full">
<span className="font-medium text-xs truncate">
{
repo.name
}
</span>
{repo.private && (
<Lock className="w-3 h-3 text-muted-foreground/50 shrink-0" />
)}
<span className="flex items-center gap-0.5 ml-auto text-[10px] text-muted-foreground/60 shrink-0">
<Star className="size-2 opacity-50" />
{formatNumber(
repo.stargazers_count,
)}
</span>
</div>
<span className="text-[10px] min-h-[14px] text-muted-foreground/70 line-clamp-1">
{repo.description ??
null}
</span>
</DropdownMenuItem>
),
)
) : (
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
No matching
repositories
</div>
)}
</div>
</>
) : (
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
No repositories found
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link
href={`/${owner}/${repoName}`}
className="font-medium text-foreground hover:text-foreground/80 transition-colors tracking-tight"
>
{repoName}
</Link>
)}
</div>
);
}
@@ -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({
}
>
<div
className={`hidden lg:flex items-center gap-1 text-xs px-2 pt-3 pb-1 ${collapsed ? "visible" : "invisible"}`}
className={`hidden lg:flex px-2 pt-3 pb-1 ${collapsed ? "visible" : "invisible"}`}
>
<Link
href={`/${owner}`}
className="text-muted-foreground/60 hover:text-foreground transition-colors tracking-tight"
>
{owner}
</Link>
<span className="text-muted-foreground/30">/</span>
<Link
href={`/${owner}/${repo}`}
className="font-medium text-foreground hover:text-foreground/80 transition-colors tracking-tight"
>
{repo}
</Link>
<RepoBreadcrumb
owner={owner}
repoName={repo}
ownerType={ownerType}
/>
</div>
{children}
</div>
@@ -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 (
<div className="space-y-4 pb-4">
+6 -15
View File
@@ -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({
<aside className="hidden lg:flex w-[260px] shrink-0 overflow-y-auto pt-0 px-4 pb-4 flex-col gap-5">
{/* Name + Avatar + Description + Badges */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 text-xs">
<Link
href={`/${owner}`}
className="text-muted-foreground/60 hover:text-foreground transition-colors tracking-tight"
>
{owner}
</Link>
<span className="text-muted-foreground/30">/</span>
<Link
href={`/${owner}/${repoName}`}
className="font-medium text-foreground hover:text-foreground/80 transition-colors tracking-tight"
>
{repoName}
</Link>
</div>
<RepoBreadcrumb
owner={owner}
repoName={repoName}
ownerType={ownerType}
/>
<Image
src={avatarUrl}
alt=""