/* Overlays: command palette (fuzzy file finder), content search, context menu, toast */ const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue; // --------------------------------------------------------------------------- // fuzzy(q, str) — verbatim port // --------------------------------------------------------------------------- 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; } // --------------------------------------------------------------------------- // Highlight — props: text, idx // --------------------------------------------------------------------------- const Highlight = { props: ['text', 'idx'], setup(props) { // Build an array of { ch, bold } for rendering const parts = computed(() => { const set = props.idx && props.idx.length ? new Set(props.idx) : null; return props.text.split('').map((ch, i) => ({ ch, bold: set ? set.has(i) : false })); }); return { parts }; }, template: ` {{ p.ch }} {{ p.ch }} ` }; // --------------------------------------------------------------------------- // SearchModal — props: onOpen, onOpenAt, onClose, changeSet // --------------------------------------------------------------------------- const SearchModal = { props: ['onOpen', 'onOpenAt', 'onClose', 'changeSet'], setup(props) { const q = ref(''); const sel = ref(0); const inputEl = ref(null); const leftEl = ref(null); // All paths — stable, PROJECT.files doesn't change at runtime const allPaths = computed(() => Object.keys(PROJECT.files)); // Content hits (left panel) — substring search, min 2 chars const content = computed(() => { const term = q.value.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; }); // File-name fuzzy matches (right panel) const files = computed(() => { const term = q.value.trim(); if (!term) return []; const out = []; for (const p of allPaths.value) { 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; }); // Flat list of content hits for keyboard nav + flat-index tracking // Each entry gets its flatIndex here so the template can use it directly const flat = computed(() => { const arr = []; content.value.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no }))); return arr; }); // Precomputed flat-index map: "path:lineNo" -> flatIndex // Used in template to assign .sel class without mutating during render const flatIndexMap = computed(() => { const map = {}; let ix = 0; content.value.forEach((g) => { g.hits.slice(0, 12).forEach((h) => { map[`${g.path}:${h.no}`] = ix++; }); }); return map; }); const totalHits = computed(() => flat.value.length); // Reset selection when query changes watch(q, () => { sel.value = 0; }); // Scroll selected line into view when sel changes watch(sel, () => { nextTick(() => { const el = leftEl.value && leftEl.value.querySelector('.sr-line.sel'); if (el) el.scrollIntoView({ block: 'nearest' }); }); }); onMounted(() => { inputEl.value && inputEl.value.focus(); }); function onKey(e) { if (e.key === 'ArrowDown') { e.preventDefault(); sel.value = Math.min(sel.value + 1, flat.value.length - 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); sel.value = Math.max(sel.value - 1, 0); } else if (e.key === 'Enter') { e.preventDefault(); if (flat.value[sel.value]) { props.onOpenAt(flat.value[sel.value].path, flat.value[sel.value].no); props.onClose(); } else if (files.value[0]) { props.onOpen(files.value[0].path); props.onClose(); } } else if (e.key === 'Escape') { e.preventDefault(); props.onClose(); } } // renderLine: returns { pre, mid, post } for a content line hit function renderLine(ln, ix, len) { return { pre: ln.slice(0, ix), mid: ln.slice(ix, ix + len), post: ln.slice(ix + len) }; } return { q, sel, inputEl, leftEl, content, files, flat, flatIndexMap, totalHits, onKey, renderLine, }; }, template: `
{{ preview }}