Skip to content

Commit dabeff5

Browse files
authored
feat(desktop): telescope prefixes and fuzzy palette (#526)
## Why Cmd+K was a command list. Jump notebook / tag / heading were separate modes. Inkdrop Telescope is one bar: type a prefix, fuzzy-rank the source. ## What - Prefixes in the same palette: `>` commands, `b ` notebooks, `t ` tags, `#` headings. Existing mode commands (Quick Open, Jump to Notebook, …) stay as aliases. - `b work` scopes to notebooks; typing `blog` does not. - Rank by subsequence (prefix hits first), not `includes`. - Esc clears a prefix before closing. Independent of #522#525. ## Test plan - [ ] Cmd+K, type `b ` — notebooks. Type `t ` — tags. Type `#` — headings. Type `>` — commands. - [ ] `inb` ranks Inbox. `wo` still does not match Weekly. - [ ] Esc with `b foo` in the box clears the query; Esc again closes. - [ ] Jump to Notebook (the old command) still opens scoped to notebooks. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added scoped command palette searches for commands, notebooks, tags, and headings. * Added fuzzy matching that ranks relevant results, including non-contiguous matches. * Updated the search hint to show supported prefixes. * **Bug Fixes** * Improved Escape-key behavior to clear active searches before closing the palette. * Improved filtering, accessibility text, rendering, and keyboard behavior for scoped searches. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 6c642ca commit dabeff5

3 files changed

Lines changed: 156 additions & 41 deletions

File tree

apps/desktop/src/renderer/components/CommandPalette.tsx

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
notebookPath,
5353
paletteAriaLabel,
5454
palettePlaceholder,
55+
parsePaletteQuery,
5556
type PaletteMode,
5657
} from '../utils/paletteQuery';
5758
import { cssm } from '../lib/cssm';
@@ -142,15 +143,15 @@ export function CommandPalette({
142143
const listRef = useRef<HTMLDivElement>(null);
143144
const previousFocusRef = useRef<HTMLElement | null>(null);
144145

146+
const parsed = useMemo(() => parsePaletteQuery(query, mode), [query, mode]);
147+
const source = parsed.source;
148+
const needle = parsed.needle;
149+
145150
const filteredCommands = useMemo(() => {
146-
if (mode !== 'commands') return [];
147-
return commands.filter(cmd => {
148-
if (cmd.showInPalette === false) return false;
149-
if (cmd.enabled === false) return false;
150-
if (query) return cmd.name.toLowerCase().includes(query.toLowerCase());
151-
return true;
152-
});
153-
}, [commands, query, mode]);
151+
if (source !== 'commands') return [];
152+
const visible = commands.filter(cmd => cmd.showInPalette !== false && cmd.enabled !== false);
153+
return filterByQuery(visible, needle, cmd => `${cmd.name} ${cmd.id}`);
154+
}, [commands, needle, source]);
154155

155156
const groups = useMemo(() => {
156157
const result: {
@@ -166,19 +167,19 @@ export function CommandPalette({
166167
}, [filteredCommands]);
167168

168169
const notebookHits = useMemo(() => {
169-
if (mode !== 'notebooks') return [];
170-
return filterByQuery(notebooks, query, nb => notebookPath(notebooks, nb.id));
171-
}, [mode, notebooks, query]);
170+
if (source !== 'notebooks') return [];
171+
return filterByQuery(notebooks, needle, nb => notebookPath(notebooks, nb.id));
172+
}, [source, notebooks, needle]);
172173

173174
const tagHits = useMemo(() => {
174-
if (mode !== 'tags') return [];
175-
return filterByQuery(tags, query, tag => String(tag));
176-
}, [mode, tags, query]);
175+
if (source !== 'tags') return [];
176+
return filterByQuery(tags, needle, tag => String(tag));
177+
}, [source, tags, needle]);
177178

178179
const headingHits = useMemo(() => {
179-
if (mode !== 'headings') return [];
180-
return filterByQuery(headings, query, heading => heading.text);
181-
}, [mode, headings, query]);
180+
if (source !== 'headings') return [];
181+
return filterByQuery(headings, needle, heading => heading.text);
182+
}, [source, headings, needle]);
182183

