// Shell-style directory autocomplete for a path (NAP-329). // // Lists one directory at a time over the live `GET /api/daemons/:id/dirs` // endpoint, caches per `(daemon, dir)`, 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 complete the longest common prefix; if one match, accept it (and // append `/` so the next level loads); if nothing to extend, cycle. // ↑/↓ move the highlight // Enter accept the highlighted entry (and suppress form submit) // Esc close the dropdown // // Failures (offline daemon, permission denied) degrade to no suggestions. 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"). */ function splitPath(value: string): { dir: string; prefix: string } { const slash = value.lastIndexOf("/"); if (slash === -1) return { dir: "", prefix: value }; return { dir: value.slice(0, slash + 1), prefix: value.slice(slash + 1) }; } /** Longest common prefix of a list of strings (case-sensitive, preserving the * actual on-disk casing). */ function longestCommonPrefix(items: string[]): string { if (items.length === 0) return ""; let lcp = items[0]; for (const s of items.slice(1)) { let i = 0; while (i < lcp.length && i < s.length && lcp[i] === s[i]) i++; lcp = lcp.slice(0, i); if (lcp === "") break; } return lcp; } export function attachDirAutocomplete( input: HTMLInputElement, getDaemonId: () => string, ): DirAutocompleteHandle { const dropdown = document.createElement("ul"); dropdown.className = "dir-ac"; dropdown.setAttribute("role", "listbox"); dropdown.hidden = true; document.body.appendChild(dropdown); // `(daemonId\0dir) -> 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; const cacheKey = (daemonId: string, dir: string) => `${daemonId}${dir}`; 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 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 { 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 daemonId = getDaemonId(); if (!daemonId) { close(); return; } const { dir, prefix } = splitPath(input.value); const key = cacheKey(daemonId, dir); const cached = cache.get(key); if (cached) { applyFilter(cached, prefix); return; } const seq = ++fetchSeq; listDir(daemonId, dir) .then((listing) => { cache.set(key, 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 (cacheKey(getDaemonId(), cur.dir) === key) 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 immediately (the drill-in gesture). function accept(name: string): void { const { dir } = splitPath(input.value); input.value = `${dir}${name}/`; input.focus(); // Notify other listeners (spawn.ts marks the field user-edited) and load // the child level. input.dispatchEvent(new Event("input", { bubbles: true })); } function onInput(): void { scheduleRefresh(); } function onFocus(): void { refresh(); } function onKeydown(e: KeyboardEvent): void { if (e.key === "Escape") { if (open) { e.preventDefault(); close(); } return; } if (e.key === "Tab") { if (!open || filtered.length === 0) return; // let focus move normally e.preventDefault(); if (filtered.length === 1) { accept(filtered[0]); return; } const { dir, prefix } = splitPath(input.value); const lcp = longestCommonPrefix(filtered); if (lcp.length > prefix.length) { input.value = `${dir}${lcp}`; input.dispatchEvent(new Event("input", { bubbles: true })); } else { // Nothing to extend — cycle the highlight like a second shell . highlight = (highlight + 1) % filtered.length; render(); } 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(close, 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(); }, }; }