Draft panes are gone: an empty pane is a placeholder again, and every spawn affordance opens a dialog around the shared spawn form. Escape, Cancel, the ✕ and a backdrop click dismiss it; a successful submit puts the session in a new tab (Spawn similar and the fast-forward fallback keep placing it beside the pane they acted on). Removes the per-leaf draft state, its persistence strip, and the form reuse/teardown machinery the panes needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
236 lines
7.8 KiB
TypeScript
236 lines
7.8 KiB
TypeScript
// Shell-style directory autocomplete for a path <input> (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>`:
|
|
//
|
|
// 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 the surrounding spawn modal
|
|
//
|
|
// 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<string, string[]>();
|
|
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 close the
|
|
// spawn modal 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();
|
|
},
|
|
};
|
|
}
|