// React port of ../../dirAutocomplete.ts: shell-style directory autocomplete // on a path . Lists one directory at a time over `GET /api/dirs`, // caches per directory, and filters the trailing path segment locally. // // Keyboard model mirrors `cd dir/sub`: // Tab commit the highlighted entry and let focus move on; with nothing // highlighted, plain tab navigation // ↑/↓ move the highlight // Enter accept the highlighted entry (and suppress form submit); with // nothing highlighted, bubble to the form unprevented // Esc close the dropdown, refocus the input, and stop the key from // bubbling — so dismissing the popup can't also close a parent modal // // Once dismissed (Esc or blur) the popup stays closed until the operator // types again — refocusing alone doesn't reopen it. import { useCallback, useEffect, useRef, useState, type InputHTMLAttributes, type Ref } from "react"; import { createPortal } from "react-dom"; import { listDir } from "../../api.js"; import { splitPath } from "../../paths.js"; /** Most entries rendered at once — a long list is unhelpful and slow. */ const MAX_VISIBLE = 60; /** Debounce before a network fetch when the input crosses into a new directory. */ const FETCH_DEBOUNCE_MS = 90; export interface DirAutocompleteProps extends Omit, "value" | "onChange"> { value: string; onChange(value: string): void; /** Access to the underlying `` — e.g. to focus/select it. */ inputRef?: Ref; } function assignRef(ref: Ref | undefined, el: HTMLInputElement | null): void { if (!ref) return; if (typeof ref === "function") ref(el); else (ref as { current: HTMLInputElement | null }).current = el; } export function DirAutocomplete({ value, onChange, inputRef, ...inputProps }: DirAutocompleteProps) { const localRef = useRef(null); const cache = useRef(new Map()); const fetchSeq = useRef(0); const debounceTimer = useRef(undefined); const dismissedRef = useRef(false); const [filtered, setFiltered] = useState([]); const [highlight, setHighlight] = useState(-1); const [open, setOpen] = useState(false); const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null); const setInputRef = useCallback( (el: HTMLInputElement | null) => { localRef.current = el; assignRef(inputRef, el); }, [inputRef], ); const reposition = useCallback(() => { const el = localRef.current; if (!el) return; const r = el.getBoundingClientRect(); setRect({ left: r.left, top: r.bottom + 4, width: r.width }); }, []); const close = useCallback(() => { setOpen(false); setFiltered([]); setHighlight(-1); }, []); const dismiss = useCallback(() => { dismissedRef.current = true; close(); }, [close]); const applyFilter = useCallback( (entries: string[], prefix: string) => { // A fetch can outlive the dismissal it was meant to serve (Tab-commit // re-arms a refresh, then blur dismisses 120ms later). Showing it now // would anchor a dropdown under an unfocused input nothing closes. if (dismissedRef.current || document.activeElement !== localRef.current) { close(); return; } const lower = prefix.toLowerCase(); const next = entries.filter((e) => e.toLowerCase().startsWith(lower)); setFiltered(next); setHighlight(next.length > 0 ? 0 : -1); if (next.length === 0) { close(); return; } setOpen(true); reposition(); }, [close, reposition], ); const refresh = useCallback( (raw: string) => { const { dir, prefix } = splitPath(raw); const cached = cache.current.get(dir); if (cached) { applyFilter(cached, prefix); return; } const seq = ++fetchSeq.current; listDir(dir) .then((listing) => { cache.current.set(dir, listing.entries); if (seq !== fetchSeq.current) return; // a newer fetch superseded this one const cur = splitPath(localRef.current?.value ?? raw); if (cur.dir === dir) applyFilter(listing.entries, cur.prefix); }) .catch(() => { if (seq === fetchSeq.current) close(); }); }, [applyFilter, close], ); const scheduleRefresh = useCallback( (raw: string) => { window.clearTimeout(debounceTimer.current); debounceTimer.current = window.setTimeout(() => refresh(raw), FETCH_DEBOUNCE_MS); }, [refresh], ); /** Complete `name` as the next path segment and append `/` so the child * directory's listing loads on the next interaction — the drill-in * gesture. Unlike `accept`, doesn't steal focus: Tab uses this to commit * and move to the next field in one gesture. */ const commit = useCallback( (name: string) => { const { dir } = splitPath(value); onChange(`${dir}${name}/`); }, [value, onChange], ); const accept = useCallback( (name: string) => { commit(name); localRef.current?.focus(); }, [commit], ); useEffect(() => { if (!open) return; window.addEventListener("scroll", reposition, true); window.addEventListener("resize", reposition); return () => { window.removeEventListener("scroll", reposition, true); window.removeEventListener("resize", reposition); }; }, [open, reposition]); useEffect(() => () => window.clearTimeout(debounceTimer.current), []); return ( <> { dismissedRef.current = false; const next = e.currentTarget.value; onChange(next); scheduleRefresh(next); }} onFocus={(e) => { inputProps.onFocus?.(e); if (dismissedRef.current) return; refresh(value); }} onBlur={(e) => { inputProps.onBlur?.(e); // Delay so a dropdown mousedown can complete first. window.setTimeout(dismiss, 120); }} onKeyDown={(e) => { inputProps.onKeyDown?.(e); if (e.defaultPrevented) return; if (e.key === "Escape") { if (open) { e.preventDefault(); e.stopPropagation(); dismiss(); localRef.current?.focus(); } return; } if (e.key === "Tab") { if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation commit(filtered[highlight]); // no preventDefault — focus moves to the next field return; } if (!open) return; if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((h) => Math.min(h + 1, filtered.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((h) => Math.max(h - 1, 0)); } else if (e.key === "Enter" && highlight >= 0 && highlight < filtered.length) { e.preventDefault(); // accept the entry instead of submitting the form accept(filtered[highlight]); } }} /> {open && rect && createPortal(
    {filtered.slice(0, MAX_VISIBLE).map((name, i) => (
  • { e.preventDefault(); // accept before the input's blur fires accept(name); }} > {name}/
  • ))} {filtered.length > MAX_VISIBLE && (
  • +{filtered.length - MAX_VISIBLE} more — keep typing to narrow
  • )}
, document.body, )} ); }