// Shell-style directory autocomplete for a path (NAP-329). // // Lists one directory at a time over the live `GET /api/dirs` endpoint, caches // per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/` // hits the network. 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) // Esc close the dropdown, refocus the input, and swallow the key so it // can't also discard a surrounding draft pane // // Once dismissed (Esc or blur) the popup stays closed until the operator types // again — refocusing alone doesn't reopen it. // // Failures (offline daemon, permission denied) degrade to no suggestions. import { splitPath } from "./paths.js"; import { listDir } from "./api.js"; export interface DirAutocompleteHandle { destroy(): void; } /** Most entries we render at once — a long list is unhelpful and slow. */ const MAX_VISIBLE = 60; /** Debounce before a network fetch when the user crosses into a new directory. */ const FETCH_DEBOUNCE_MS = 90; /** Split a path value into the directory to list and the trailing fragment * being typed within it. The directory keeps its trailing `/` (or is empty, * meaning "the daemon's home dir"). */ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteHandle { const dropdown = document.createElement("ul"); dropdown.className = "dir-ac"; dropdown.setAttribute("role", "listbox"); dropdown.hidden = true; document.body.appendChild(dropdown); // `dir -> entries`. Successful listings only; errors aren't cached so a // transient failure can recover on the next keystroke. const cache = new Map(); let filtered: string[] = []; let highlight = -1; let fetchSeq = 0; let debounce: number | undefined; let open = false; // Set when the operator dismissed the popup (Esc, blur, outside click); a // refocus must not reopen it — only new typing does. let dismissed = false; function close(): void { if (!open) return; open = false; dropdown.hidden = true; dropdown.replaceChildren(); highlight = -1; window.removeEventListener("scroll", reposition, true); window.removeEventListener("resize", reposition); } function dismiss(): void { dismissed = true; close(); } function reposition(): void { const r = input.getBoundingClientRect(); dropdown.style.left = `${r.left}px`; dropdown.style.top = `${r.bottom + 4}px`; dropdown.style.width = `${r.width}px`; } function render(): void { dropdown.replaceChildren(); if (filtered.length === 0) { close(); return; } const shown = filtered.slice(0, MAX_VISIBLE); shown.forEach((name, i) => { const li = document.createElement("li"); li.className = "dir-ac-item" + (i === highlight ? " is-active" : ""); li.setAttribute("role", "option"); li.setAttribute("aria-selected", String(i === highlight)); li.textContent = name + "/"; // mousedown (not click) so we accept before the input's blur fires. li.addEventListener("mousedown", (e) => { e.preventDefault(); accept(name); }); dropdown.appendChild(li); }); if (filtered.length > MAX_VISIBLE) { const more = document.createElement("li"); more.className = "dir-ac-more"; more.textContent = `+${filtered.length - MAX_VISIBLE} more — keep typing to narrow`; dropdown.appendChild(more); } if (!open) { open = true; dropdown.hidden = false; window.addEventListener("scroll", reposition, true); window.addEventListener("resize", reposition); } reposition(); } function applyFilter(entries: string[], prefix: string): void { // A fetch can outlive the dismissal that was meant to close this popup // (Tab-commit re-arms a refresh, then blur dismisses 120ms later). Showing // it now would anchor a dropdown under an unfocused input that nothing // closes until the next interaction. if (dismissed || document.activeElement !== input) { close(); return; } const lower = prefix.toLowerCase(); filtered = entries.filter((e) => e.toLowerCase().startsWith(lower)); highlight = filtered.length > 0 ? 0 : -1; render(); } // Fetch (or read from cache) the listing for the directory currently implied // by the input, then filter by the trailing fragment. function refresh(): void { const { dir, prefix } = splitPath(input.value); const cached = cache.get(dir); if (cached) { applyFilter(cached, prefix); return; } const seq = ++fetchSeq; listDir(dir) .then((listing) => { cache.set(dir, listing.entries); if (seq !== fetchSeq) return; // a newer fetch superseded this one // The input may have changed while in flight; re-split before filtering. const cur = splitPath(input.value); if (cur.dir === dir) applyFilter(listing.entries, cur.prefix); }) .catch(() => { if (seq === fetchSeq) close(); }); } function scheduleRefresh(): void { window.clearTimeout(debounce); debounce = window.setTimeout(refresh, FETCH_DEBOUNCE_MS); } // Accept `name` as the next path segment: complete it 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. function commit(name: string): void { const { dir } = splitPath(input.value); input.value = `${dir}${name}/`; // Notify other listeners (spawnForm marks the field user-edited). input.dispatchEvent(new Event("input", { bubbles: true })); } function accept(name: string): void { commit(name); input.focus(); } function onInput(): void { dismissed = false; scheduleRefresh(); } function onFocus(): void { if (dismissed) return; refresh(); } function onKeydown(e: KeyboardEvent): void { if (e.key === "Escape") { if (open) { e.preventDefault(); // Swallow the key: an Escape that bubbles would also discard the // draft pane hosting this form. e.stopPropagation(); dismiss(); input.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(); highlight = Math.min(highlight + 1, filtered.length - 1); render(); } else if (e.key === "ArrowUp") { e.preventDefault(); highlight = Math.max(highlight - 1, 0); render(); } else if (e.key === "Enter") { if (highlight >= 0 && highlight < filtered.length) { e.preventDefault(); // accept the entry instead of submitting the form accept(filtered[highlight]); } } } function onBlur(): void { // Delay so a dropdown mousedown can complete first. window.setTimeout(dismiss, 120); } input.addEventListener("input", onInput); input.addEventListener("focus", onFocus); input.addEventListener("keydown", onKeydown); input.addEventListener("blur", onBlur); return { destroy() { window.clearTimeout(debounce); close(); input.removeEventListener("input", onInput); input.removeEventListener("focus", onFocus); input.removeEventListener("keydown", onKeydown); input.removeEventListener("blur", onBlur); dropdown.remove(); }, }; }