183184
type FlatItem =
184185
| { type: 'note'; id: string; title: string }
@@ -188,28 +189,28 @@ export function CommandPalette({
188189
| { type: 'command'; id: string };
189190

190191
const flatItems = useMemo((): FlatItem[] => {
191-
if (mode === 'notes') {
192+
if (source === 'notes') {
192193
return noteHits.map(note => ({ type: 'note', id: note.id, title: note.title }));
193194
}
194-
if (mode === 'notebooks') {
195+
if (source === 'notebooks') {
195196
return notebookHits.map(nb => ({
196197
type: 'notebook',
197198
id: nb.id,
198199
title: notebookPath(notebooks, nb.id),
199200
}));
200201
}
201-
if (mode === 'tags') {
202+
if (source === 'tags') {
202203
return tagHits.map(tag => ({ type: 'tag', id: String(tag), title: String(tag) }));
203204
}
204-
if (mode === 'headings') {
205+
if (source === 'headings') {
205206
return headingHits.map((heading, index) => ({
206207
type: 'heading' as const,
207208
id: `${index}:${heading.text}`,
208209
title: heading.text,
209210
}));
210211
}
211212
return groups.flatMap(g => g.commands.map(cmd => ({ type: 'command' as const, id: cmd.id })));
212-
}, [mode, noteHits, notebookHits, tagHits, headingHits, notebooks, groups]);
213+
}, [source, noteHits, notebookHits, tagHits, headingHits, notebooks, groups]);
213214

214215
useEffect(() => {
215216
if (isOpen) {
@@ -228,13 +229,13 @@ export function CommandPalette({
228229
}, [query]);
229230

230231
useEffect(() => {
231-
if (!isOpen || mode !== 'notes') return;
232-
const needle = query.trim();
232+
if (!isOpen || source !== 'notes') return;
233+
const searchNeedle = needle.trim();
233234
let cancelled = false;
234235
const timer = window.setTimeout(
235236
() => {
236-
const req = needle
237-
? window.dripnex.notes.search(needle, {
237+
const req = searchNeedle
238+
? window.dripnex.notes.search(searchNeedle, {
238239
limit: 12,
239240
isDeleted: false,
240241
excludeNotebookIds: ['templates'],
@@ -252,13 +253,13 @@ export function CommandPalette({
252253
}
253254
});
254255
},
255-
needle ? 160 : 0
256+
searchNeedle ? 160 : 0
256257
);
257258
return () => {
258259
cancelled = true;
259260
window.clearTimeout(timer);
260261
};
261-
}, [isOpen, mode, query]);
262+
}, [isOpen, source, needle]);
262263

263264
useEffect(() => {
264265
if (!listRef.current) return;
@@ -332,26 +333,30 @@ export function CommandPalette({
332333
}
333334
case 'Escape': {
334335
e.preventDefault();
336+
if (parsed.scoped || needle) {
337+
setQuery('');
338+
break;
339+
}
335340
finish();
336341
break;
337342
}
338343
}
339344
},
340-
[flatItems, selectedIndex, executeItem, finish]
345+
[flatItems, selectedIndex, executeItem, finish, parsed.scoped, needle]
341346
);
342347

343348
if (!isOpen) return null;
344349

345350
const groupLabel =
346-
mode === 'notes'
347-
? query.trim()
351+
source === 'notes'
352+
? needle.trim()
348353
? 'Notes'
349354
: 'Recent'
350-
: mode === 'notebooks'
355+
: source === 'notebooks'
351356
? 'Notebooks'
352-
: mode === 'tags'
357+
: source === 'tags'
353358
? 'Tags'
354-
: mode === 'headings'
359+
: source === 'headings'
355360
? 'Headings'
356361
: null;
357362

@@ -361,7 +366,7 @@ export function CommandPalette({
361366
onClick={onClose}
362367
onKeyDown={handleKeyDown}
363368
role="dialog"
364-
aria-label={paletteAriaLabel(mode)}
369+
aria-label={paletteAriaLabel(source)}
365370
aria-modal="true"
366371
>
367372
<div
@@ -380,7 +385,7 @@ export function CommandPalette({
380385
ref={inputRef}
381386
className={sc('command-palette-input')}
382387
type="text"
383-
placeholder={palettePlaceholder(mode)}
388+
placeholder={palettePlaceholder(source)}
384389
value={query}
385390
onChange={e => setQuery(e.target.value)}
386391
role="combobox"
@@ -402,7 +407,7 @@ export function CommandPalette({
402407
>
403408
{flatItems.length === 0 ? (
404409
<div className={sc('command-palette-empty')}>No matches</div>
405-
) : mode === 'commands' ? (
410+
) : source === 'commands' ? (
406411
groups.map(group => (
407412
<div key={group.category} className={sc('command-palette-group')}>
408413
<div className={sc('command-palette-group-label')}>{group.label}</div>

apps/desktop/src/renderer/utils/__tests__/paletteQuery.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, expect, it } from 'vitest';
2-
import { filterByQuery, notebookPath, palettePlaceholder } from '../paletteQuery';
2+
import {
3+
filterByQuery,
4+
fuzzyScore,
5+
notebookPath,
6+
palettePlaceholder,
7+
parsePaletteQuery,
8+
} from '../paletteQuery';
39

410
describe('filterByQuery', () => {
511
it('returns all items when the query is empty', () => {
@@ -24,6 +30,49 @@ describe('notebookPath', () => {
2430
});
2531
});
2632

33+
describe('parsePaletteQuery', () => {
34+
it('scopes on prefix plus space', () => {
35+
expect(parsePaletteQuery('b Work', 'commands')).toEqual({
36+
source: 'notebooks',
37+
needle: 'Work',
38+
scoped: true,
39+
});
40+
expect(parsePaletteQuery('t tips', 'commands').source).toBe('tags');
41+
expect(parsePaletteQuery('> sort', 'notes').source).toBe('commands');
42+
expect(parsePaletteQuery('# intro', 'commands').source).toBe('headings');
43+
});
44+
45+
it('does not treat "blog" as the notebooks prefix', () => {
46+
expect(parsePaletteQuery('blog', 'commands')).toEqual({
47+
source: 'commands',
48+
needle: 'blog',
49+
scoped: false,
50+
});
51+
});
52+
});
53+
54+
describe('fuzzyScore', () => {
55+
it('ranks a prefix above a subsequence', () => {
56+
const prefix = fuzzyScore('Inbox', 'in');
57+
const sub = fuzzyScore('Pinned', 'in');
58+
expect(prefix).not.toBeNull();
59+
expect(sub).not.toBeNull();
60+
expect(prefix!).toBeGreaterThan(sub!);
61+
});
62+
63+
it('ranks a long prefix above a short substring', () => {
64+
const prefix = fuzzyScore(`Intro ${'x'.repeat(900)}`, 'intro');
65+
const sub = fuzzyScore('xintro', 'intro');
66+
expect(prefix).not.toBeNull();
67+
expect(sub).not.toBeNull();
68+
expect(prefix!).toBeGreaterThan(sub!);
69+
});
70+
71+
it('rejects a query that is not a subsequence', () => {
72+
expect(fuzzyScore('Weekly', 'wo')).toBeNull();
73+
});
74+
});
75+
2776
describe('palettePlaceholder', () => {
2877
it('names each mode', () => {
2978
expect(palettePlaceholder('notes')).toContain('note');

apps/desktop/src/renderer/utils/paletteQuery.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,70 @@ export function filterByQuery<T>(
1313
query: string,
1414
getText: (item: T) => string
1515
): T[] {
16-
const needle = query.trim().toLowerCase();
16+
return fuzzyFilter(items, query, getText);
17+
}
18+
19+
/**
20+
* Telescope prefixes. `>` and `#` can stand alone; `b` / `t` need a space
21+
* so typing "blog" in the command list does not jump to notebooks.
22+
*/
23+
export function parsePaletteQuery(
24+
raw: string,
25+
fallback: PaletteMode
26+
): { source: PaletteMode; needle: string; scoped: boolean } {
27+
const rules: { re: RegExp; source: PaletteMode }[] = [
28+
{ re: /^>\s?/, source: 'commands' },
29+
{ re: /^b\s/i, source: 'notebooks' },
30+
{ re: /^t\s/i, source: 'tags' },
31+
{ re: /^#\s?/, source: 'headings' },
32+
];
33+
for (const rule of rules) {
34+
const match = raw.match(rule.re);
35+
if (match) {
36+
return { source: rule.source, needle: raw.slice(match[0].length), scoped: true };
37+
}
38+
}
39+
return { source: fallback, needle: raw, scoped: false };
40+
}
41+
42+
/** Higher is better. `null` means the query chars are not a subsequence. */
43+
export function fuzzyScore(text: string, query: string): number | null {
44+
const t = text.toLowerCase();
45+
const q = query.trim().toLowerCase();
46+
if (!q) return 0;
47+
const substring = t.indexOf(q);
48+
// Tiers so a long prefix still beats any substring / subsequence.
49+
if (substring === 0) return 3_000_000 - t.length;
50+
if (substring > 0) return 2_000_000 - substring - t.length * 0.05;
51+
52+
let ti = 0;
53+
let score = 0;
54+
let consecutive = 0;
55+
let prev = -2;
56+
for (const ch of q) {
57+
const found = t.indexOf(ch, ti);
58+
if (found === -1) return null;
59+
consecutive = found === prev + 1 ? consecutive + 1 : 0;
60+
score += 8 + consecutive * 18;
61+
if (found === 0 || /[\s/_-]/.test(t[found - 1] ?? '')) score += 28;
62+
prev = found;
63+
ti = found + 1;
64+
}
65+
return score - t.length * 0.08;
66+
}
67+
68+
export function fuzzyFilter<T>(
69+
items: readonly T[],
70+
query: string,
71+
getText: (item: T) => string
72+
): T[] {
73+
const needle = query.trim();
1774
if (!needle) return [...items];
18-
return items.filter(item => getText(item).toLowerCase().includes(needle));
75+
return items
76+
.map(item => ({ item, score: fuzzyScore(getText(item), needle) }))
77+
.filter((row): row is { item: T; score: number } => row.score !== null)
78+
.sort((a, b) => b.score - a.score)
79+
.map(row => row.item);
1980
}
2081

2182
export function notebookPath(
@@ -37,7 +98,7 @@ export function notebookPath(
3798
export function palettePlaceholder(mode: PaletteMode): string {
3899
switch (mode) {
39100
case 'commands':
40-
return 'Run a command…';
101+
return 'Run a command… > b t #';
41102
case 'notes':
42103
return 'Quick Open a note…';
43104
case 'notebooks':

0 commit comments

Comments
 (0)