import { CheckIcon, ChevronIcon, PlusIcon } from "@chopin/icons"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { documentPath } 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 { rememberChannel } from "./channel-recovery"; import { DocumentRename } from "./document-rename"; import { motionImmediately } from "./motion-input"; import { TerminalAlert } from "./terminal-alert"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; function optionId(list: string, channel: Api.Channel): string { return `${list}-option-${channel.id}`; } function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } function select( userId: string, channel: Api.Channel, repository: Pick, ) { rememberChannel(userId, channel, repository); location.assign(documentPath(repository.owner, repository.name, channel.slug)); } /** Repository-scoped navigation and creation stay separate from repository selection. */ export function DocumentPicker( { canEdit, current, onRename, repository, userId, }: { canEdit: boolean; current: Pick; onRename: (channel: Pick) => void; repository: Pick; userId: string; }, ) { let request = useRef(0); let panelId = useId(); let listId = useId(); let [open, setOpen] = useState(false); let [query, setQuery] = useState(""); let [page, setPage] = useState(); let [listError, setListError] = useState(); let [createError, setCreateError] = useState(); let [creating, setCreating] = useState(false); let [renaming, setRenaming] = useState(false); let [renameError, setRenameError] = useState(); let [renameSaving, setRenameSaving] = useState(false); let [loadingMore, setLoadingMore] = useState(false); let [active, setActive] = useState(0); let [retry, setRetry] = useState(0); let normalized = query.trim(); let channels = page?.channels ?? []; let activeIndex = channels.length === 0 ? -1 : Math.min(active, channels.length - 1); let activeChannel = channels[activeIndex]; let pickerContent = useMemo( () => ({ channels: channels.length, createError, listError, renameError, renaming }), [channels.length, createError, listError, renameError, renaming], ); let setPickerOpen = (next: boolean) => { if (!next && renameSaving) return; setOpen(next); }; let { panel, position, search, trigger } = useAnchoredPicker(open, setPickerOpen, pickerContent); let popupPresence = useTransitionPresence( open ? true : undefined, 150, motionImmediately(), ); useEffect(() => { if (!open) return; let id = ++request.current; let timer = window.setTimeout(() => { setPage(undefined); setListError(undefined); Api.channels(repository.owner, repository.name, { includeArchived: false, query: normalized || undefined, }).then(value => { if (request.current !== id) return; setPage(value); setActive(0); }, reason => { if (request.current !== id) return; setListError(reason); }); }, normalized ? 160 : 0); return () => window.clearTimeout(timer); }, [current.title, normalized, open, repository.name, repository.owner, retry]); useEffect(() => { if (!open || !activeChannel) return; let frame = requestAnimationFrame(() => { document.getElementById(optionId(listId, activeChannel))?.scrollIntoView({ block: "nearest", }); }); return () => cancelAnimationFrame(frame); }, [activeChannel, listId, open]); function searchKeyDown(event: ReactKeyboardEvent) { if (event.key === "ArrowDown") { setActive(value => channels.length === 0 ? 0 : (value + 1) % channels.length); } else if (event.key === "ArrowUp") { setActive(value => channels.length === 0 ? 0 : (value - 1 + channels.length) % channels.length ); } else if (event.key === "Enter" && activeChannel) { select(userId, activeChannel, repository); } else return; event.preventDefault(); } async function more() { if (!page?.nextCursor || loadingMore) return; let id = request.current; setLoadingMore(true); try { let next = await Api.channels(repository.owner, repository.name, { cursor: page.nextCursor, includeArchived: false, query: normalized || undefined, }); if (request.current !== id) return; setPage(current => current && { ...next, channels: [ ...current.channels, ...next.channels.filter(channel => !current.channels.some(known => known.id === channel.id) ), ], } ); } catch (reason) { if (request.current === id) setListError(reason); } finally { if (request.current === id) setLoadingMore(false); } } async function create() { if (creating) return; setCreating(true); setCreateError(undefined); try { let created = await Api.createChannel(repository.owner, repository.name); rememberChannel(userId, created.channel, created.repository); location.assign(documentPath( created.repository.owner, created.repository.name, created.channel.slug, )); } catch (reason) { setCreateError(reason); setCreating(false); } } function finishRename(detail: Api.ChannelDetail) { onRename(detail.channel); setRenaming(false); setRenameError(undefined); setRenameSaving(false); setRetry(value => value + 1); requestAnimationFrame(() => search.current?.focus()); } function cancelRename() { setRenaming(false); setRenameError(undefined); setRenameSaving(false); requestAnimationFrame(() => search.current?.focus()); } let popup = popupPresence.phase !== "closed" && createPortal(
{ setQuery(event.target.value); setActive(0); }} onKeyDown={searchKeyDown} placeholder="Find a document" ref={search} role="combobox" value={query} />
{ let node = event.currentTarget; if (node.scrollHeight - node.scrollTop - node.clientHeight < 32) void more(); }} style={{ maxHeight: "min(60vh, 384px)" }} > {!page && !listError && (

Loading documents...

)} {listError !== undefined && (
{message(listError, "Could not load documents.")}
)} {page && !listError && channels.length === 0 && (

No matching documents.

)}
{channels.map((channel, index) => { let selected = channel.id === current.id; let descriptionId = channel.description ? `${optionId(listId, channel)}-description` : undefined; return ( ); })}
{loadingMore && (

Loading...

)}
{canEdit && (
{renaming ? ( ) : ( <> {createError !== undefined && ( {message(createError, "Could not create document.")} )} )}
)}
, document.body, ); return ( <> {popup} ); }