import { LoaderIcon, SearchIcon } from "@chopin/icons"; import { useEffect, useRef, useState } from "react"; import * as Api from "./api"; import { NavigationDialog } from "./navigation-dialog"; import { PaletteListbox } from "./navigation-palette"; import type { NavigationDialogMotion } from "./navigation-dialog"; export type DocumentSearchResult = { project: Api.NavigationProject; channel: Api.Channel; }; export async function searchAvailableDocuments( projects: Api.NavigationProject[], query: string, includeArchived: boolean, load: typeof Api.channels = Api.channels, signal?: AbortSignal, ): Promise<{ results: DocumentSearchResult[]; failedProjectIds: string[] }> { let searched = await Promise.all( projects.filter(project => project.available).map( async project => { let channels: Api.Channel[] = []; let cursor: string | undefined; try { do { let page = await load( project.repositoryOwner, project.repositoryName, { cursor, includeArchived, query: query.trim() || undefined, signal, }, ); channels.push(...page.channels); cursor = page.nextCursor; } while (cursor); return { channels, project }; } catch (error) { if (signal?.aborted) throw error; return { channels: [], error, project }; } }, ), ); return { results: searched.flatMap(({ channels, project }) => channels.map(channel => ({ channel, project })) ), failedProjectIds: searched.filter(result => "error" in result) .map(result => result.project.repositoryId), }; } type SearchState = | { status: "loading" } | { status: "ready"; results: DocumentSearchResult[]; failedProjectIds: string[] } | { status: "error"; message: string }; // Last unfiltered result. It is valid only for the same user, project set and // document catalogue identity, so mutations never leave stale rows behind. let recent: { key: string; source: unknown; results: DocumentSearchResult[] } | undefined; let recentKey = (userId: string, projects: Api.NavigationProject[], includeArchived: boolean) => `${userId}:${includeArchived}:${projects.map(project => project.repositoryId).join(",")}`; function rememberRecent( key: string, source: unknown, result: Awaited>, ) { if (!result.failedProjectIds.length) recent = { key, source, results: result.results }; } export function warmRecentSearch( userId: string, projects: Api.NavigationProject[], includeArchived: boolean, source: unknown, ) { return searchAvailableDocuments(projects, "", includeArchived).then( result => rememberRecent(recentKey(userId, projects, includeArchived), source, result), () => {}, ); } export function DocumentSearchDialog( { includeArchived, motion, onDismiss, onSelect, projects, source, userId, }: { includeArchived: boolean; motion: NavigationDialogMotion; onDismiss: () => void; onSelect: (documentId: string) => void; projects: Api.NavigationProject[]; source: unknown; userId: string; }, ) { let input = useRef(null); let [query, setQuery] = useState(""); let [retry, setRetry] = useState(0); let key = recentKey(userId, projects, includeArchived); let cached = () => recent?.key === key && recent.source === source ? recent : undefined; let [search, setSearch] = useState(() => { let hit = cached(); return hit ? { status: "ready", results: hit.results, failedProjectIds: [] } : { status: "loading" }; }); useEffect(() => { let active = true; let controller = new AbortController(); let timer = window.setTimeout(() => { let hit = query.trim() ? undefined : cached(); setSearch( hit ? { status: "ready", results: hit.results, failedProjectIds: [] } : { status: "loading" }, ); searchAvailableDocuments( projects, query, includeArchived, Api.channels, controller.signal, ).then(result => { if (!active) return; if (!query.trim()) rememberRecent(key, source, result); setSearch({ status: "ready", ...result }); }, error => { if (active && !controller.signal.aborted) { setSearch({ status: "error", message: error instanceof Error ? error.message : "Could not search documents.", }); } }); }, 150); return () => { active = false; window.clearTimeout(timer); controller.abort(); }; }, [includeArchived, key, projects, query, retry, source]); let results = search.status === "ready" ? search.results : []; let manyProjects = projects.length > 1; return (
{search.status === "loading" && (

)} {search.status === "error" && (

{search.message}

)} {search.status === "ready" && search.failedProjectIds.length > 0 && (

Some Projects could not be searched.

)} {search.status === "ready" && results.length === 0 && (

{query.trim() ? "No matching documents" : "No documents yet"}

)} {results.length > 0 && ( channel.id} items={results} label="Documents" onChoose={({ channel }) => { onSelect(channel.id); onDismiss(); }} query={query} renderItem={({ channel, project }) => ( <> {channel.title} {channel.description && ( {channel.description} )} {channel.archivedAt && Archived} {manyProjects && ( {project.repositoryOwner}/{project.repositoryName} )} )} /> )}
); }