diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx
index 73660880..996409fd 100644
--- a/src/components/Editor.test.tsx
+++ b/src/components/Editor.test.tsx
@@ -360,4 +360,50 @@ describe('wikilink autocomplete', () => {
' ',
])
})
+
+ it('deduplicates entries with the same path', async () => {
+ const dupEntries: VaultEntry[] = [
+ { ...mockEntry, title: 'Dup Note', filename: 'dup.md', path: '/vault/dup.md', aliases: [] },
+ { ...mockEntry, title: 'Dup Note Copy', filename: 'dup.md', path: '/vault/dup.md', aliases: [] },
+ { ...mockEntry, title: 'Other Note', filename: 'other.md', path: '/vault/other.md', aliases: [] },
+ ]
+ capturedGetItems = null
+ mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
+ render(
+
+ )
+ const items = await capturedGetItems!('Note')
+ const paths = items.map((i: { path: string }) => i.path)
+ expect(new Set(paths).size).toBe(paths.length)
+ mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
+ })
+
+ it('disambiguates entries with the same title by appending folder name', async () => {
+ const sameTitle: VaultEntry[] = [
+ { ...mockEntry, title: 'Standup', filename: 'standup.md', path: '/vault/work/standup.md', aliases: [] },
+ { ...mockEntry, title: 'Standup', filename: 'standup.md', path: '/vault/personal/standup.md', aliases: [] },
+ ]
+ capturedGetItems = null
+ mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
+ render(
+
+ )
+ const items = await capturedGetItems!('Standup')
+ expect(items).toHaveLength(2)
+ const titles = items.map((i: { title: string }) => i.title)
+ expect(new Set(titles).size).toBe(2)
+ expect(titles).toContain('Standup (work)')
+ expect(titles).toContain('Standup (personal)')
+ mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
+ })
})
diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx
index e01f32a9..ede88396 100644
--- a/src/components/Editor.tsx
+++ b/src/components/Editor.tsx
@@ -14,7 +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 { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import './Editor.css'
import './EditorTheme.css'
@@ -132,12 +132,13 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
}, [editor])
const baseItems = useMemo(
- () => entries.map(entry => ({
+ () => deduplicateByPath(entries.map(entry => ({
title: entry.title,
- aliases: [entry.filename.replace(/\.md$/, ''), ...entry.aliases],
+ aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryTitle: entry.title,
- })),
+ path: entry.path,
+ }))),
[entries]
)
@@ -157,7 +158,8 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
])
},
}))
- return filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
+ const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
+ return disambiguateTitles(deduplicateByPath(filtered))
}, [baseItems, editor])
return (
diff --git a/src/utils/wikilinkSuggestions.test.ts b/src/utils/wikilinkSuggestions.test.ts
index 3b1ead17..f96263e9 100644
--- a/src/utils/wikilinkSuggestions.test.ts
+++ b/src/utils/wikilinkSuggestions.test.ts
@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest'
-import { preFilterWikilinks, MIN_QUERY_LENGTH, MAX_RESULTS, type WikilinkBaseItem } from './wikilinkSuggestions'
+import { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MIN_QUERY_LENGTH, MAX_RESULTS, type WikilinkBaseItem } from './wikilinkSuggestions'
-function makeItem(title: string, aliases: string[] = [], group = 'Note'): WikilinkBaseItem {
- return { title, aliases, group, entryTitle: title }
+let pathCounter = 0
+function makeItem(title: string, aliases: string[] = [], group = 'Note', path?: string): WikilinkBaseItem {
+ return { title, aliases, group, entryTitle: title, path: path ?? `/vault/${title.toLowerCase().replace(/\s/g, '-')}-${pathCounter++}.md` }
}
describe('preFilterWikilinks', () => {
@@ -83,7 +84,7 @@ describe('constants', () => {
describe('preFilterWikilinks with large dataset', () => {
const largeItems: WikilinkBaseItem[] = Array.from({ length: 10000 }, (_, i) =>
- makeItem(`Note ${i}`, [`alias-${i}`], i % 3 === 0 ? 'Project' : 'Note')
+ makeItem(`Note ${i}`, [`alias-${i}`], i % 3 === 0 ? 'Project' : 'Note', `/vault/note-${i}.md`)
)
it('handles 10000+ items without throwing', () => {
@@ -96,3 +97,77 @@ describe('preFilterWikilinks with large dataset', () => {
expect(preFilterWikilinks(largeItems, 'N')).toEqual([])
})
})
+
+describe('deduplicateByPath', () => {
+ it('removes items with duplicate paths, keeping the first occurrence', () => {
+ const items = [
+ makeItem('Alpha', [], 'Note', '/vault/alpha.md'),
+ makeItem('Beta', [], 'Note', '/vault/beta.md'),
+ makeItem('Alpha Dup', [], 'Note', '/vault/alpha.md'),
+ ]
+ const result = deduplicateByPath(items)
+ expect(result).toHaveLength(2)
+ expect(result[0].title).toBe('Alpha')
+ expect(result[1].title).toBe('Beta')
+ })
+
+ it('returns all items when paths are unique', () => {
+ const items = [
+ makeItem('A', [], 'Note', '/vault/a.md'),
+ makeItem('B', [], 'Note', '/vault/b.md'),
+ makeItem('C', [], 'Note', '/vault/c.md'),
+ ]
+ expect(deduplicateByPath(items)).toHaveLength(3)
+ })
+
+ it('returns empty array for empty input', () => {
+ expect(deduplicateByPath([])).toEqual([])
+ })
+})
+
+describe('disambiguateTitles', () => {
+ it('appends parent folder when titles collide', () => {
+ const items = [
+ makeItem('Meeting Notes', [], 'Note', '/vault/project/meeting-notes.md'),
+ makeItem('Meeting Notes', [], 'Note', '/vault/personal/meeting-notes.md'),
+ ]
+ const result = disambiguateTitles(items)
+ expect(result).toHaveLength(2)
+ expect(result[0].title).toBe('Meeting Notes (project)')
+ expect(result[1].title).toBe('Meeting Notes (personal)')
+ })
+
+ it('leaves unique titles unchanged', () => {
+ const items = [
+ makeItem('Alpha', [], 'Note', '/vault/alpha.md'),
+ makeItem('Beta', [], 'Note', '/vault/beta.md'),
+ ]
+ const result = disambiguateTitles(items)
+ expect(result[0].title).toBe('Alpha')
+ expect(result[1].title).toBe('Beta')
+ })
+
+ it('preserves entryTitle even when title is disambiguated', () => {
+ const items = [
+ makeItem('Standup', [], 'Note', '/vault/work/standup.md'),
+ makeItem('Standup', [], 'Note', '/vault/personal/standup.md'),
+ ]
+ const result = disambiguateTitles(items)
+ expect(result[0].entryTitle).toBe('Standup')
+ expect(result[1].entryTitle).toBe('Standup')
+ })
+
+ it('handles three-way title collision', () => {
+ const items = [
+ makeItem('TODO', [], 'Note', '/vault/work/todo.md'),
+ makeItem('TODO', [], 'Note', '/vault/personal/todo.md'),
+ makeItem('TODO', [], 'Note', '/vault/archive/todo.md'),
+ ]
+ const result = disambiguateTitles(items)
+ expect(new Set(result.map(r => r.title)).size).toBe(3)
+ })
+
+ it('returns empty array for empty input', () => {
+ expect(disambiguateTitles([])).toEqual([])
+ })
+})
diff --git a/src/utils/wikilinkSuggestions.ts b/src/utils/wikilinkSuggestions.ts
index 5acedb85..3d8feeaa 100644
--- a/src/utils/wikilinkSuggestions.ts
+++ b/src/utils/wikilinkSuggestions.ts
@@ -6,6 +6,7 @@ export interface WikilinkBaseItem {
aliases: string[]
group: string
entryTitle: string
+ path: string
}
/**
@@ -27,3 +28,32 @@ export function preFilterWikilinks(
item.group.toLowerCase().includes(lowerQuery)
)
}
+
+/** Remove duplicate items by path, keeping the first occurrence. */
+export function deduplicateByPath(items: T[]): T[] {
+ const seen = new Set()
+ return items.filter(item => {
+ if (seen.has(item.path)) return false
+ seen.add(item.path)
+ return true
+ })
+}
+
+/**
+ * When multiple items share the same title, append the parent folder name
+ * so the user can distinguish them and BlockNote gets unique React keys.
+ */
+export function disambiguateTitles(
+ items: T[],
+): T[] {
+ const titleCounts = new Map()
+ for (const item of items) {
+ titleCounts.set(item.title, (titleCounts.get(item.title) ?? 0) + 1)
+ }
+ return items.map(item => {
+ if ((titleCounts.get(item.title) ?? 0) <= 1) return item
+ const parts = item.path.split('/')
+ const folder = parts.length >= 2 ? parts[parts.length - 2] : ''
+ return { ...item, title: `${item.title} (${folder})` }
+ })
+}