227 lines
9.3 KiB
JavaScript
227 lines
9.3 KiB
JavaScript
/* Overlays: command palette (fuzzy file finder), content search, context menu, toast */
|
|
|
|
function fuzzy(q, str) {
|
|
q = q.toLowerCase(); const s = str.toLowerCase();
|
|
let i = 0; const idx = [];
|
|
for (let j = 0; j < s.length && i < q.length; j++) {
|
|
if (s[j] === q[i]) { idx.push(j); i++; }
|
|
}
|
|
return i === q.length ? idx : null;
|
|
}
|
|
|
|
function Highlight({ text, idx }) {
|
|
if (!idx || !idx.length) return <span>{text}</span>;
|
|
const set = new Set(idx);
|
|
return <span>{text.split("").map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <React.Fragment key={i}>{ch}</React.Fragment>)}</span>;
|
|
}
|
|
|
|
function SearchModal({ onOpen, onOpenAt, onClose, changeSet }) {
|
|
const [q, setQ] = useState("");
|
|
const [sel, setSel] = useState(0);
|
|
const inputRef = useRef(null);
|
|
const leftRef = useRef(null);
|
|
const allPaths = useMemo(() => Object.keys(PROJECT.files), []);
|
|
|
|
useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
|
|
|
|
// content hits (left)
|
|
const content = useMemo(() => {
|
|
const term = q.trim();
|
|
if (term.length < 2) return [];
|
|
const low = term.toLowerCase();
|
|
const groups = [];
|
|
for (const [path, src] of Object.entries(PROJECT.files)) {
|
|
const lines = src.split("\n");
|
|
const hits = [];
|
|
lines.forEach((ln, i) => {
|
|
const ix = ln.toLowerCase().indexOf(low);
|
|
if (ix >= 0) hits.push({ no: i + 1, ln, ix });
|
|
});
|
|
if (hits.length) groups.push({ path, hits });
|
|
}
|
|
return groups;
|
|
}, [q]);
|
|
|
|
// file-name matches (right)
|
|
const files = useMemo(() => {
|
|
const term = q.trim();
|
|
if (!term) return [];
|
|
const out = [];
|
|
for (const p of allPaths) {
|
|
const name = p.split("/").pop();
|
|
const ni = fuzzy(term, name);
|
|
if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue; }
|
|
const pi = fuzzy(term, p);
|
|
if (pi) out.push({ path: p, idx: null, rank: 1, pos: pi[0] });
|
|
}
|
|
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length);
|
|
return out;
|
|
}, [q]);
|
|
|
|
// flat list of content hits for keyboard nav
|
|
const flat = useMemo(() => {
|
|
const arr = [];
|
|
content.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })));
|
|
return arr;
|
|
}, [content]);
|
|
|
|
const totalHits = flat.length;
|
|
useEffect(() => { setSel(0); }, [q]);
|
|
useEffect(() => {
|
|
const el = leftRef.current && leftRef.current.querySelector(".sr-line.sel");
|
|
if (el) el.scrollIntoView({ block: "nearest" });
|
|
}, [sel]);
|
|
|
|
function onKey(e) {
|
|
if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(s + 1, flat.length - 1)); }
|
|
else if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)); }
|
|
else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose(); }
|
|
else if (files[0]) { onOpen(files[0].path); onClose(); }
|
|
} else if (e.key === "Escape") { e.preventDefault(); onClose(); }
|
|
}
|
|
|
|
function renderLine(ln, ix, len) {
|
|
const pre = ln.slice(0, ix), mid = ln.slice(ix, ix + len), post = ln.slice(ix + len);
|
|
return <span className="tx">{pre}<mark>{mid}</mark>{post}</span>;
|
|
}
|
|
|
|
const term = q.trim();
|
|
let flatIx = -1;
|
|
|
|
return (
|
|
<div className="scrim" onMouseDown={onClose}>
|
|
<div className="search-modal" onMouseDown={(e) => e.stopPropagation()}>
|
|
<div className="pi">
|
|
{Icon.search({ style: { color: "var(--fg-3)" } })}
|
|
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
|
|
placeholder="Search content and file names…" spellCheck={false} />
|
|
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? "" : "s"} · {files.length} file{files.length === 1 ? "" : "s"}</span>
|
|
</div>
|
|
<div className="search-cols">
|
|
<div className="sc-left" ref={leftRef}>
|
|
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
|
|
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
|
|
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>}
|
|
{content.map((g) => (
|
|
<React.Fragment key={g.path}>
|
|
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
|
|
<FileIcon path={g.path} />
|
|
<span className="srf-name">{g.path}</span>
|
|
<span className="cnt">{g.hits.length}</span>
|
|
</div>
|
|
{g.hits.slice(0, 12).map((h) => {
|
|
flatIx++;
|
|
const me = flatIx;
|
|
return (
|
|
<div key={h.no} className={"sr-line" + (me === sel ? " sel" : "")}
|
|
onMouseEnter={() => setSel(me)}
|
|
onClick={() => { onOpenAt(g.path, h.no); onClose(); }}>
|
|
<span className="no">{h.no}</span>
|
|
{renderLine(h.ln, h.ix, term.length)}
|
|
</div>
|
|
);
|
|
})}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
<div className="sc-right">
|
|
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>}</div>
|
|
{!term && <div className="pempty sm">Start typing…</div>}
|
|
{term && files.length === 0 && <div className="pempty sm">No file names match</div>}
|
|
{files.slice(0, 40).map((r) => {
|
|
const name = r.path.split("/").pop();
|
|
const dir = r.path.split("/").slice(0, -1).join("/");
|
|
return (
|
|
<div key={r.path} className="fres" onClick={() => { onOpen(r.path); onClose(); }} title={r.path}>
|
|
<FileIcon path={r.path} />
|
|
<div className="fres-txt">
|
|
<span className="fn"><Highlight text={name} idx={r.idx} /></span>
|
|
{dir && <span className="fd">{dir}/</span>}
|
|
</div>
|
|
{changeSet.has(r.path) && <span className="tree-badge M" style={{ fontFamily: "var(--mono)", fontSize: 10 }}>•</span>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ContextMenu({ menu, onClose }) {
|
|
const ref = useRef(null);
|
|
useEffect(() => {
|
|
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
|
|
const k = (e) => { if (e.key === "Escape") onClose(); };
|
|
document.addEventListener("mousedown", h);
|
|
document.addEventListener("keydown", k);
|
|
return () => { document.removeEventListener("mousedown", h); document.removeEventListener("keydown", k); };
|
|
}, []);
|
|
if (!menu) return null;
|
|
const x = Math.min(menu.x, window.innerWidth - 270);
|
|
const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60));
|
|
return (
|
|
<div className="ctx" ref={ref} style={{ left: x, top: y }}>
|
|
{menu.note && <div className="ctx-note">{menu.note}</div>}
|
|
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
|
|
<div key={i} className={"ctx-item" + (it.primary ? " primary" : "")}
|
|
onClick={() => { it.onClick(); onClose(); }}>
|
|
<span className="ic">{it.icon}</span>
|
|
<span>{it.label}</span>
|
|
{it.kbd && <span className="kc">{it.kbd}</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Toasts({ toasts }) {
|
|
return (
|
|
<div className="toast-wrap">
|
|
{toasts.map((t) => (
|
|
<div key={t.id} className="toast">
|
|
{Icon.copy({ style: { color: "var(--accent)" } })}
|
|
<span className="tt">{t.title}</span>
|
|
{t.ref && <span className="tref">{t.ref}</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PassPopup({ x, y, refStr, onConfirm, onCancel }) {
|
|
const [text, setText] = useState("");
|
|
const inputRef = useRef(null);
|
|
const boxRef = useRef(null);
|
|
useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
|
|
useEffect(() => {
|
|
const h = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) onCancel(); };
|
|
const k = (e) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); } };
|
|
document.addEventListener("mousedown", h);
|
|
document.addEventListener("keydown", k, true);
|
|
return () => { document.removeEventListener("mousedown", h); document.removeEventListener("keydown", k, true); };
|
|
}, []);
|
|
const left = Math.min(x, window.innerWidth - 360);
|
|
const top = Math.min(y + 6, window.innerHeight - 150);
|
|
const preview = (text.trim() ? text.trim() + " " : "") + refStr;
|
|
return (
|
|
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
|
<div className="pass-head">{Icon.spark({})}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
|
|
<input ref={inputRef} className="pass-input" value={text} spellCheck={false}
|
|
placeholder="Add a note (optional)…"
|
|
onChange={(e) => setText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") { e.preventDefault(); onConfirm(text); }
|
|
else if (e.key === "Escape") { e.preventDefault(); onCancel(); }
|
|
}} />
|
|
<div className="pass-preview"><span className="pp-lbl">inserts</span><code>{preview}</code></div>
|
|
<div className="pass-foot"><kbd>↵</kbd> insert into agent · <kbd>esc</kbd> cancel</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
Object.assign(window, { SearchModal, ContextMenu, Toasts, PassPopup, fuzzy });
|