Ports the vanilla spawn form/modal to src/components/spawn/: SpawnModal, SpawnForm, DirAutocomplete, BranchPicker, and a useFastForward hook for the one-keystroke spawn. Pure logic modules (spawnForm.ts's buildSpawnRequest, fastForward.ts, spawnSimilar.ts, projectList.ts, toolIcon.ts) are reused as-is rather than re-derived. Exports supportsStructuredView from spawnForm.ts so the React form can share the structured-view-eligibility check with the vanilla one.
257 lines
8.6 KiB
TypeScript
257 lines
8.6 KiB
TypeScript
// React port of ../../dirAutocomplete.ts: shell-style directory autocomplete
|
|
// on a path <input>. Lists one directory at a time over `GET /api/dirs`,
|
|
// caches per directory, and filters the trailing path segment locally.
|
|
//
|
|
// 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); with
|
|
// nothing highlighted, bubble to the form unprevented
|
|
// Esc close the dropdown, refocus the input, and stop the key from
|
|
// bubbling — so dismissing the popup can't also close a parent modal
|
|
//
|
|
// Once dismissed (Esc or blur) the popup stays closed until the operator
|
|
// types again — refocusing alone doesn't reopen it.
|
|
|
|
import { useCallback, useEffect, useRef, useState, type InputHTMLAttributes, type Ref } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
import { listDir } from "../../api.js";
|
|
import { splitPath } from "../../paths.js";
|
|
|
|
/** Most entries rendered at once — a long list is unhelpful and slow. */
|
|
const MAX_VISIBLE = 60;
|
|
/** Debounce before a network fetch when the input crosses into a new directory. */
|
|
const FETCH_DEBOUNCE_MS = 90;
|
|
|
|
export interface DirAutocompleteProps
|
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
|
|
value: string;
|
|
onChange(value: string): void;
|
|
/** Access to the underlying `<input>` — e.g. to focus/select it. */
|
|
inputRef?: Ref<HTMLInputElement>;
|
|
}
|
|
|
|
function assignRef(ref: Ref<HTMLInputElement> | undefined, el: HTMLInputElement | null): void {
|
|
if (!ref) return;
|
|
if (typeof ref === "function") ref(el);
|
|
else (ref as { current: HTMLInputElement | null }).current = el;
|
|
}
|
|
|
|
export function DirAutocomplete({ value, onChange, inputRef, ...inputProps }: DirAutocompleteProps) {
|
|
const localRef = useRef<HTMLInputElement | null>(null);
|
|
const cache = useRef(new Map<string, string[]>());
|
|
const fetchSeq = useRef(0);
|
|
const debounceTimer = useRef<number | undefined>(undefined);
|
|
const dismissedRef = useRef(false);
|
|
|
|
const [filtered, setFiltered] = useState<string[]>([]);
|
|
const [highlight, setHighlight] = useState(-1);
|
|
const [open, setOpen] = useState(false);
|
|
const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null);
|
|
|
|
const setInputRef = useCallback(
|
|
(el: HTMLInputElement | null) => {
|
|
localRef.current = el;
|
|
assignRef(inputRef, el);
|
|
},
|
|
[inputRef],
|
|
);
|
|
|
|
const reposition = useCallback(() => {
|
|
const el = localRef.current;
|
|
if (!el) return;
|
|
const r = el.getBoundingClientRect();
|
|
setRect({ left: r.left, top: r.bottom + 4, width: r.width });
|
|
}, []);
|
|
|
|
const close = useCallback(() => {
|
|
setOpen(false);
|
|
setFiltered([]);
|
|
setHighlight(-1);
|
|
}, []);
|
|
|
|
const dismiss = useCallback(() => {
|
|
dismissedRef.current = true;
|
|
close();
|
|
}, [close]);
|
|
|
|
const applyFilter = useCallback(
|
|
(entries: string[], prefix: string) => {
|
|
// A fetch can outlive the dismissal it was meant to serve (Tab-commit
|
|
// re-arms a refresh, then blur dismisses 120ms later). Showing it now
|
|
// would anchor a dropdown under an unfocused input nothing closes.
|
|
if (dismissedRef.current || document.activeElement !== localRef.current) {
|
|
close();
|
|
return;
|
|
}
|
|
const lower = prefix.toLowerCase();
|
|
const next = entries.filter((e) => e.toLowerCase().startsWith(lower));
|
|
setFiltered(next);
|
|
setHighlight(next.length > 0 ? 0 : -1);
|
|
if (next.length === 0) {
|
|
close();
|
|
return;
|
|
}
|
|
setOpen(true);
|
|
reposition();
|
|
},
|
|
[close, reposition],
|
|
);
|
|
|
|
const refresh = useCallback(
|
|
(raw: string) => {
|
|
const { dir, prefix } = splitPath(raw);
|
|
const cached = cache.current.get(dir);
|
|
if (cached) {
|
|
applyFilter(cached, prefix);
|
|
return;
|
|
}
|
|
const seq = ++fetchSeq.current;
|
|
listDir(dir)
|
|
.then((listing) => {
|
|
cache.current.set(dir, listing.entries);
|
|
if (seq !== fetchSeq.current) return; // a newer fetch superseded this one
|
|
const cur = splitPath(localRef.current?.value ?? raw);
|
|
if (cur.dir === dir) applyFilter(listing.entries, cur.prefix);
|
|
})
|
|
.catch(() => {
|
|
if (seq === fetchSeq.current) close();
|
|
});
|
|
},
|
|
[applyFilter, close],
|
|
);
|
|
|
|
const scheduleRefresh = useCallback(
|
|
(raw: string) => {
|
|
window.clearTimeout(debounceTimer.current);
|
|
debounceTimer.current = window.setTimeout(() => refresh(raw), FETCH_DEBOUNCE_MS);
|
|
},
|
|
[refresh],
|
|
);
|
|
|
|
/** Complete `name` as the next path segment 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. */
|
|
const commit = useCallback(
|
|
(name: string) => {
|
|
const { dir } = splitPath(value);
|
|
onChange(`${dir}${name}/`);
|
|
},
|
|
[value, onChange],
|
|
);
|
|
|
|
const accept = useCallback(
|
|
(name: string) => {
|
|
commit(name);
|
|
localRef.current?.focus();
|
|
},
|
|
[commit],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
window.addEventListener("scroll", reposition, true);
|
|
window.addEventListener("resize", reposition);
|
|
return () => {
|
|
window.removeEventListener("scroll", reposition, true);
|
|
window.removeEventListener("resize", reposition);
|
|
};
|
|
}, [open, reposition]);
|
|
|
|
useEffect(() => () => window.clearTimeout(debounceTimer.current), []);
|
|
|
|
return (
|
|
<>
|
|
<input
|
|
{...inputProps}
|
|
ref={setInputRef}
|
|
value={value}
|
|
autoComplete="off"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
aria-autocomplete="list"
|
|
onChange={(e) => {
|
|
dismissedRef.current = false;
|
|
const next = e.currentTarget.value;
|
|
onChange(next);
|
|
scheduleRefresh(next);
|
|
}}
|
|
onFocus={(e) => {
|
|
inputProps.onFocus?.(e);
|
|
if (dismissedRef.current) return;
|
|
refresh(value);
|
|
}}
|
|
onBlur={(e) => {
|
|
inputProps.onBlur?.(e);
|
|
// Delay so a dropdown mousedown can complete first.
|
|
window.setTimeout(dismiss, 120);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
inputProps.onKeyDown?.(e);
|
|
if (e.defaultPrevented) return;
|
|
if (e.key === "Escape") {
|
|
if (open) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dismiss();
|
|
localRef.current?.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();
|
|
setHighlight((h) => Math.min(h + 1, filtered.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setHighlight((h) => Math.max(h - 1, 0));
|
|
} else if (e.key === "Enter" && highlight >= 0 && highlight < filtered.length) {
|
|
e.preventDefault(); // accept the entry instead of submitting the form
|
|
accept(filtered[highlight]);
|
|
}
|
|
}}
|
|
/>
|
|
{open &&
|
|
rect &&
|
|
createPortal(
|
|
<ul
|
|
role="listbox"
|
|
className="dir-ac fixed z-modal max-h-[240px] overflow-y-auto rounded-r-2 border border-border-strong bg-surface py-sp-1 font-mono shadow-2"
|
|
style={{ left: rect.left, top: rect.top, width: rect.width }}
|
|
>
|
|
{filtered.slice(0, MAX_VISIBLE).map((name, i) => (
|
|
<li
|
|
key={name}
|
|
role="option"
|
|
aria-selected={i === highlight}
|
|
className={`cursor-pointer truncate px-sp-3 py-1 text-fs-12 text-ink ${
|
|
i === highlight ? "bg-accent-tint" : ""
|
|
}`}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault(); // accept before the input's blur fires
|
|
accept(name);
|
|
}}
|
|
>
|
|
{name}/
|
|
</li>
|
|
))}
|
|
{filtered.length > MAX_VISIBLE && (
|
|
<li className="px-sp-3 py-1 text-fs-11 text-faint">
|
|
+{filtered.length - MAX_VISIBLE} more — keep typing to narrow
|
|
</li>
|
|
)}
|
|
</ul>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
}
|