import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react"; import { currentViewport, listenToViewportChanges } from "@chopin/viewport"; import { createPortal } from "react-dom"; import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import * as Api from "./api"; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from "react"; export type RepositoryIdentity = Pick; type Position = Pick< CSSProperties, "height" | "left" | "maxHeight" | "top" | "visibility" | "width" >; type InstalledRepositories = { installation: Api.GitHubInstallation; page: Api.RepositoryPage; error?: boolean; loadingMore?: boolean; }; function repositoryHref(repository: RepositoryIdentity): string { return `/repositories/${encodeURIComponent(repository.owner)}/${ encodeURIComponent(repository.name) }`; } function sameRepository(left: RepositoryIdentity | undefined, right: RepositoryIdentity): boolean { return left?.owner.toLowerCase() === right.owner.toLowerCase() && left.name.toLowerCase() === right.name.toLowerCase(); } function optionId(list: string, repository: Api.Repository): string { return `${list}-option-${repository.id}`; } function reauthenticate(reason: unknown): boolean { if (!(reason instanceof Api.ApiError) || reason.status !== 401) return false; location.assign("/"); return true; } async function installedRepositories( publish: (installed: InstalledRepositories[], replace: boolean) => void, ): Promise { let installations: Api.GitHubInstallation[] = []; let page = 1; let visited = new Set(); while (!visited.has(page)) { visited.add(page); let result = await Api.installations(page); installations.push(...result.installations); if (!result.nextPage) break; page = result.nextPage; } let installed: InstalledRepositories[] = installations.map(installation => ({ installation, page: { repositories: [] }, loadingMore: !installation.suspended && installation.permissions.contents, })); publish(installed, true); for (let index = 0; index < installed.length; index++) { let group = installed[index]!; if (!group.loadingMore) continue; try { let page = await Api.installationRepositories(group.installation.id); installed = installed.map((value, position) => position === index ? { installation: group.installation, page, loadingMore: false } : value ); } catch (reason) { if (reauthenticate(reason)) throw reason; installed = installed.map((value, position) => position === index ? { ...group, error: true, loadingMore: false } : value ); } publish([installed[index]!], false); } return installed; } export function RepositoryPicker( { current, initialOpen = false }: { current?: RepositoryIdentity; initialOpen?: boolean }, ) { let trigger = useRef(null); let panel = useRef(null); let search = useRef(null); let request = useRef | undefined>(undefined); let mounted = useRef(true); let panelId = useId(); let listId = useId(); let [open, setOpen] = useState(initialOpen); let [position, setPosition] = useState({ visibility: "hidden" }); let [installed, setInstalled] = useState(); let [error, setError] = useState(); let [attempt, setAttempt] = useState(0); let [query, setQuery] = useState(""); let [active, setActive] = useState(0); let normalized = query.trim().toLowerCase(); let repositories = installed?.flatMap(group => group.page.repositories) ?? []; let matches = normalized ? repositories.filter(repository => repository.fullName.toLowerCase().includes(normalized)) : repositories; let activeIndex = matches.length === 0 ? -1 : Math.min(active, matches.length - 1); let activeRepository = matches[activeIndex]; useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); useEffect(() => { if (!open) return; let active = true; let pending = request.current; if (!pending) { let created = installedRepositories((value, replace) => { if (!mounted.current) return; if (replace) return setInstalled(value); let replacements = new Map(value.map(group => [group.installation.id, group])); setInstalled(current => current?.map(group => replacements.get(group.installation.id) ?? group) ?? value ); }); request.current = created; pending = created; } pending.then(() => {}, reason => { if (active && !reauthenticate(reason)) setError(reason); }); return () => { active = false; }; }, [attempt, open]); useLayoutEffect(() => { if (!open) return; function place() { let rect = trigger.current?.getBoundingClientRect(); if (!rect) return; let viewport = currentViewport(); let viewportLeft = viewport.left; let viewportTop = viewport.top; let viewportWidth = viewport.width; let viewportHeight = viewport.height; let margin = 8; let gap = 4; let topEdge = viewportTop + margin; let bottomEdge = viewportTop + viewportHeight - margin; let width = Math.max(0, Math.min(360, viewportWidth - margin * 2)); let left = Math.max( viewportLeft + margin, Math.min(rect.left, viewportLeft + viewportWidth - width - margin), ); let above = Math.max(0, Math.min(rect.top - gap, bottomEdge) - topEdge); let below = Math.max(0, bottomEdge - Math.max(rect.bottom + gap, topEdge)); let useAbove = above > below; let previousHeight = panel.current?.style.height; let previousMaxHeight = panel.current?.style.maxHeight; if (panel.current) { panel.current.style.height = "auto"; panel.current.style.maxHeight = "none"; } let naturalHeight = panel.current?.scrollHeight ?? 0; if (panel.current) { panel.current.style.height = previousHeight ?? ""; panel.current.style.maxHeight = previousMaxHeight ?? ""; } let maxHeight = Math.min(naturalHeight, useAbove ? above : below); let wantedTop = useAbove ? rect.top - gap - maxHeight : rect.bottom + gap; let top = Math.max(topEdge, Math.min(wantedTop, bottomEdge - maxHeight)); setPosition({ height: maxHeight, left, maxHeight, top, visibility: "visible", width, }); } function placeForViewportChange(event?: Event) { let target = event?.target; if (target instanceof Node && panel.current?.contains(target)) return; place(); } place(); return listenToViewportChanges(placeForViewportChange, { observeDocumentScroll: true }); }, [error, installed, matches.length, normalized, open]); useEffect(() => { if (!open) return; let frame = requestAnimationFrame(() => { if (!panel.current?.contains(document.activeElement)) search.current?.focus(); }); return () => cancelAnimationFrame(frame); }, [installed, open]); useEffect(() => { if (!open || !activeRepository) return; let frame = requestAnimationFrame(() => { let option = document.getElementById(optionId(listId, activeRepository)); option?.scrollIntoView({ block: "nearest" }); let scroller = option?.closest("[data-repository-scroll]"); if (!option || !scroller) return; let optionBox = option.getBoundingClientRect(); let scrollerBox = scroller.getBoundingClientRect(); if (optionBox.top < scrollerBox.top || optionBox.bottom > scrollerBox.bottom) { // Chromium can treat a fractional edge as nearest-visible. option.scrollIntoView({ block: "end" }); } }); return () => cancelAnimationFrame(frame); }, [activeRepository, listId, open, position.height, position.top]); useEffect(() => { if (!open) return; function closeOnPointer(event: PointerEvent) { let target = event.target; if (!(target instanceof Node)) return; if (trigger.current?.contains(target) || panel.current?.contains(target)) return; setOpen(false); } function closeOnFocus(event: FocusEvent) { let target = event.target; if (!(target instanceof Node)) return; if (trigger.current?.contains(target) || panel.current?.contains(target)) return; setOpen(false); } function closeOnEscape(event: KeyboardEvent) { if (event.key !== "Escape") return; event.preventDefault(); setOpen(false); trigger.current?.focus(); } document.addEventListener("pointerdown", closeOnPointer); document.addEventListener("focusin", closeOnFocus); document.addEventListener("keydown", closeOnEscape); return () => { document.removeEventListener("pointerdown", closeOnPointer); document.removeEventListener("focusin", closeOnFocus); document.removeEventListener("keydown", closeOnEscape); }; }, [open]); function retry() { request.current = undefined; setError(undefined); setInstalled(undefined); setAttempt(value => value + 1); } async function more(installationId: string) { let group = installed?.find(value => value.installation.id === installationId); if (!group?.page.nextPage || group.loadingMore) return; setInstalled(current => current?.map(value => value.installation.id === installationId ? { ...value, loadingMore: true } : value ) ); setError(undefined); try { let next = await Api.installationRepositories(installationId, group.page.nextPage); if (!mounted.current) return; setInstalled(current => current?.map(value => { if (value.installation.id !== installationId) return value; let known = new Set(value.page.repositories.map(repository => repository.id)); return { ...value, loadingMore: false, page: { repositories: [ ...value.page.repositories, ...next.repositories.filter(repository => !known.has(repository.id)), ], nextPage: next.nextPage, }, }; }) ); } catch (reason) { if (mounted.current && !reauthenticate(reason)) setError(reason); } finally { if (mounted.current) { setInstalled(current => current?.map(value => value.installation.id === installationId ? { ...value, loadingMore: false } : value ) ); } } } async function retryInstallation(installationId: string) { setInstalled(current => current?.map(value => value.installation.id === installationId ? { ...value, error: false, loadingMore: true } : value ) ); try { let page = await Api.installationRepositories(installationId); if (!mounted.current) return; setInstalled(current => current?.map(value => value.installation.id === installationId ? { ...value, error: false, loadingMore: false, page } : value ) ); } catch (reason) { if (mounted.current && !reauthenticate(reason)) { setInstalled(current => current?.map(value => value.installation.id === installationId ? { ...value, error: true, loadingMore: false } : value ) ); } } } function select(repository: Api.Repository) { location.assign(repositoryHref(repository)); } function searchKeyDown(event: ReactKeyboardEvent) { if (event.key === "ArrowDown") { setActive(value => matches.length === 0 ? 0 : (value + 1) % matches.length); } else if (event.key === "ArrowUp") { setActive(value => matches.length === 0 ? 0 : (value - 1 + matches.length) % matches.length); } else if (event.key === "Enter" && activeRepository) { select(activeRepository); } else { return; } event.preventDefault(); } let popup = open && createPortal(
{ setQuery(event.target.value); setActive(0); }} onKeyDown={searchKeyDown} placeholder="Find a repository" ref={search} role="combobox" value={query} />
{!installed && !error && (

Loading repositories...

)} {!installed && error !== undefined && (

Could not load repositories.

)} {installed?.length === 0 && (

Install the GitHub App

Choose the personal or organization repositories Chopin may read.

Install GitHub App
)} {installed?.some(group => group.loadingMore) && repositories.length === 0 && (

Loading installed repositories...

)} {installed && repositories.length === 0 && installed.length > 0 && !installed.some(group => group.loadingMore) && (

No installed repositories are available.

)} {installed && repositories.length > 0 && matches.length === 0 && (

{installed.some(group => group.page.nextPage ) ? "No matches in loaded repositories." : "No matching repositories."}

)} {installed?.filter(group => group.error || group.installation.suspended || !group.installation.permissions.contents ).map(group => (
{group.installation.account.login} {group.error ? ( ) : ( Configure )}
))}
group.loadingMore)} aria-label="Repositories" id={listId} role="listbox" > {installed?.map(group => { let groupRepositories = matches.filter(repository => repository.owner.toLowerCase() === group.installation.account.login.toLowerCase() ); if (groupRepositories.length === 0) return null; return (
{group.installation.account.login}
{groupRepositories.map(repository => { let index = matches.findIndex(value => value.id === repository.id); let selected = sameRepository(current, repository); return ( ); })}
); })}
{installed && (
{error !== undefined && (

Could not load more repositories.

)} {installed.filter(group => group.page.nextPage).map(group => ( ))} Manage repository access
)}
, document.body, ); return ( <> {popup} ); }