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({