Compare commits

...

3 Commits

Author SHA1 Message Date
Test
36febb75da fix: active section badge shows primary background instead of gray
The inline badgeStyle (background: var(--muted)) was overriding the
Tailwind activeBadgeClassName (bg-primary) because inline styles have
higher specificity. Fixed by not falling back to badgeStyle when
activeBadgeClassName is provided and the item is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:50:02 +02:00
Test
8a923a95cf fix: add error handling to createNoteImmediate to prevent crashes
Wraps the note creation flow in try-catch so errors in title
generation, template resolution, or tab opening are logged to
console instead of crashing the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:34:44 +02:00
Test
c0fed9c5c0 fix: wikilink autocomplete uses relative path, prevent silent rename
Bug 1: Wikilink autocomplete now always inserts the vault-relative path
as the target (e.g. [[docs/adr/0001-tauri-stack|Title]]) instead of
just the filename. This ensures wikilinks are unambiguous and resolve
correctly across subfolders after reload.

Bug 2: unique_dest_path now excludes the source file from collision
checks, preventing false positives where a note's own file is treated
as a naming conflict (causing silent -2 suffix renames).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:15:17 +02:00
9 changed files with 65 additions and 63 deletions

View File

@@ -144,9 +144,10 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
}
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
/// `exclude` is the source file being renamed — it should not be treated as a collision.
fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::path::PathBuf {
let dest = dest_dir.join(filename);
if !dest.exists() {
if !dest.exists() || dest == exclude {
return dest;
}
let stem = Path::new(filename)
@@ -160,7 +161,7 @@ fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
let mut counter = 2;
loop {
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
if !candidate.exists() {
if !candidate.exists() || candidate == exclude {
return candidate;
}
counter += 1;
@@ -223,7 +224,7 @@ pub fn rename_note(
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = unique_dest_path(parent_dir, &expected_filename);
let new_file = unique_dest_path(parent_dir, &expected_filename, old_file);
let new_path_str = new_file.to_string_lossy().to_string();
fs::write(&new_file, &updated_content)

View File

@@ -467,7 +467,7 @@ describe('wikilink autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'Alpha Project' } },
{ type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } },
' ',
])
})
@@ -620,7 +620,7 @@ describe('person @mention autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'Matteo Cellini' } },
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } },
' ',
])
})

View File

