diff --git a/src-tauri/src/ai_chat.rs b/src-tauri/src/ai_chat.rs index 9770e4e3..a3903f61 100644 --- a/src-tauri/src/ai_chat.rs +++ b/src-tauri/src/ai_chat.rs @@ -184,8 +184,12 @@ mod tests { assert_eq!(extract_response_text(&resp), ""); } + // Mutex to serialize env-var tests and avoid race conditions in parallel test runs + static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn test_get_api_key_missing() { + let _guard = ENV_MUTEX.lock().unwrap(); // Temporarily clear the env var let prev = std::env::var("ANTHROPIC_API_KEY").ok(); unsafe { @@ -206,6 +210,7 @@ mod tests { #[test] fn test_get_api_key_present() { + let _guard = ENV_MUTEX.lock().unwrap(); let prev = std::env::var("ANTHROPIC_API_KEY").ok(); unsafe { std::env::set_var("ANTHROPIC_API_KEY", "sk-test-key-123"); diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index c97e6068..a28258ad 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -22,14 +22,19 @@ vi.mock('@blocknote/core', () => ({ filterSuggestionItems: vi.fn(() => []), })) +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock +const mockFilterSuggestionItems = vi.fn((...args: any[]) => args[0] ?? []) vi.mock('@blocknote/core/extensions', () => ({ - filterSuggestionItems: vi.fn(() => []), + filterSuggestionItems: (...args: unknown[]) => mockFilterSuggestionItems(...args), })) +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock +let capturedGetItems: ((query: string) => Promise) | null = null vi.mock('@blocknote/react', () => ({ createReactInlineContentSpec: () => ({ render: () => null }), useCreateBlockNote: () => mockEditor, - SuggestionMenuController: () => null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock + SuggestionMenuController: (props: any) => { capturedGetItems = props.getItems; return null }, })) vi.mock('@blocknote/mantine', () => ({ @@ -268,3 +273,78 @@ describe('Editor', () => { mockEditor.insertBlocks.mockClear() }) }) + +describe('wikilink autocomplete', () => { + const entries: VaultEntry[] = [ + { ...mockEntry, title: 'Alpha Project', filename: 'alpha.md', aliases: ['al'] }, + { ...mockEntry, title: 'Beta Review', filename: 'beta.md', path: '/vault/beta.md', aliases: [] }, + { ...mockEntry, title: 'Gamma Notes', filename: 'gamma.md', path: '/vault/gamma.md', aliases: ['gam'] }, + ] + + function renderWithEntries() { + capturedGetItems = null + mockFilterSuggestionItems.mockClear() + render( + + ) + } + + it('returns empty array for query shorter than 2 characters', async () => { + renderWithEntries() + expect(capturedGetItems).toBeTruthy() + expect(await capturedGetItems!('')).toEqual([]) + expect(await capturedGetItems!('a')).toEqual([]) + // filterSuggestionItems should NOT be called for short queries + expect(mockFilterSuggestionItems).not.toHaveBeenCalled() + }) + + it('returns items for query of 2+ characters', async () => { + renderWithEntries() + const items = await capturedGetItems!('Al') + expect(items.length).toBeGreaterThan(0) + expect(mockFilterSuggestionItems).toHaveBeenCalled() + }) + + it('limits results to MAX_RESULTS (20)', async () => { + // Create many entries that will all match + const manyEntries = Array.from({ length: 50 }, (_, i) => ({ + ...mockEntry, + title: `Match Item ${i}`, + filename: `match-${i}.md`, + path: `/vault/match-${i}.md`, + aliases: [], + })) + + capturedGetItems = null + mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) + render( + + ) + + const items = await capturedGetItems!('Match') + expect(items.length).toBeLessThanOrEqual(20) + mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items) + }) + + it('each item has onItemClick that inserts wikilink', async () => { + renderWithEntries() + mockEditor.insertInlineContent.mockClear() + const items = await capturedGetItems!('Alpha') + expect(items.length).toBeGreaterThan(0) + items[0].onItemClick() + expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([ + { type: 'wikilink', props: { target: 'Alpha Project' } }, + ' ', + ]) + }) +}) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 12c9745c..9d219063 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -14,6 +14,7 @@ import { TabBar } from './TabBar' import { BreadcrumbBar } from './BreadcrumbBar' import { useEditorTheme } from '../hooks/useTheme' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks' +import { preFilterWikilinks, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors' import './Editor.css' import './EditorTheme.css' @@ -141,7 +142,10 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e ) const getWikilinkItems = useCallback(async (query: string) => { - const items = baseItems.map(item => ({ + if (query.length < MIN_QUERY_LENGTH) return [] + + const candidates = preFilterWikilinks(baseItems, query) + const items = candidates.map(item => ({ ...item, onItemClick: () => { editor.insertInlineContent([ @@ -153,7 +157,7 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e ]) }, })) - return filterSuggestionItems(items, query) + return filterSuggestionItems(items, query).slice(0, MAX_RESULTS) }, [baseItems, editor]) return ( diff --git a/src/hooks/useCommitFlow.ts b/src/hooks/useCommitFlow.ts index e4d66ba2..3cb3c1f1 100644 --- a/src/hooks/useCommitFlow.ts +++ b/src/hooks/useCommitFlow.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react' interface CommitFlowConfig { - savePending: () => Promise + savePending: () => Promise loadModifiedFiles: () => Promise commitAndPush: (message: string) => Promise setToastMessage: (msg: string | null) => void diff --git a/src/utils/wikilinkSuggestions.test.ts b/src/utils/wikilinkSuggestions.test.ts new file mode 100644 index 00000000..3b1ead17 --- /dev/null +++ b/src/utils/wikilinkSuggestions.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { preFilterWikilinks, MIN_QUERY_LENGTH, MAX_RESULTS, type WikilinkBaseItem } from './wikilinkSuggestions' + +function makeItem(title: string, aliases: string[] = [], group = 'Note'): WikilinkBaseItem { + return { title, aliases, group, entryTitle: title } +} + +describe('preFilterWikilinks', () => { + const items: WikilinkBaseItem[] = [ + makeItem('Build Laputa App', ['laputa-app'], 'Project'), + makeItem('Quarterly Review', ['q1-review'], 'Responsibility'), + makeItem('TypeScript Tips', ['ts-tips']), + makeItem('Café Notes', ['café']), + makeItem('React Hooks Deep-Dive', ['react-hooks']), + ] + + it('returns empty for query shorter than MIN_QUERY_LENGTH', () => { + expect(preFilterWikilinks(items, '')).toEqual([]) + expect(preFilterWikilinks(items, 'a')).toEqual([]) + }) + + it('returns matches for query of exactly MIN_QUERY_LENGTH', () => { + const result = preFilterWikilinks(items, 'la') + expect(result.length).toBeGreaterThan(0) + expect(result.some(r => r.title === 'Build Laputa App')).toBe(true) + }) + + it('matches on title (case-insensitive)', () => { + const result = preFilterWikilinks(items, 'quarterly') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Quarterly Review') + }) + + it('matches on aliases', () => { + const result = preFilterWikilinks(items, 'ts-tip') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('TypeScript Tips') + }) + + it('matches on group', () => { + const result = preFilterWikilinks(items, 'Project') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Build Laputa App') + }) + + it('handles accented characters', () => { + const result = preFilterWikilinks(items, 'café') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Café Notes') + }) + + it('handles hyphens in query', () => { + const result = preFilterWikilinks(items, 'deep-di') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('React Hooks Deep-Dive') + }) + + it('returns empty when nothing matches', () => { + expect(preFilterWikilinks(items, 'zzzzz')).toEqual([]) + }) + + it('returns all matches when multiple items match', () => { + // Both "Build Laputa App" and "React Hooks Deep-Dive" contain 'e' + // but query must be >= 2 chars, so use a longer shared substring + const result = preFilterWikilinks(items, 'No') // "Note" group + "Café Notes" + expect(result.length).toBeGreaterThan(1) + }) + + it('handles empty items array', () => { + expect(preFilterWikilinks([], 'test')).toEqual([]) + }) +}) + +describe('constants', () => { + it('MIN_QUERY_LENGTH is 2', () => { + expect(MIN_QUERY_LENGTH).toBe(2) + }) + + it('MAX_RESULTS is 20', () => { + expect(MAX_RESULTS).toBe(20) + }) +}) + +describe('preFilterWikilinks with large dataset', () => { + const largeItems: WikilinkBaseItem[] = Array.from({ length: 10000 }, (_, i) => + makeItem(`Note ${i}`, [`alias-${i}`], i % 3 === 0 ? 'Project' : 'Note') + ) + + it('handles 10000+ items without throwing', () => { + const result = preFilterWikilinks(largeItems, 'Note 50') + expect(result.length).toBeGreaterThan(0) + expect(result.length).toBeLessThan(largeItems.length) + }) + + it('short query on large dataset returns empty', () => { + expect(preFilterWikilinks(largeItems, 'N')).toEqual([]) + }) +}) diff --git a/src/utils/wikilinkSuggestions.ts b/src/utils/wikilinkSuggestions.ts new file mode 100644 index 00000000..5acedb85 --- /dev/null +++ b/src/utils/wikilinkSuggestions.ts @@ -0,0 +1,29 @@ +export const MIN_QUERY_LENGTH = 2 +export const MAX_RESULTS = 20 + +export interface WikilinkBaseItem { + title: string + aliases: string[] + group: string + entryTitle: string +} + +/** + * Pre-filter wikilink suggestion candidates using case-insensitive substring + * matching on title, aliases, and group. This avoids creating expensive + * onItemClick closures for thousands of entries that won't match anyway. + * + * Returns [] when query is shorter than MIN_QUERY_LENGTH. + */ +export function preFilterWikilinks( + items: T[], + query: string, +): T[] { + if (query.length < MIN_QUERY_LENGTH) return [] + const lowerQuery = query.toLowerCase() + return items.filter(item => + item.title.toLowerCase().includes(lowerQuery) || + item.aliases.some(a => a.toLowerCase().includes(lowerQuery)) || + item.group.toLowerCase().includes(lowerQuery) + ) +}