import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { type Me, useMe, useOrgProjects, useSetActiveProject } from "./api.ts"; import { authClient, useActiveOrganization, useListOrganizations } from "./auth-client.ts"; import { ScrollArea } from "./design/scroll-area.tsx"; const ON_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || ""); type MeWithOrg = Me & { org: NonNullable; project: NonNullable }; export function OrgProjectSwitcher() { const me = useMe(); const [open, setOpen] = useState(false); const ref = useRef(null); const canOpen = !!me.data?.org && !!me.data?.project; // Global ⌘O / Ctrl+O toggles the menu. useEffect(() => { if (!canOpen) return; const onKey = (e: KeyboardEvent) => { const modifier = ON_MAC ? e.metaKey : e.ctrlKey; if (!modifier || e.shiftKey || e.altKey) return; if (e.key.toLowerCase() !== "o") return; e.preventDefault(); setOpen((v) => !v); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [canOpen]); useEffect(() => { if (!open) return; const onMouseDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onMouseDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("keydown", onKey); }; }, [open]); if (!me.data) { return
; } // Pre-org user (mid-onboarding): no org/project to switch between yet. if (!me.data.org || !me.data.project) return null; const meData: MeWithOrg = { ...me.data, org: me.data.org, project: me.data.project }; return (
{open && setOpen(false)} />}
); } type Step = "orgs" | "projects"; function SwitcherDropdown({ me, onClose }: { me: MeWithOrg; onClose: () => void }) { const orgsQuery = useListOrganizations(); const activeOrgQuery = useActiveOrganization(); const projects = useOrgProjects(); const setActiveProject = useSetActiveProject(); const qc = useQueryClient(); const navigate = useNavigate(); const [step, setStep] = useState("orgs"); const [pendingOrgSwitch, setPendingOrgSwitch] = useState<{ targetOrgId: string; } | null>(null); const [query, setQuery] = useState(""); const [highlight, setHighlight] = useState(0); const [copied, setCopied] = useState(false); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, [step]); useEffect(() => { setHighlight(0); }, [query, step]); const activeOrgId = activeOrgQuery.data?.id ?? me.org.id; useEffect(() => { if (!pendingOrgSwitch) return; if (activeOrgId !== pendingOrgSwitch.targetOrgId) return; if (projects.isFetching || !projects.data) return; const list = projects.data.projects; setPendingOrgSwitch(null); if (list.length <= 1) { onClose(); } else { setStep("projects"); setQuery(""); } }, [pendingOrgSwitch, activeOrgId, projects.isFetching, projects.data, onClose]); const switchOrgAndDrill = async (orgId: string) => { setPendingOrgSwitch({ targetOrgId: orgId }); if (activeOrgId === orgId) return; await authClient.organization.setActive({ organizationId: orgId }); await Promise.all([ qc.invalidateQueries({ queryKey: ["me"] }), qc.invalidateQueries({ queryKey: ["org-projects"] }), ]); }; const manageOrg = async (orgId: string) => { if (activeOrgId !== orgId) { await authClient.organization.setActive({ organizationId: orgId }); await Promise.all([ qc.invalidateQueries({ queryKey: ["me"] }), qc.invalidateQueries({ queryKey: ["org-projects"] }), ]); } onClose(); navigate("/settings?scope=org§ion=members"); }; const pickProject = async (projectId: string) => { if (projectId === me.project.id) { onClose(); return; } await setActiveProject.mutateAsync(projectId); onClose(); }; const goBackToOrgs = () => { setStep("orgs"); setQuery(""); setPendingOrgSwitch(null); }; const copyId = async () => { try { await navigator.clipboard.writeText(me.project.id); setCopied(true); setTimeout(() => setCopied(false), 1200); } catch {} }; type OrgRow = { id: string; name: string; sub: string; active: boolean }; const orgRows: OrgRow[] = useMemo(() => { const orgs = orgsQuery.data ?? []; return orgs.map((o) => ({ id: o.id, name: o.name, sub: o.slug, active: activeOrgId === o.id, })); }, [orgsQuery.data, activeOrgId]); const q = query.trim().toLowerCase(); const matchedOrgs = useMemo( () => (q ? orgRows.filter((o) => o.name.toLowerCase().includes(q)) : orgRows), [orgRows, q], ); const projectList = projects.data?.projects ?? [ { id: me.project.id, name: me.project.name, slug: me.project.slug }, ]; const matchedProjects = useMemo( () => q ? projectList.filter( (p) => p.name.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q), ) : projectList, [projectList, q], ); const onKeyDown = (e: React.KeyboardEvent) => { if (step === "orgs") { const list = matchedOrgs; if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((h) => Math.min(h + 1, Math.max(list.length - 1, 0))); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); const row = list[highlight]; if (row) void switchOrgAndDrill(row.id); } } else { const list = matchedProjects; if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((h) => Math.min(h + 1, Math.max(list.length - 1, 0))); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); const row = list[highlight]; if (row) void pickProject(row.id); } else if (e.key === "Backspace" && query === "") { e.preventDefault(); goBackToOrgs(); } else if (e.key === "Escape") { e.preventDefault(); goBackToOrgs(); } } }; const isSwitching = !!pendingOrgSwitch; const drilledHeader = step === "projects" ? me.org.name : null; return (
{drilledHeader && ( )}
setQuery(e.target.value)} onKeyDown={onKeyDown} placeholder={step === "orgs" ? "Find organization…" : "Find project…"} className="h-7 w-full rounded-sm border border-border bg-surface-2 pl-7 pr-2 text-[12px] text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" />
{step === "orgs" ? ( matchedOrgs.length === 0 ? ( ) : (
    {matchedOrgs.map((o, i) => ( void switchOrgAndDrill(o.id)} primary={o.name} secondary={o.sub} query={q} onManage={() => void manageOrg(o.id)} /> ))}
) ) : isSwitching ? (
Loading projects…
) : matchedProjects.length === 0 ? ( ) : ( <>
    {matchedProjects.map((p, i) => ( void pickProject(p.id)} primary={p.name} secondary={p.slug} query={q} /> ))}
Project ID
)}
↑↓ Navigate ↵ {step === "orgs" ? "Open" : "Select"}
Esc {step === "projects" ? "Back" : "Close"}
); } function FilterRow({ active, highlighted, onClick, primary, secondary, query, onManage, }: { active: boolean; highlighted: boolean; onClick: () => void; primary: string; secondary?: string; query: string; onManage?: () => void; }) { return (
  • {onManage && (
    {active && }
    )}
  • ); } function GearIcon() { return ( ); } function highlightMatch(text: string, query: string) { if (!query) return text; const idx = text.toLowerCase().indexOf(query); if (idx === -1) return text; return ( <> {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ); } function Empty({ query }: { query: string }) { return (
    No matches for "{query}"
    ); } function SearchIcon() { return ( ); } function KbdMini({ children }: { children: React.ReactNode }) { return ( {children} ); } function Chevron() { return ( ); } function BackArrow() { return ( ); } function Check() { return ( ); }