import { CheckIcon, ChevronIcon } from "@chopin/icons"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { documentsPath } from "@chopin/protocol/document-url"; import { createPortal } from "react-dom"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useAnchoredPicker } from "./anchored-picker"; import * as Api from "./api"; import { motionImmediately } from "./motion-input"; import { clearRepositoryCache, installedRepositoryGroups, loadRepositorySnapshot, readRepositoryCache, repositoryCacheIsStale, writeRepositoryCache, } from "./repository-snapshot"; import { TerminalAlert } from "./terminal-alert"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import type { InstalledRepositoryGroup, RepositorySnapshot } from "./repository-snapshot"; export type RepositoryIdentity = Pick< Api.Repository, "owner" | "ownerAvatarUrl" | "name" | "fullName" >; function repositoryHref(repository: RepositoryIdentity): string { return documentsPath(repository.owner, 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, userId: string): boolean { if (!(reason instanceof Api.ApiError) || reason.status !== 401) return false; clearRepositoryCache(userId); location.reload(); return true; } export function RepositoryPicker( { compact = false, current, initialOpen = false, userId, }: { compact?: boolean; current?: RepositoryIdentity; initialOpen?: boolean; userId: string }, ) { let [initial] = useState(() => readRepositoryCache(userId)); let request = useRef | undefined>(undefined); let snapshot = useRef(initial); let mounted = useRef(true); let previousQuery = useRef(""); let panelId = useId(); let listId = useId(); let [open, setOpen] = useState(initialOpen); let [installed, setInstalled] = useState(() => initial ? installedRepositoryGroups(initial) : undefined ); let [error, setError] = useState(); let [loading, setLoading] = useState(!initial); let [refreshing, setRefreshing] = useState(false); let [query, setQuery] = useState(""); let [active, setActive] = useState(0); let [avatarFailed, setAvatarFailed] = useState(false); let [avatarUrlForFailure, setAvatarUrlForFailure] = useState(current?.ownerAvatarUrl); if (avatarUrlForFailure !== current?.ownerAvatarUrl) { setAvatarUrlForFailure(current?.ownerAvatarUrl); setAvatarFailed(false); } let normalized = query.trim().toLowerCase(); let repositories = installed?.flatMap(group => group.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]; let pickerContent = useMemo( () => ({ error, installed, loading, matches: matches.length, normalized, refreshing }), [error, installed, loading, matches.length, normalized, refreshing], ); let { panel, position, search, trigger } = useAnchoredPicker(open, setOpen, pickerContent); let popupPresence = useTransitionPresence( open ? true : undefined, 150, motionImmediately(), ); useEffect(() => { mounted.current = true; if (!snapshot.current) void refresh(); return () => { mounted.current = false; }; }, []); useEffect(() => { if (!open) { previousQuery.current = ""; return; } let started = !previousQuery.current && !!normalized; previousQuery.current = normalized; if (started && snapshot.current && repositoryCacheIsStale(snapshot.current)) void refresh(); }, [normalized, 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]); function refresh(): Promise { if (request.current) return request.current; let previous = snapshot.current; setError(undefined); if (previous) setRefreshing(true); else setLoading(true); let pending = loadRepositorySnapshot( userId, previous, previous ? undefined : value => { if (mounted.current) setInstalled(installedRepositoryGroups(value)); }, ).then(value => { if (!mounted.current) return; snapshot.current = value; writeRepositoryCache(value); setInstalled(installedRepositoryGroups(value)); }, reason => { if (mounted.current && !reauthenticate(reason, userId)) setError(reason); }).finally(() => { if (request.current === pending) request.current = undefined; if (mounted.current) { setLoading(false); setRefreshing(false); } }); request.current = pending; return pending; } function retry() { void refresh(); } 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 = popupPresence.phase !== "closed" && createPortal(
{ setQuery(event.target.value); setActive(0); }} onKeyDown={searchKeyDown} placeholder="Find a repository" ref={search} role="combobox" value={query} />
{!installed && loading && (

Loading repositories...

)} {!installed && !loading && error !== undefined && (
Could not load repositories.
)} {installed?.length === 0 && !loading && (

Install the GitHub App

Choose the personal or organization repositories Chopin may read.

Install GitHub App
)} {installed && loading && repositories.length === 0 && installed.length > 0 && (

Loading installed repositories...

)} {installed && loading && repositories.length > 0 && (

Loading remaining repositories...

)} {installed && refreshing && (

Refreshing repositories...

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

No installed repositories are available.

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

{loading || refreshing ? "Checking for matching repositories..." : "No matching repositories."}

)} {installed?.filter(group => group.installation.suspended || !group.installation.permissions.contents ).map(group => (
{group.installation.account.login} Configure
))}
{installed?.map(group => { let known = new Set(group.repositories.map(repository => repository.id)); let groupRepositories = matches.filter(repository => known.has(repository.id)); 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 && ( {snapshot.current ? "Could not refresh repositories." : "Could not load all repositories."} )} Manage repository access
)}
, document.body, ); return ( <> {popup} ); }