mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(quick-switcher): pure fuzzy matcher + ranker
Tiered scoring (exact 1.0 / stem 1.0 / prefix 0.8 / subseq 0.5+bonuses
capped 0.79) with leftmost+rightmost greedy alignment — rightmost wins
for extension/suffix queries ('md' against 'readme.md'), leftmost wins
for prefix queries ('re' against 'readme.md'). Boost-aware rankResults
applies recency x2 and open-tabs x1.5 multiplicatively; empty query
falls back to boosts alone.
Pure module, no DOM/IPC/Node — runs in renderer or main. 22 new tests.
Amit Haridas
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# SP-1: Quick-switcher fuzzy overlay + Inline AI assist
|
||||||
|
|
||||||
|
**Branch:** master
|
||||||
|
**Started:** 2026-09-30 IST
|
||||||
|
**Status:** in progress
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Replace the existing Cmd+P "Recent Files" menu with a fuzzy quick-switcher
|
||||||
|
overlay, and add Cmd+K inline AI assist (Rewrite / Shorten / Expand) on
|
||||||
|
selected text using the existing `AiProviders` infrastructure.
|
||||||
|
|
||||||
|
## Design decisions (user-approved)
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|---|---|
|
||||||
|
| Cmd+P behavior | Replace Recent Files menu with overlay |
|
||||||
|
| AI response mode | Typewriter stream into selection |
|
||||||
|
| AI action set | Rewrite + Shorten + Expand only (no custom prompts) |
|
||||||
|
|
||||||
|
## Commit plan
|
||||||
|
|
||||||
|
### SP-1A — Quick-switcher fuzzy overlay
|
||||||
|
- [ ] **C1**: `feat(quick-switcher): pure fuzzy matcher + ranker` — `src/quick-switcher/fuzzy-matcher.js` + tests
|
||||||
|
- [ ] **C2**: `feat(quick-switcher): IPC for listing workspace files` — main.js + preload.js wiring
|
||||||
|
- [ ] **C3**: `feat(quick-switcher): overlay UI + keyboard nav` — `src/quick-switcher/quick-switcher-overlay.js` + tests
|
||||||
|
- [ ] **C4**: `feat(quick-switcher): wire Cmd+P into renderer, drop old Recent Files menu`
|
||||||
|
|
||||||
|
### SP-1B — Inline AI assist
|
||||||
|
- [ ] **C5**: `feat(ai-assist): pure prompt builder + result applier` — `src/ai-assist/inline-assist.js` + tests
|
||||||
|
- [ ] **C6**: `feat(ai-assist): IPC streaming wrapper around AiProviders` — main.js handler
|
||||||
|
- [ ] **C7**: `feat(ai-assist): floating popover + cancel/stop controls` — `src/renderer/inline-ai-popover.js` + tests
|
||||||
|
- [ ] **C8**: `feat(ai-assist): Cmd+K keymap + popover mount in renderer`
|
||||||
|
|
||||||
|
## TDD discipline
|
||||||
|
|
||||||
|
- All pure modules test: integration tests first, then implementation
|
||||||
|
- All renderer UI test: DOM contract tests using @testing-library/dom
|
||||||
|
- All IPC handlers: tests using injected fetch stub
|
||||||
|
- Lint + format green at every commit
|
||||||
|
- Grep forbidden markers before each commit claim
|
||||||
|
|
||||||
|
## Out of scope (deferred)
|
||||||
|
|
||||||
|
- Per-action configurable prompts
|
||||||
|
- Multi-selection batch assist
|
||||||
|
- AI inline assist without specific settings (built on AI Assistant plugin config)
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Quick-switcher fuzzy matcher.
|
||||||
|
*
|
||||||
|
* Pure module — no DOM, no IPC, no Node globals — so it can run in the
|
||||||
|
* renderer (for instant client-side filtering) or the main process (for
|
||||||
|
* precomputed indices). Tested under @jest-environment node.
|
||||||
|
*
|
||||||
|
* Scoring tiers:
|
||||||
|
* exact match → 1.00 (query === target, case-insensitive)
|
||||||
|
* stem match → 1.00 (query === target without first extension)
|
||||||
|
* prefix match → 0.80
|
||||||
|
* subsequence match → 0.50 + bonuses, capped at 0.79
|
||||||
|
* no match → 0.00
|
||||||
|
*
|
||||||
|
* The stem tier exists because a user typing `idea` against a file named
|
||||||
|
* `idea.md` is signalling the same intent as typing the full name — they
|
||||||
|
* have the unique file in mind and aren't fuzzy-matching. Treating it as
|
||||||
|
* 1.0 (rather than 0.8 prefix) means recent/open-tabs boosts work as
|
||||||
|
* expected: 1.0 × 2.0 × 1.5 = 3.0 for an exact, open, recent file.
|
||||||
|
*
|
||||||
|
* Subsequence matching tries BOTH leftmost-greedy and rightmost-greedy
|
||||||
|
* alignments and keeps the higher-scoring one. Rightmost wins for queries
|
||||||
|
* that target a file's extension or suffix ("md" → "readme.md"); leftmost
|
||||||
|
* wins for queries that target a leading prefix ("re" → "readme.md").
|
||||||
|
*
|
||||||
|
* Bonuses (each +0.05, capped):
|
||||||
|
* - word-boundary hit: previous char is one of / - _ space .
|
||||||
|
* OR position is at start-of-target (i=0)
|
||||||
|
* OR position is at end-of-target (i=length-1)
|
||||||
|
* - consecutive run: each pair of adjacent match positions in target
|
||||||
|
*
|
||||||
|
* Subsequence score is capped below the prefix tier (0.79) so the ranking
|
||||||
|
* is stable: prefix > subsequence even when the subsequence is a perfect
|
||||||
|
* substring match.
|
||||||
|
*
|
||||||
|
* @module fuzzy-matcher
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SEPARATORS = /[\/\-_. ]/;
|
||||||
|
const SUBSEQ_CAP = 0.79;
|
||||||
|
const BOUNDARY_BONUS = 0.05;
|
||||||
|
const CONSECUTIVE_BONUS = 0.1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score `query` against `target` (typically a filename basename).
|
||||||
|
*
|
||||||
|
* @param {string} query
|
||||||
|
* @param {string} target
|
||||||
|
* @returns {number} score in [0, 1]
|
||||||
|
*/
|
||||||
|
function fuzzyScore(query, target) {
|
||||||
|
if (typeof query !== 'string' || typeof target !== 'string') return 0;
|
||||||
|
if (query.length === 0 || target.length === 0) return 0;
|
||||||
|
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const t = target.toLowerCase();
|
||||||
|
|
||||||
|
if (q === t) return 1.0;
|
||||||
|
|
||||||
|
// Stem match: query equals target with the first extension stripped.
|
||||||
|
// 'idea' against 'idea.md' → stem 'idea' → 1.0
|
||||||
|
// 'archive' against 'archive.tar.gz' → stem 'archive' → 1.0
|
||||||
|
// '.gitignore' (no real extension) → stem '.gitignore', query must match whole name
|
||||||
|
const firstDot = t.indexOf('.');
|
||||||
|
const stem = firstDot >= 1 ? t.slice(0, firstDot) : t;
|
||||||
|
if (q === stem) return 1.0;
|
||||||
|
|
||||||
|
if (t.startsWith(q)) return 0.8;
|
||||||
|
|
||||||
|
const leftMatch = greedyMatch(q, t, 'left');
|
||||||
|
const rightMatch = greedyMatch(q, t, 'right');
|
||||||
|
|
||||||
|
const leftScore = leftMatch ? scoreMatch(leftMatch, t) : 0;
|
||||||
|
const rightScore = rightMatch ? scoreMatch(rightMatch, t) : 0;
|
||||||
|
|
||||||
|
const best = Math.max(leftScore, rightScore);
|
||||||
|
return best > 0 ? Math.min(SUBSEQ_CAP, best) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Greedy subsequence match. Returns the array of target indices where
|
||||||
|
* each query character was matched, or null if the query isn't a
|
||||||
|
* subsequence.
|
||||||
|
*
|
||||||
|
* 'left' → for each query char, take the first match at or after the
|
||||||
|
* previous match position.
|
||||||
|
* 'right' → for each query char (processed right-to-left), take the
|
||||||
|
* last match at or before the next-needed position.
|
||||||
|
*/
|
||||||
|
function greedyMatch(query, target, direction) {
|
||||||
|
const positions = [];
|
||||||
|
if (direction === 'left') {
|
||||||
|
let ti = 0;
|
||||||
|
for (let qi = 0; qi < query.length; qi++) {
|
||||||
|
let found = -1;
|
||||||
|
for (let j = ti; j < target.length; j++) {
|
||||||
|
if (target[j] === query[qi]) {
|
||||||
|
found = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found === -1) return null;
|
||||||
|
positions.push(found);
|
||||||
|
ti = found + 1;
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
// right
|
||||||
|
let ti = target.length;
|
||||||
|
for (let qi = query.length - 1; qi >= 0; qi--) {
|
||||||
|
let found = -1;
|
||||||
|
for (let j = ti - 1; j >= 0; j--) {
|
||||||
|
if (target[j] === query[qi]) {
|
||||||
|
found = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found === -1) return null;
|
||||||
|
positions.unshift(found);
|
||||||
|
ti = found;
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score a match by counting boundary hits and consecutive pairs.
|
||||||
|
*/
|
||||||
|
function scoreMatch(positions, target) {
|
||||||
|
let boundaryHits = 0;
|
||||||
|
let consecutivePairs = 0;
|
||||||
|
const n = target.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < positions.length; i++) {
|
||||||
|
const p = positions[i];
|
||||||
|
if (p === 0 || p === n - 1 || SEPARATORS.test(target[p - 1] || '')) {
|
||||||
|
boundaryHits++;
|
||||||
|
}
|
||||||
|
if (i > 0 && positions[i] === positions[i - 1] + 1) {
|
||||||
|
consecutivePairs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.5 + boundaryHits * BOUNDARY_BONUS + consecutivePairs * CONSECUTIVE_BONUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank `items` against `query`, applying per-path boosts. Items with no
|
||||||
|
* match (and not boosted) are excluded.
|
||||||
|
*
|
||||||
|
* Boosts are multiplicative on the base score:
|
||||||
|
* - recent → ×2.0
|
||||||
|
* - openTabs → ×1.5
|
||||||
|
*
|
||||||
|
* An empty query bypasses scoring entirely and returns items purely by
|
||||||
|
* boost: recent first (×2.0), then open tabs (×1.5), then nothing.
|
||||||
|
*
|
||||||
|
* @param {string} query
|
||||||
|
* @param {Array<{path:string, name?:string}>} items
|
||||||
|
* @param {object} [boosts]
|
||||||
|
* @param {Set<string>} [boosts.recent]
|
||||||
|
* @param {Set<string>} [boosts.openTabs]
|
||||||
|
* @returns {Array<{item:object, score:number}>} sorted descending by score
|
||||||
|
*/
|
||||||
|
function rankResults(query, items, boosts = {}) {
|
||||||
|
if (!Array.isArray(items)) return [];
|
||||||
|
const { recent = new Set(), openTabs = new Set() } = boosts;
|
||||||
|
|
||||||
|
const isEmptyQuery = typeof query !== 'string' || query.length === 0;
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || typeof item.path !== 'string') continue;
|
||||||
|
|
||||||
|
let score;
|
||||||
|
if (isEmptyQuery) {
|
||||||
|
if (recent.has(item.path)) score = 2.0;
|
||||||
|
else if (openTabs.has(item.path)) score = 1.5;
|
||||||
|
else continue;
|
||||||
|
} else {
|
||||||
|
const base = fuzzyScore(query, item.name || '');
|
||||||
|
if (base === 0) continue;
|
||||||
|
score = base;
|
||||||
|
if (recent.has(item.path)) score *= 2.0;
|
||||||
|
if (openTabs.has(item.path)) score *= 1.5;
|
||||||
|
}
|
||||||
|
out.push({ item, score });
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => b.score - a.score);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fuzzyScore,
|
||||||
|
rankResults,
|
||||||
|
// exported for tests / advanced callers
|
||||||
|
greedyMatch,
|
||||||
|
scoreMatch,
|
||||||
|
SUBSEQ_CAP,
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*
|
||||||
|
* Quick-switcher fuzzy matcher.
|
||||||
|
*
|
||||||
|
* Tiered scoring:
|
||||||
|
* - exact match → 1.00
|
||||||
|
* - prefix match → 0.80
|
||||||
|
* - subsequence match → 0.50 + bonuses (word-boundary, consecutive)
|
||||||
|
* - no match → 0.00
|
||||||
|
*
|
||||||
|
* Empty query returns 0 for every target — the caller (rankResults) falls
|
||||||
|
* back to recency/open-tabs boosts alone to rank empty-query results.
|
||||||
|
*
|
||||||
|
* All scoring is case-insensitive. The original target casing is preserved
|
||||||
|
* for display; only the matching logic lowercases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { fuzzyScore, rankResults } = require('../src/quick-switcher/fuzzy-matcher');
|
||||||
|
|
||||||
|
describe('fuzzyScore', () => {
|
||||||
|
test('exact match returns 1.0', () => {
|
||||||
|
expect(fuzzyScore('readme.md', 'readme.md')).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact match is case-insensitive', () => {
|
||||||
|
expect(fuzzyScore('README.md', 'readme.md')).toBe(1.0);
|
||||||
|
expect(fuzzyScore('readme.MD', 'README.md')).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefix match returns ~0.8', () => {
|
||||||
|
const score = fuzzyScore('read', 'readme.md');
|
||||||
|
expect(score).toBeGreaterThanOrEqual(0.8);
|
||||||
|
expect(score).toBeLessThan(0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefix match is case-insensitive', () => {
|
||||||
|
expect(fuzzyScore('READ', 'readme.md')).toBeCloseTo(0.8, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subsequence match returns ~0.5 + bonuses', () => {
|
||||||
|
const score = fuzzyScore('rmd', 'readme.md');
|
||||||
|
expect(score).toBeGreaterThanOrEqual(0.5);
|
||||||
|
expect(score).toBeLessThan(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subsequence match on word boundary scores higher than mid-word', () => {
|
||||||
|
const boundary = fuzzyScore('md', 'readme.md'); // 'md' is a suffix → still prefix-like
|
||||||
|
const mid = fuzzyScore('ea', 'readme.md'); // 'ea' is mid-word subsequence
|
||||||
|
expect(boundary).toBeGreaterThan(mid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consecutive matches score higher than scattered matches', () => {
|
||||||
|
const consecutive = fuzzyScore('re', 'readme.md'); // contiguous
|
||||||
|
const scattered = fuzzyScore('rd', 'readme.md'); // not contiguous
|
||||||
|
expect(consecutive).toBeGreaterThan(scattered);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no match returns 0', () => {
|
||||||
|
expect(fuzzyScore('xyz', 'readme.md')).toBe(0);
|
||||||
|
expect(fuzzyScore('', '')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty query returns 0 (caller decides what to do)', () => {
|
||||||
|
expect(fuzzyScore('', 'readme.md')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('query longer than target returns 0', () => {
|
||||||
|
expect(fuzzyScore('readme.md.bak', 'readme.md')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-string inputs return 0', () => {
|
||||||
|
expect(fuzzyScore(null, 'readme.md')).toBe(0);
|
||||||
|
expect(fuzzyScore(undefined, 'readme.md')).toBe(0);
|
||||||
|
expect(fuzzyScore('rm', null)).toBe(0);
|
||||||
|
expect(fuzzyScore('rm', undefined)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves display casing (does not mutate target)', () => {
|
||||||
|
const target = 'README.md';
|
||||||
|
fuzzyScore('readme', target);
|
||||||
|
expect(target).toBe('README.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rankResults', () => {
|
||||||
|
const items = [
|
||||||
|
{ path: '/notes/readme.md', name: 'readme.md' },
|
||||||
|
{ path: '/notes/rust/intro.md', name: 'intro.md' },
|
||||||
|
{ path: '/notes/idea.md', name: 'idea.md' },
|
||||||
|
{ path: '/archive/old-readme.md', name: 'old-readme.md' },
|
||||||
|
{ path: '/totally/unrelated.txt', name: 'unrelated.txt' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('sorts by score descending', () => {
|
||||||
|
const ranked = rankResults('readme', items);
|
||||||
|
expect(ranked.length).toBeGreaterThan(0);
|
||||||
|
for (let i = 1; i < ranked.length; i++) {
|
||||||
|
expect(ranked[i - 1].score).toBeGreaterThanOrEqual(ranked[i].score);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes zero-score items', () => {
|
||||||
|
const ranked = rankResults('xyz', items);
|
||||||
|
expect(ranked).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies recency boost (×2.0) to recent paths', () => {
|
||||||
|
const recent = new Set(['/archive/old-readme.md']);
|
||||||
|
const ranked = rankResults('readme', items, { recent });
|
||||||
|
// old-readme is a prefix match → base 0.8; with ×2.0 boost = 1.6
|
||||||
|
// readme.md is a prefix match → base 0.8; no boost
|
||||||
|
// So old-readme should rank first despite being older
|
||||||
|
expect(ranked[0].item.path).toBe('/archive/old-readme.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies open-tabs boost (×1.5)', () => {
|
||||||
|
const openTabs = new Set(['/notes/idea.md']);
|
||||||
|
const ranked = rankResults('idea', items, { openTabs });
|
||||||
|
// idea.md is exact match → base 1.0; with ×1.5 boost = 1.5
|
||||||
|
expect(ranked[0].item.path).toBe('/notes/idea.md');
|
||||||
|
expect(ranked[0].score).toBeCloseTo(1.5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('combines recency and open-tabs boosts (multiplicative)', () => {
|
||||||
|
const recent = new Set(['/notes/idea.md']);
|
||||||
|
const openTabs = new Set(['/notes/idea.md']);
|
||||||
|
const ranked = rankResults('idea', items, { recent, openTabs });
|
||||||
|
// exact 1.0 × 2.0 × 1.5 = 3.0
|
||||||
|
expect(ranked[0].score).toBeCloseTo(3.0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty query falls back to boosts only — recent first, then open tabs', () => {
|
||||||
|
const recent = new Set(['/archive/old-readme.md']);
|
||||||
|
const openTabs = new Set(['/notes/rust/intro.md']);
|
||||||
|
const ranked = rankResults('', items, { recent, openTabs });
|
||||||
|
// Recent items get base × 2.0; open tabs × 1.5; nothing else
|
||||||
|
expect(ranked[0].item.path).toBe('/archive/old-readme.md');
|
||||||
|
expect(ranked[1].item.path).toBe('/notes/rust/intro.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty query with no boosts returns empty array', () => {
|
||||||
|
const ranked = rankResults('', items, {});
|
||||||
|
expect(ranked).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty array for empty items', () => {
|
||||||
|
expect(rankResults('readme', [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact match beats prefix match beats subsequence match', () => {
|
||||||
|
const ranked = rankResults('idea', items);
|
||||||
|
// idea.md is exact match (1.0)
|
||||||
|
// old-readme.md is subsequence (matches 'i', 'd', 'e', 'a' across path chars)
|
||||||
|
expect(ranked[0].item.path).toBe('/notes/idea.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles identical names in different paths via path-aware boost', () => {
|
||||||
|
const dupItems = [
|
||||||
|
{ path: '/a/readme.md', name: 'readme.md' },
|
||||||
|
{ path: '/b/readme.md', name: 'readme.md' },
|
||||||
|
];
|
||||||
|
const recent = new Set(['/b/readme.md']);
|
||||||
|
const ranked = rankResults('readme', dupItems, { recent });
|
||||||
|
expect(ranked[0].item.path).toBe('/b/readme.md');
|
||||||
|
expect(ranked[1].item.path).toBe('/a/readme.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user