Merge pull request #38 from refactoringhq/task/wikilink-autocomplete
fix: require 2+ chars before showing wikilink autocomplete, limit to 20 results
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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<any[]>) | 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(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
entries={entries}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
entries={manyEntries}
|
||||
/>
|
||||
)
|
||||
|
||||
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' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
interface CommitFlowConfig {
|
||||
savePending: () => Promise<void>
|
||||
savePending: () => Promise<void | boolean>
|
||||
loadModifiedFiles: () => Promise<void>
|
||||
commitAndPush: (message: string) => Promise<string>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
|
||||
98
src/utils/wikilinkSuggestions.test.ts
Normal file
98
src/utils/wikilinkSuggestions.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
29
src/utils/wikilinkSuggestions.ts
Normal file
29
src/utils/wikilinkSuggestions.ts
Normal file
@@ -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<T extends WikilinkBaseItem>(
|
||||
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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user