Files
helder/design_handoff_helder_workbench/design/src/overlays.jsx
2026-06-15 09:43:01 +02:00

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