Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 42 additions & 37 deletions apps/desktop/src/renderer/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
notebookPath,
paletteAriaLabel,
palettePlaceholder,
parsePaletteQuery,
type PaletteMode,
} from '../utils/paletteQuery';
import { cssm } from '../lib/cssm';
Expand Down Expand Up @@ -142,15 +143,15 @@ export function CommandPalette({
const listRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);

const parsed = useMemo(() => parsePaletteQuery(query, mode), [query, mode]);
const source = parsed.source;
const needle = parsed.needle;

const filteredCommands = useMemo(() => {
if (mode !== 'commands') return [];
return commands.filter(cmd => {
if (cmd.showInPalette === false) return false;
if (cmd.enabled === false) return false;
if (query) return cmd.name.toLowerCase().includes(query.toLowerCase());
return true;
});
}, [commands, query, mode]);
if (source !== 'commands') return [];
const visible = commands.filter(cmd => cmd.showInPalette !== false && cmd.enabled !== false);
return filterByQuery(visible, needle, cmd => `${cmd.name} ${cmd.id}`);
}, [commands, needle, source]);

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

const notebookHits = useMemo(() => {
if (mode !== 'notebooks') return [];
return filterByQuery(notebooks, query, nb => notebookPath(notebooks, nb.id));
}, [mode, notebooks, query]);
if (source !== 'notebooks') return [];
return filterByQuery(notebooks, needle, nb => notebookPath(notebooks, nb.id));
}, [source, notebooks, needle]);

const tagHits = useMemo(() => {
if (mode !== 'tags') return [];
return filterByQuery(tags, query, tag => String(tag));
}, [mode, tags, query]);
if (source !== 'tags') return [];
return filterByQuery(tags, needle, tag => String(tag));
}, [source, tags, needle]);

const headingHits = useMemo(() => {
if (mode !== 'headings') return [];
return filterByQuery(headings, query, heading => heading.text);
}, [mode, headings, query]);
if (source !== 'headings') return [];
return filterByQuery(headings, needle, heading => heading.text);
}, [source, headings, needle]);

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

