import type { Signal } from "@char/aftercare"; import { type ProjectIndex, workspacePath } from "@berry/api/projects.ts"; import { api } from "./client.ts"; import type { Scope } from "./ui.ts"; export type FolderOptions = { scope: Scope; environment: Signal; base: () => string | null; projects?: Signal; }; type Suggestion = { path: string; project?: string; platform?: string }; let nextList = 0; export function folderInput({ scope, environment, base, projects }: FolderOptions) { const id = `folder-options-${++nextList}`; const input = ( ) as HTMLInputElement; const list =
as HTMLElement; const status =

as HTMLElement; const menu =

as HTMLElement; const element =
{input}{menu}
as HTMLElement; let suggestions: Suggestion[] = []; // the last answer for this machine, which stands in, narrowed, while the next is fetched let found: string[] = []; let selected = -1; let revision = 0; let timer: ReturnType | undefined; let request: AbortController | undefined; let focused = false; const draw = () => { list.replaceChildren(...suggestions.map((suggestion, index) => ( ))); menu.hidden = suggestions.length === 0 && !status.textContent; input.setAttribute("aria-expanded", String(suggestions.length > 0)); if (selected < 0) input.removeAttribute("aria-activedescendant"); else input.setAttribute("aria-activedescendant", `${id}-${selected}`); }; const cancel = () => { revision++; clearTimeout(timer); request?.abort(); }; const close = () => { cancel(); suggestions = []; selected = -1; status.textContent = ""; draw(); }; const choose = (path: string) => { input.value = path; input.dispatchEvent(new Event("input", { bubbles: true })); close(); }; const refresh = () => { cancel(); if (!focused) return close(); const version = revision; const machine = environment.get(); const prefix = input.value; const saved = Object.values(projects?.get() ?? {}).flatMap((project) => project.roots.filter((root) => root.environment === machine && (root.platform === "win32" ? root.cwd.toLowerCase().includes(prefix.toLowerCase()) : root.cwd.includes(prefix)) ).map((root) => ({ path: root.cwd, project: project.name, platform: root.platform })) ).slice(0, 10); const merge = (paths: string[]) => [ ...saved, ...paths.filter((path) => !saved.some((item) => workspacePath(item.path, item.platform) === workspacePath(path, item.platform) ) ).map((path) => ({ path })), ]; suggestions = merge(found.filter((path) => path.startsWith(prefix))); selected = -1; status.textContent = suggestions.length ? "" : "finding folders…"; draw(); timer = setTimeout(async () => { request = new AbortController(); const query = new URLSearchParams({ environment: machine, prefix }); const cwd = base(); if (cwd) query.set("cwd", cwd); try { const paths = await api( "GET", `/api/folders?${query}`, undefined, request.signal, ); if (version !== revision) return; found = paths; suggestions = merge(paths); status.textContent = suggestions.length ? "" : "no matching folders"; draw(); } catch (error) { if (version !== revision) return; status.textContent = (error as Error).message; draw(); } }, 120); }; input.addEventListener("focus", () => { focused = true; refresh(); }); input.addEventListener("input", refresh); input.addEventListener("blur", () => { focused = false; close(); }); input.addEventListener("keydown", (event) => { if (event.key === "Escape" && !menu.hidden) { event.preventDefault(); event.stopImmediatePropagation(); close(); } else if ((event.key === "ArrowDown" || event.key === "ArrowUp") && suggestions.length) { event.preventDefault(); selected = selected < 0 ? event.key === "ArrowDown" ? 0 : suggestions.length - 1 : (selected + (event.key === "ArrowDown" ? 1 : -1) + suggestions.length) % suggestions.length; draw(); list.children[selected]?.scrollIntoView?.({ block: "nearest" }); } else if (event.key === "Enter" && selected >= 0) { event.preventDefault(); event.stopImmediatePropagation(); choose(suggestions[selected].path); } }); scope.add( environment.subscribe(() => { found = []; refresh(); }).unsubscribe, ); if (projects) scope.add(projects.subscribe(refresh).unsubscribe); scope.add(close); return { input, element }; }