This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/dirAutocomplete.ts
T
Miguel Palhas 8376122dfe fix(maestro): second-review findings across daemon, tui and web
Ten findings from reviewing the accumulated blitz/spawn-v2 diff. The two
that could wedge a repo:

- The cleanup guard refused any worktree whose commits were on no other
  ref, which is every ordinary unpushed branch. Paths are deterministic
  now, so the leftover tree blocked the next spawn and each branch became
  a one-shot. `worktree remove` keeps refs/heads/<branch>, so committed
  work is never at risk: guard on an unclean working tree alone.
- A worktree whose directory had vanished failed the git probe and was
  reported as unsaved work, stranding the registration forever. Prune it
  instead, and prune before `worktree add` tests the target path.

Also:

- The CLI hardcoded create_branch: true, so the daemon's existing-branch
  path was unreachable. Added --existing-branch.
- Pastes were forwarded without their DECSET 2004 markers, so a remote
  shell ran a multi-line paste line by line.
- spawnForm emitted before its seed was restored and after destroy(), so
  a rebuilt draft blanked its cwd and a dead form clobbered the live one.
- showRawCwd and the probe's worktree untick never reached the leaf.
- A late autocomplete fetch reopened the popup under a blurred input.
- The "remove worktree when killed" checkbox promised something the
  daemon no longer honours; removed.
2026-08-23 20:54:21 +01:00

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