// 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();
},
};
}