const flatItems = useMemo((): FlatItem[] => {
if (mode === 'notes') {
if (source === 'notes') {
return noteHits.map(note => ({ type: 'note', id: note.id, title: note.title }));
}
if (mode === 'notebooks') {
if (source === 'notebooks') {
return notebookHits.map(nb => ({
type: 'notebook',
id: nb.id,
title: notebookPath(notebooks, nb.id),
}));
}
if (mode === 'tags') {
if (source === 'tags') {
return tagHits.map(tag => ({ type: 'tag', id: String(tag), title: String(tag) }));
}
if (mode === 'headings') {
if (source === 'headings') {
return headingHits.map((heading, index) => ({
type: 'heading' as const,
id: `${index}:${heading.text}`,
title: heading.text,
}));
}
return groups.flatMap(g => g.commands.map(cmd => ({ type: 'command' as const, id: cmd.id })));
}, [mode, noteHits, notebookHits, tagHits, headingHits, notebooks, groups]);
}, [source, noteHits, notebookHits, tagHits, headingHits, notebooks, groups]);

useEffect(() => {
if (isOpen) {
Expand All @@ -228,13 +229,13 @@ export function CommandPalette({
}, [query]);

useEffect(() => {
if (!isOpen || mode !== 'notes') return;
const needle = query.trim();
if (!isOpen || source !== 'notes') return;
const searchNeedle = needle.trim();
let cancelled = false;
const timer = window.setTimeout(
() => {
const req = needle
? window.dripnex.notes.search(needle, {
const req = searchNeedle
? window.dripnex.notes.search(searchNeedle, {
limit: 12,
isDeleted: false,
excludeNotebookIds: ['templates'],
Expand All @@ -252,13 +253,13 @@ export function CommandPalette({
}
});
},
needle ? 160 : 0
searchNeedle ? 160 : 0
);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [isOpen, mode, query]);
}, [isOpen, source, needle]);

useEffect(() => {
if (!listRef.current) return;
Expand Down Expand Up @@ -332,26 +333,30 @@ export function CommandPalette({
}
case 'Escape': {
e.preventDefault();
if (parsed.scoped || needle) {
setQuery('');
break;
}
finish();
break;
}
}
},
[flatItems, selectedIndex, executeItem, finish]
[flatItems, selectedIndex, executeItem, finish, parsed.scoped, needle]
);

if (!isOpen) return null;

const groupLabel =
mode === 'notes'
? query.trim()
source === 'notes'
? needle.trim()
? 'Notes'
: 'Recent'
: mode === 'notebooks'
: source === 'notebooks'
? 'Notebooks'
: mode === 'tags'
: source === 'tags'
? 'Tags'
: mode === 'headings'
: source === 'headings'
? 'Headings'
: null;

Expand All @@ -361,7 +366,7 @@ export function CommandPalette({
onClick={onClose}
onKeyDown={handleKeyDown}
role="dialog"
aria-label={paletteAriaLabel(mode)}
aria-label={paletteAriaLabel(source)}
aria-modal="true"
>
<div
Expand All @@ -380,7 +385,7 @@ export function CommandPalette({
ref={inputRef}
className={sc('command-palette-input')}
type="text"
placeholder={palettePlaceholder(mode)}
placeholder={palettePlaceholder(source)}
value={query}
onChange={e => setQuery(e.target.value)}
role="combobox"
Expand All @@ -402,7 +407,7 @@ export function CommandPalette({
>
{flatItems.length === 0 ? (
<div className={sc('command-palette-empty')}>No matches</div>
) : mode === 'commands' ? (
) : source === 'commands' ? (
groups.map(group => (
<div key={group.category} className={sc('command-palette-group')}>
<div className={sc('command-palette-group-label')}>{group.label}</div>
Expand Down
51 changes: 50 additions & 1 deletion apps/desktop/src/renderer/utils/__tests__/paletteQuery.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { filterByQuery, notebookPath, palettePlaceholder } from '../paletteQuery';
import {
filterByQuery,
fuzzyScore,
notebookPath,
palettePlaceholder,
parsePaletteQuery,
} from '../paletteQuery';

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

describe('parsePaletteQuery', () => {
it('scopes on prefix plus space', () => {
expect(parsePaletteQuery('b Work', 'commands')).toEqual({
source: 'notebooks',
needle: 'Work',
scoped: true,
});
expect(parsePaletteQuery('t tips', 'commands').source).toBe('tags');
expect(parsePaletteQuery('> sort', 'notes').source).toBe('commands');
expect(parsePaletteQuery('# intro', 'commands').source).toBe('headings');
});

it('does not treat "blog" as the notebooks prefix', () => {
expect(parsePaletteQuery('blog', 'commands')).toEqual({
source: 'commands',
needle: 'blog',
scoped: false,
});
});
});

describe('fuzzyScore', () => {
it('ranks a prefix above a subsequence', () => {
const prefix = fuzzyScore('Inbox', 'in');
const sub = fuzzyScore('Pinned', 'in');
expect(prefix).not.toBeNull();
expect(sub).not.toBeNull();
expect(prefix!).toBeGreaterThan(sub!);
});

it('ranks a long prefix above a short substring', () => {
const prefix = fuzzyScore(`Intro ${'x'.repeat(900)}`, 'intro');
const sub = fuzzyScore('xintro', 'intro');
expect(prefix).not.toBeNull();
expect(sub).not.toBeNull();
expect(prefix!).toBeGreaterThan(sub!);
});

it('rejects a query that is not a subsequence', () => {
expect(fuzzyScore('Weekly', 'wo')).toBeNull();
});
});

describe('palettePlaceholder', () => {
it('names each mode', () => {
expect(palettePlaceholder('notes')).toContain('note');
Expand Down
67 changes: 64 additions & 3 deletions apps/desktop/src/renderer/utils/paletteQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,70 @@ export function filterByQuery<T>(
query: string,
getText: (item: T) => string
): T[] {
const needle = query.trim().toLowerCase();
return fuzzyFilter(items, query, getText);
}

/**
* Telescope prefixes. `>` and `#` can stand alone; `b` / `t` need a space
* so typing "blog" in the command list does not jump to notebooks.
*/
export function parsePaletteQuery(
raw: string,
fallback: PaletteMode
): { source: PaletteMode; needle: string; scoped: boolean } {
const rules: { re: RegExp; source: PaletteMode }[] = [
{ re: /^>\s?/, source: 'commands' },
{ re: /^b\s/i, source: 'notebooks' },
{ re: /^t\s/i, source: 'tags' },
{ re: /^#\s?/, source: 'headings' },
];
for (const rule of rules) {
const match = raw.match(rule.re);
if (match) {
return { source: rule.source, needle: raw.slice(match[0].length), scoped: true };
}
}
return { source: fallback, needle: raw, scoped: false };
}

/** Higher is better. `null` means the query chars are not a subsequence. */
export function fuzzyScore(text: string, query: string): number | null {
const t = text.toLowerCase();
const q = query.trim().toLowerCase();
if (!q) return 0;
const substring = t.indexOf(q);
// Tiers so a long prefix still beats any substring / subsequence.
if (substring === 0) return 3_000_000 - t.length;
if (substring > 0) return 2_000_000 - substring - t.length * 0.05;

let ti = 0;
let score = 0;
let consecutive = 0;
let prev = -2;
for (const ch of q) {
const found = t.indexOf(ch, ti);
if (found === -1) return null;
consecutive = found === prev + 1 ? consecutive + 1 : 0;
score += 8 + consecutive * 18;
if (found === 0 || /[\s/_-]/.test(t[found - 1] ?? '')) score += 28;
prev = found;
ti = found + 1;
}
return score - t.length * 0.08;
}

export function fuzzyFilter<T>(
items: readonly T[],
query: string,
getText: (item: T) => string
): T[] {
const needle = query.trim();
if (!needle) return [...items];
return items.filter(item => getText(item).toLowerCase().includes(needle));
return items
.map(item => ({ item, score: fuzzyScore(getText(item), needle) }))
.filter((row): row is { item: T; score: number } => row.score !== null)
.sort((a, b) => b.score - a.score)
.map(row => row.item);
}

export function notebookPath(
Expand All @@ -37,7 +98,7 @@ export function notebookPath(
export function palettePlaceholder(mode: PaletteMode): string {
switch (mode) {
case 'commands':
return 'Run a command…';
return 'Run a command… > b t #';
case 'notes':
return 'Quick Open a note…';
case 'notebooks':
Expand Down
Loading