Compare commits
5 Commits
v0.2026033
...
v0.2026033
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a923a95cf | ||
|
|
c0fed9c5c0 | ||
|
|
4d787d6f84 | ||
|
|
3bc824940a | ||
|
|
cebeca678f |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.85
|
||||
AVERAGE_THRESHOLD=9.39
|
||||
HOTSPOT_THRESHOLD=9.84
|
||||
AVERAGE_THRESHOLD=9.38
|
||||
|
||||
@@ -162,9 +162,11 @@ if failed:
|
||||
sys.exit(1)
|
||||
|
||||
# Ratchet: update thresholds file if scores improved
|
||||
# Truncate (floor) to 2 decimals so the threshold never exceeds the actual score
|
||||
import math
|
||||
thresholds_file = '$THRESHOLDS_FILE'
|
||||
new_hotspot = max(hotspot_min, round(hotspot, 2))
|
||||
new_average = max(average_min, round(average, 2))
|
||||
new_hotspot = max(hotspot_min, math.floor(hotspot * 100) / 100)
|
||||
new_average = max(average_min, math.floor(average * 100) / 100)
|
||||
if new_hotspot > hotspot_min or new_average > average_min:
|
||||
with open(thresholds_file, 'w') as f:
|
||||
f.write(f'HOTSPOT_THRESHOLD={new_hotspot}\nAVERAGE_THRESHOLD={new_average}\n')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -111,14 +111,9 @@ describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — title in breadcrumb on scroll', () => {
|
||||
it('does not show title when titleHidden is false', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} titleHidden={false} />)
|
||||
expect(screen.queryByText('Test Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows type and title when titleHidden is true', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} titleHidden={true} />)
|
||||
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
|
||||
it('always renders title elements in the DOM', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('›')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Note')).toBeInTheDocument()
|
||||
@@ -126,21 +121,29 @@ describe('BreadcrumbBar — title in breadcrumb on scroll', () => {
|
||||
|
||||
it('shows emoji icon when entry has an emoji icon', () => {
|
||||
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
|
||||
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} titleHidden={true} />)
|
||||
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
|
||||
expect(screen.getByText('🚀')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show icon when entry has a non-emoji icon', () => {
|
||||
const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' }
|
||||
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} titleHidden={true} />)
|
||||
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} />)
|
||||
expect(screen.queryByText('cooking-pot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to "Note" when isA is null', () => {
|
||||
const entryNoType = { ...baseEntry, isA: null }
|
||||
render(<BreadcrumbBar entry={entryNoType} {...defaultProps} titleHidden={true} />)
|
||||
render(<BreadcrumbBar entry={entryNoType} {...defaultProps} />)
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shadow is controlled by data-title-hidden attribute via CSS', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const bar = container.querySelector('.breadcrumb-bar')!
|
||||
expect(bar).not.toHaveAttribute('data-title-hidden')
|
||||
bar.setAttribute('data-title-hidden', '')
|
||||
expect(bar).toHaveAttribute('data-title-hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
|
||||
@@ -32,8 +32,8 @@ interface BreadcrumbBarProps {
|
||||
onRestore?: () => void
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
/** When true, the note title is scrolled out of view — show it inline. */
|
||||
titleHidden?: boolean
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
barRef?: React.Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
@@ -178,22 +178,21 @@ function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
|
||||
}
|
||||
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, titleHidden, ...actionProps
|
||||
entry, barRef, ...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
data-tauri-drag-region
|
||||
className="flex shrink-0 items-center"
|
||||
className="breadcrumb-bar flex shrink-0 items-center"
|
||||
style={{
|
||||
height: 52,
|
||||
background: 'var(--background)',
|
||||
padding: '6px 16px',
|
||||
boxShadow: titleHidden ? '0 1px 3px rgba(0,0,0,0.08)' : 'none',
|
||||
transition: 'box-shadow 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{titleHidden && <BreadcrumbTitle entry={entry} />}
|
||||
<div className="breadcrumb-bar__title flex-1 min-w-0">
|
||||
<BreadcrumbTitle entry={entry} />
|
||||
</div>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,24 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Breadcrumb bar: title + shadow toggled via data attribute (no React re-render) */
|
||||
.breadcrumb-bar {
|
||||
transition: box-shadow 0.2s ease;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.breadcrumb-bar[data-title-hidden] {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.breadcrumb-bar__title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breadcrumb-bar[data-title-hidden] .breadcrumb-bar__title {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Scroll area wrapping title + editor — single scroll context for alignment */
|
||||
.editor-scroll-area {
|
||||
flex: 1;
|
||||
|
||||
@@ -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' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useRef, useState, useEffect } from 'react'
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { DiffView } from './DiffView'
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -120,9 +122,9 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
|
||||
return cb ? () => cb(path) : undefined
|
||||
}
|
||||
|
||||
function ActiveTabBreadcrumb({ activeTab, titleHidden, props }: {
|
||||
function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
|
||||
activeTab: Tab
|
||||
titleHidden: boolean
|
||||
barRef: React.RefObject<HTMLDivElement | null>
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
|
||||
}) {
|
||||
const wordCount = countWords(activeTab.content)
|
||||
@@ -131,7 +133,7 @@ function ActiveTabBreadcrumb({ activeTab, titleHidden, props }: {
|
||||
<BreadcrumbBar
|
||||
entry={activeTab.entry}
|
||||
wordCount={wordCount}
|
||||
titleHidden={titleHidden}
|
||||
barRef={barRef}
|
||||
showDiffToggle={props.showDiffToggle}
|
||||
diffMode={props.diffMode}
|
||||
diffLoading={props.diffLoading}
|
||||
@@ -171,18 +173,21 @@ export function EditorContent({
|
||||
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
|
||||
|
||||
const titleSectionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [titleScrolledAway, setTitleScrolledAway] = useState(false)
|
||||
const titleHidden = showEditor && titleScrolledAway
|
||||
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = titleSectionRef.current
|
||||
if (!el) return
|
||||
const bar = breadcrumbBarRef.current
|
||||
if (!el || !bar) return
|
||||
const observer = new IntersectionObserver(
|
||||
([e]) => setTitleScrolledAway(!e.isIntersecting),
|
||||
([e]) => {
|
||||
if (e.isIntersecting) bar.removeAttribute('data-title-hidden')
|
||||
else bar.setAttribute('data-title-hidden', '')
|
||||
},
|
||||
{ threshold: 0 },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
return () => { observer.disconnect(); bar.removeAttribute('data-title-hidden') }
|
||||
}, [activeTab?.entry.path, showEditor])
|
||||
|
||||
const handleSetIcon = useCallback((emoji: string) => {
|
||||
@@ -198,7 +203,7 @@ export function EditorContent({
|
||||
{activeTab && (
|
||||
<ActiveTabBreadcrumb
|
||||
activeTab={activeTab}
|
||||
titleHidden={titleHidden}
|
||||
barRef={breadcrumbBarRef}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
/>
|
||||
)}
|
||||
@@ -218,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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -437,7 +437,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
|
||||
return (
|
||||
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} onRemoveVault={onRemoveVault} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
@@ -449,15 +449,15 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
onMouseEnter={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} onCommitPush={onCommitPush} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} remoteStatus={remoteStatus} onTriggerSync={onTriggerSync} onPullAndPush={onPullAndPush} onOpenConflictResolver={onOpenConflictResolver} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} onCommitPush={onCommitPush} />
|
||||
<PulseBadge onClick={onClickPulse} disabled={!isGitVault} />
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
|
||||
{zoomLevel !== 100 && (
|
||||
<span
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user