@@ -92,7 +92,7 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
}
function RawModeEditorSection({
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef,
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef, vaultPath,
}: {
rawMode: boolean
activeTab: Tab | null
@@ -100,6 +100,7 @@ function RawModeEditorSection({
onContentChange?: (path: string, content: string) => void
onSave?: () => void
latestContentRef?: React.MutableRefObject<string | null>
vaultPath?: string
}) {
if (!rawMode || !activeTab) return null
return (
@@ -111,6 +112,7 @@ function RawModeEditorSection({
onContentChange={onContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
latestContentRef={latestContentRef}
vaultPath={vaultPath}
/>
)
}
@@ -221,7 +223,7 @@ export function EditorContent({
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} />
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} vaultPath={vaultPath} />
{showEditor && activeTab && (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div ref={titleSectionRef} className="title-section">

View File

@@ -21,6 +21,7 @@ export interface RawEditorViewProps {
path: string
entries: VaultEntry[]
onContentChange: (path: string, content: string) => void
vaultPath?: string
onSave: () => void
/** Mutable ref updated on every keystroke with the latest doc string.
* Allows the parent to flush debounced content before unmount. */
@@ -37,7 +38,7 @@ function getCursorCoords(view: EditorView): { top: number; left: number } | null
return { top: coords.bottom, left: coords.left }
}
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef }: RawEditorViewProps) {
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathRef = useRef(path)
@@ -92,10 +93,10 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
const coords = getCursorCoords(view)
if (!coords) { setAutocomplete(null); return }
const candidates = preFilterWikilinks(baseItems, query)
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title))
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title), vaultPath ?? '')
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
}, [baseItems, typeEntryMap])
}, [baseItems, typeEntryMap, vaultPath])
const handleSave = useCallback(() => {
if (debounceRef.current) {

View File

@@ -46,7 +46,7 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
const textClass = compact ? 'text-[12px]' : 'text-[13px]'
const padding = compact ? '4px 16px' : '6px 16px'
const resolvedBadgeClass = isActive && activeBadgeClassName ? activeBadgeClassName : badgeClassName
const resolvedBadgeStyle = isActive && activeBadgeStyle ? activeBadgeStyle : badgeStyle
const resolvedBadgeStyle = isActive && activeBadgeClassName ? activeBadgeStyle : badgeStyle
if (disabled) {
return (

View File

@@ -92,16 +92,16 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < MIN_QUERY_LENGTH) return []
const candidates = preFilterWikilinks(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap])
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < PERSON_MENTION_MIN_QUERY) return []
const candidates = filterPersonMentions(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap])
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>

View File

@@ -169,17 +169,21 @@ interface ImmediateCreateDeps {
/** Create an untitled note without persisting to disk (deferred save). */
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
const noteType = type || 'Note'
const title = generateUntitledName(deps.entries, noteType, deps.pendingNames)
deps.pendingNames.add(title)
const template = resolveTemplate(deps.entries, noteType)
const resolved = resolveNewNote(title, noteType, deps.vaultPath, template)
deps.openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
deps.trackUnsaved?.(resolved.entry.path)
deps.markContentPending?.(resolved.entry.path, resolved.content)
signalFocusEditor({ selectTitle: true })
setTimeout(() => deps.pendingNames.delete(title), 500)
try {
const noteType = type || 'Note'
const title = generateUntitledName(deps.entries, noteType, deps.pendingNames)
deps.pendingNames.add(title)
const template = resolveTemplate(deps.entries, noteType)
const resolved = resolveNewNote(title, noteType, deps.vaultPath, template)
deps.openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, deps.addEntry)
deps.trackUnsaved?.(resolved.entry.path)
deps.markContentPending?.(resolved.entry.path, resolved.content)
signalFocusEditor({ selectTitle: true })
setTimeout(() => deps.pendingNames.delete(title), 500)
} catch (err) {
console.error('Failed to create note:', err)
}
}
interface RelationshipCreateDeps {

View File

@@ -19,56 +19,55 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
}
describe('attachClickHandlers', () => {
it('adds onItemClick to each candidate', () => {
const vaultPath = '/vault'
it('inserts relative path stem as wikilink target', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Note A', aliases: [], group: 'Note', entryTitle: 'Note A', path: '/a.md' },
{ title: 'Note B', aliases: [], group: 'Project', entryTitle: 'Note B', path: '/b.md' },
{ title: 'Note A', aliases: [], group: 'Note', entryTitle: 'Note A', path: '/vault/a.md' },
{ title: 'Note B', aliases: [], group: 'Project', entryTitle: 'Note B', path: '/vault/b.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
expect(result).toHaveLength(2)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Note A')
expect(insertWikilink).toHaveBeenCalledWith('a|Note A')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Note B')
expect(insertWikilink).toHaveBeenCalledWith('b|Note B')
})
it('preserves all original properties', () => {
const result = attachClickHandlers(
[{ title: 'X', aliases: ['y'], group: 'Topic', entryTitle: 'X', path: '/x.md' }],
[{ title: 'X', aliases: ['y'], group: 'Topic', entryTitle: 'X', path: '/vault/x.md' }],
vi.fn(),
vaultPath,
)
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/x.md' })
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/vault/x.md' })
})
it('uses slug|title target when candidates have duplicate titles', () => {
it('includes subfolder path in wikilink target', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Status Update', aliases: [], group: 'Project', entryTitle: 'Status Update', path: '/vault/status-update.md' },
{ title: 'Status Update', aliases: [], group: 'Journal', entryTitle: 'Status Update', path: '/vault/status-update-2.md' },
{ title: 'ADR 001', aliases: [], group: 'Note', entryTitle: 'ADR 001', path: '/vault/docs/adr/0001-tauri-stack.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('status-update|Status Update')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('status-update-2|Status Update')
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack|ADR 001')
})
it('uses title-only target when titles are unique', () => {
it('omits pipe display when title matches path stem', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Alpha', aliases: [], group: 'Note', entryTitle: 'Alpha', path: '/vault/alpha.md' },
{ title: 'Beta', aliases: [], group: 'Note', entryTitle: 'Beta', path: '/vault/beta.md' },
{ title: 'roadmap', aliases: [], group: 'Note', entryTitle: 'roadmap', path: '/vault/roadmap.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Alpha')
expect(insertWikilink).toHaveBeenCalledWith('roadmap')
})
})

View File

@@ -5,6 +5,7 @@ import { deduplicateByPath, disambiguateTitles } from './wikilinkSuggestions'
import { bestSearchRank } from './fuzzyMatch'
import { filterSuggestionItems } from '@blocknote/core/extensions'
import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu'
import { relativePathStem } from './wikilink'
const MAX_RESULTS = 20
@@ -16,31 +17,25 @@ interface BaseSuggestionItem {
path: string
}
/** Build a filename-based target with pipe display: "slug|Title" */
function buildPathTarget(item: BaseSuggestionItem): string {
const filename = item.path.split('/').pop() ?? ''
const slug = filename.replace(/\.md$/, '')
return `${slug}|${item.entryTitle}`
/** Build the wikilink target: relative path stem with pipe display for the title.
* e.g. "docs/adr/0001-tauri-stack|Tauri Stack" for subfolders,
* "roadmap|Roadmap" for root files. */
function buildTarget(item: BaseSuggestionItem, vaultPath: string): string {
const stem = relativePathStem(item.path, vaultPath)
return stem === item.entryTitle ? stem : `${stem}|${item.entryTitle}`
}
/** Add onItemClick to raw suggestion candidates.
* When multiple candidates share the same title, inserts a path-based
* target with pipe syntax so the wikilink uniquely identifies the note. */
* Always inserts the vault-relative path as the wikilink target
* so links are unambiguous and work across subfolders. */
export function attachClickHandlers(
candidates: BaseSuggestionItem[],
insertWikilink: (target: string) => void,
vaultPath: string,
) {
const titleCounts = new Map<string, number>()
for (const item of candidates) {
titleCounts.set(item.entryTitle, (titleCounts.get(item.entryTitle) ?? 0) + 1)
}
return candidates.map(item => ({
...item,
onItemClick: () => {
const isDuplicate = (titleCounts.get(item.entryTitle) ?? 0) > 1
insertWikilink(isDuplicate ? buildPathTarget(item) : item.entryTitle)
},
onItemClick: () => insertWikilink(buildTarget(item, vaultPath)),
}))
}