diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs index 88081b61..845787f2 100644 --- a/src-tauri/src/vault/rename.rs +++ b/src-tauri/src/vault/rename.rs @@ -1,5 +1,6 @@ use regex::Regex; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::fs; use std::path::Path; use walkdir::WalkDir; @@ -48,13 +49,13 @@ fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool { } /// Replace wikilink references in a single file's content. Returns updated content if changed. -fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> Option { +fn replace_wikilinks_in_content(content: &str, re: &Regex, new_target: &str) -> Option { if !re.is_match(content) { return None; } let replaced = re.replace_all(content, |caps: ®ex::Captures| match caps.get(1) { - Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()), - None => format!("[[{}]]", new_title), + Some(pipe) => format!("[[{}{}]]", new_target, pipe.as_str()), + None => format!("[[{}]]", new_target), }); if replaced != content { Some(replaced.into_owned()) @@ -63,15 +64,6 @@ fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> O } } -/// Parameters for a vault-wide wikilink replacement. -struct WikilinkReplacement<'a> { - vault_path: &'a Path, - old_title: &'a str, - new_title: &'a str, - old_path_stem: &'a str, - exclude_path: &'a Path, -} - /// Collect all .md file paths in vault eligible for wikilink replacement. fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec { WalkDir::new(vault_path) @@ -83,16 +75,35 @@ fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec(targets: Vec<&'a str>) -> Vec<&'a str> { + let mut seen = HashSet::new(); + targets + .into_iter() + .filter(|target| !target.is_empty()) + .filter(|target| seen.insert(*target)) + .collect() +} + +fn collect_legacy_wikilink_targets<'a>(old_title: &'a str, old_path_stem: &'a str) -> Vec<&'a str> { + let old_filename_stem = old_path_stem.rsplit('/').next().unwrap_or(old_path_stem); + unique_wikilink_targets(vec![old_title, old_path_stem, old_filename_stem]) +} + /// Replace wiki link references across all vault markdown files. -fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize { - let re = match build_wikilink_pattern(&[params.old_title, params.old_path_stem]) { +fn update_wikilinks_in_vault( + vault_path: &Path, + old_targets: &[&str], + new_target: &str, + exclude_path: &Path, +) -> usize { + let re = match build_wikilink_pattern(old_targets) { Some(r) => r, None => return 0, }; replace_wikilinks_in_files( - collect_md_files(params.vault_path, params.exclude_path), + collect_md_files(vault_path, exclude_path), &re, - params.new_title, + new_target, ) } @@ -119,23 +130,6 @@ fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool fs::write(path, &new_content).is_ok() } -fn update_path_wikilinks_in_vault( - vault_path: &Path, - old_path_stem: &str, - new_path_stem: &str, - exclude_path: &Path, -) -> usize { - let re = match build_wikilink_pattern(&[old_path_stem]) { - Some(r) => r, - None => return 0, - }; - replace_wikilinks_in_files( - collect_md_files(vault_path, exclude_path), - &re, - new_path_stem, - ) -} - /// Extract the value of the `title:` frontmatter field from raw content. fn extract_fm_title_value(content: &str) -> Option { if !content.starts_with("---\n") { @@ -288,13 +282,9 @@ pub fn rename_note( // Update wikilinks across the vault let vault_prefix = format!("{}/", vault.to_string_lossy()); let old_path_stem = to_path_stem(old_path, &vault_prefix); - let updated_files = update_wikilinks_in_vault(&WikilinkReplacement { - vault_path: vault, - old_title, - new_title, - old_path_stem, - exclude_path: &new_file, - }); + let new_path_stem = to_path_stem(&new_path_str, &vault_prefix).to_string(); + let old_targets = collect_legacy_wikilink_targets(old_title, old_path_stem); + let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file); Ok(RenameResult { new_path: new_path_str, @@ -320,6 +310,10 @@ pub fn rename_note_filename( .file_name() .map(|f| f.to_string_lossy().to_string()) .unwrap_or_default(); + let content = + fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?; + let fm_title = extract_fm_title_value(&content); + let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename); let new_filename = format!("{}.md", normalized_stem); if old_filename == new_filename { @@ -350,8 +344,8 @@ pub fn rename_note_filename( let old_path_stem = to_path_stem(old_path, &vault_prefix); let new_path = new_file.to_string_lossy().to_string(); let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string(); - let updated_files = - update_path_wikilinks_in_vault(vault, old_path_stem, &new_path_stem, &new_file); + let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem); + let updated_files = update_wikilinks_in_vault(vault, &old_targets, &new_path_stem, &new_file); Ok(RenameResult { new_path, @@ -460,22 +454,12 @@ pub fn update_wikilinks_for_renames( .strip_suffix(".md") .unwrap_or(&rename.new_path); let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem); - let new_filename_stem = new_stem.split('/').next_back().unwrap_or(new_stem); - // Build title from filename stem (kebab-case → Title Case) let old_title = super::parsing::slug_to_title(old_filename_stem); - let new_title = super::parsing::slug_to_title(new_filename_stem); - // The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself) let new_file = vault.join(&rename.new_path); - - let updated = update_wikilinks_in_vault(&WikilinkReplacement { - vault_path: vault, - old_title: &old_title, - new_title: &new_title, - old_path_stem: old_filename_stem, - exclude_path: &new_file, - }); + let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem); + let updated = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file); total_updated += updated; } @@ -565,11 +549,11 @@ mod tests { assert_eq!(result.updated_files, 2); let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap(); - assert!(other_content.contains("[[Sprint Retrospective]]")); + assert!(other_content.contains("[[note/sprint-retrospective]]")); assert!(!other_content.contains("[[Weekly Review]]")); let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap(); - assert!(project_content.contains("[[Sprint Retrospective]]")); + assert!(project_content.contains("[[note/sprint-retrospective]]")); } #[test] @@ -629,7 +613,29 @@ mod tests { assert_eq!(result.updated_files, 1); let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap(); - assert!(ref_content.contains("[[Sprint Retro|my review]]")); + assert!(ref_content.contains("[[note/sprint-retro|my review]]")); + } + + #[test] + fn test_rename_note_updates_filename_only_wikilinks_to_canonical_path() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n"); + create_test_file(vault, "note/ref.md", "# Ref\n\nSee [[weekly-review]] for info.\n"); + + let old_path = vault.join("note/weekly-review.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "Sprint Retro", + None, + ) + .unwrap(); + + assert_eq!(result.updated_files, 1); + let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap(); + assert!(ref_content.contains("[[note/sprint-retro]]")); + assert!(!ref_content.contains("[[weekly-review]]")); } #[test] @@ -838,7 +844,7 @@ mod tests { let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap(); assert!(ref_content.contains("[[note/manual-name]]")); - assert!(ref_content.contains("[[Project Kickoff]]")); + assert!(!ref_content.contains("[[Project Kickoff]]")); assert!(!ref_content.contains("[[note/project-kickoff]]")); } @@ -896,11 +902,11 @@ mod tests { assert!(!vault.join("note/weekly-review.md").exists()); let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap(); - assert!(other_content.contains("[[Sprint Retrospective]]")); + assert!(other_content.contains("[[note/sprint-retrospective]]")); assert!(!other_content.contains("[[Weekly Review]]")); let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap(); - assert!(project_content.contains("[[Sprint Retrospective]]")); + assert!(project_content.contains("[[note/sprint-retrospective]]")); } #[test] @@ -930,7 +936,7 @@ mod tests { assert_eq!(result.updated_files, 1); let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap(); - assert!(other_content.contains("[[Sprint Retrospective]]")); + assert!(other_content.contains("[[note/sprint-retrospective]]")); } #[test] diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 0bdd7736..6ec923b0 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -413,7 +413,7 @@ describe('wikilink autocomplete', () => { expect(items.length).toBeGreaterThan(0) items[0].onItemClick() expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([ - { type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } }, + { type: 'wikilink', props: { target: 'vault/project/test' } }, ' ', ]) }) @@ -566,7 +566,7 @@ describe('person @mention autocomplete', () => { expect(items.length).toBeGreaterThan(0) items[0].onItemClick() expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([ - { type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } }, + { type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } }, ' ', ]) }) diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index f5c591a6..032819db 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -75,6 +75,7 @@ export function EditorRightPanel({ content={inspectorContent} entries={entries} gitHistory={gitHistory} + vaultPath={vaultPath} onNavigate={onNavigateWikilink} onViewCommitDiff={onViewCommitDiff} onUpdateFrontmatter={onUpdateFrontmatter} diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index adc38b02..e4e8bfb2 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -24,6 +24,7 @@ interface InspectorProps { content: string | null entries: VaultEntry[] gitHistory: GitCommit[] + vaultPath?: string onNavigate: (target: string) => void onViewCommitDiff?: (commitHash: string) => void onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise @@ -41,6 +42,7 @@ export function Inspector({ content, entries, gitHistory, + vaultPath, onNavigate, onViewCommitDiff, onUpdateFrontmatter, @@ -96,6 +98,7 @@ export function Inspector({ frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} + vaultPath={vaultPath} onNavigate={onNavigate} onAddProperty={onAddProperty ? handleAddProperty : undefined} onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined} diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index 38baa198..9ef588a9 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -179,7 +179,7 @@ describe('DynamicRelationshipsPanel', () => { fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } }) fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } }) fireEvent.click(screen.getByTestId('submit-add-relationship')) - expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]') + expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]') }) it('cancels add relationship form', () => { @@ -427,14 +427,14 @@ describe('DynamicRelationshipsPanel', () => { const input = screen.getByTestId('add-relation-ref-input') fireEvent.change(input, { target: { value: 'AI' } }) fireEvent.keyDown(input, { key: 'Enter' }) - expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[AI]]']) + expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]']) }) it('does not add duplicate refs', () => { render( { fireEvent.click(screen.getByTestId('create-and-open-option')) expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note') await vi.waitFor(() => { - expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[Brand New Note]]']) + expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[brand-new-note]]']) }) }) @@ -647,7 +647,7 @@ describe('DynamicRelationshipsPanel', () => { fireEvent.click(screen.getByTestId('create-and-open-option')) expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person') await vi.waitFor(() => { - expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[New Person]]') + expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[new-person]]') }) }) }) diff --git a/src/components/RawEditorView.test.tsx b/src/components/RawEditorView.test.tsx index 5fd1c649..5ca01306 100644 --- a/src/components/RawEditorView.test.tsx +++ b/src/components/RawEditorView.test.tsx @@ -1,7 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, act } from '@testing-library/react' import { RawEditorView } from './RawEditorView' -import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils' function entry(title: string, path = `/vault/note/${title}.md`) { return { @@ -23,61 +22,6 @@ const defaultProps = { onSave: vi.fn(), } -describe('extractWikilinkQuery', () => { - it('returns null when no [[ trigger', () => { - expect(extractWikilinkQuery('hello world', 5)).toBeNull() - }) - - it('returns empty string immediately after [[', () => { - const text = 'see [[' - expect(extractWikilinkQuery(text, text.length)).toBe('') - }) - - it('returns query after [[', () => { - const text = 'see [[Proj' - expect(extractWikilinkQuery(text, text.length)).toBe('Proj') - }) - - it('returns null when ]] closes the link', () => { - const text = '[[Proj]]' - expect(extractWikilinkQuery(text, text.length)).toBeNull() - }) - - it('returns null when newline is in query', () => { - const text = '[[Proj\ncontinued' - expect(extractWikilinkQuery(text, text.length)).toBeNull() - }) - - it('handles cursor before end of text', () => { - const text = '[[Proj after' - expect(extractWikilinkQuery(text, 6)).toBe('Proj') - }) -}) - -describe('detectYamlError', () => { - it('returns null for content without frontmatter', () => { - expect(detectYamlError('# Title\n\nSome content.')).toBeNull() - }) - - it('returns null for valid frontmatter', () => { - expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull() - }) - - it('returns error for unclosed frontmatter', () => { - const error = detectYamlError('---\ntitle: My Note\n\n# Title') - expect(error).toContain('Unclosed frontmatter') - }) - - it('returns error for tab indentation in frontmatter', () => { - const error = detectYamlError('---\n\ttitle: My Note\n---\n') - expect(error).toContain('tab indentation') - }) - - it('returns null for content not starting with ---', () => { - expect(detectYamlError('Not frontmatter')).toBeNull() - }) -}) - describe('RawEditorView', () => { it('renders CodeMirror container', () => { render() diff --git a/src/components/RawEditorView.tsx b/src/components/RawEditorView.tsx index c4bd308a..1f702ba5 100644 --- a/src/components/RawEditorView.tsx +++ b/src/components/RawEditorView.tsx @@ -1,22 +1,21 @@ import { useRef, useState, useCallback, useEffect, useMemo } from 'react' import { trackEvent } from '../lib/telemetry' import type { EditorView } from '@codemirror/view' -import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' -import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment' +import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' import { buildTypeEntryMap } from '../utils/typeColors' import { NoteSearchList } from './NoteSearchList' -import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils' +import { + buildRawEditorAutocompleteState, + buildRawEditorBaseItems, + detectYamlError, + extractWikilinkQuery, + getRawEditorDropdownPosition, + replaceActiveWikilinkQuery, + type RawEditorAutocompleteState, +} from '../utils/rawEditorUtils' import { useCodeMirror } from '../hooks/useCodeMirror' -import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu' import type { VaultEntry } from '../types' -interface AutocompleteState { - caretTop: number - caretLeft: number - selectedIndex: number - items: WikilinkSuggestionItem[] -} - export interface RawEditorViewProps { content: string path: string @@ -32,13 +31,6 @@ export interface RawEditorViewProps { const DEBOUNCE_MS = 500 const DROPDOWN_MAX_HEIGHT = 200 -function getCursorCoords(view: EditorView): { top: number; left: number } | null { - const pos = view.state.selection.main.head - const coords = view.coordsAtPos(pos) - if (!coords) return null - return { top: coords.bottom, left: coords.left } -} - export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) { const containerRef = useRef(null) const debounceRef = useRef | null>(null) @@ -52,23 +44,14 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange]) useEffect(() => { onSaveRef.current = onSave }, [onSave]) - const [autocomplete, setAutocomplete] = useState(null) + const [autocomplete, setAutocomplete] = useState(null) const [yamlError, setYamlError] = useState(() => detectYamlError(content)) const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) - const baseItems = useMemo( - () => deduplicateByPath(entries.map(entry => ({ - title: entry.title, - aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])], - group: entry.isA || 'Note', - entryTitle: entry.title, - path: entry.path, - }))), - [entries], - ) + const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries]) - const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {}) + const insertWikilinkRef = useRef<(target: string) => void>(() => {}) const latestContentRefStable = useRef(latestContentRef) useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef]) @@ -91,12 +74,15 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, setAutocomplete(null) return } - const coords = getCursorCoords(view) - if (!coords) { setAutocomplete(null); return } - const candidates = preFilterWikilinks(baseItems, query) - 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 }) + const nextAutocomplete = buildRawEditorAutocompleteState({ + view, + baseItems, + query, + typeEntryMap, + onInsertTarget: (target: string) => insertWikilinkRef.current(target), + vaultPath: vaultPath ?? '', + }) + setAutocomplete(nextAutocomplete) }, [baseItems, typeEntryMap, vaultPath]) const handleSave = useCallback(() => { @@ -120,30 +106,25 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, onEscape: handleEscape, }) - const insertWikilink = useCallback((entryTitle: string) => { + const insertWikilink = useCallback((target: string) => { const view = viewRef.current if (!view) return const cursor = view.state.selection.main.head const doc = view.state.doc.toString() - const before = doc.slice(0, cursor) - const triggerIdx = before.lastIndexOf('[[') - if (triggerIdx === -1) return - - const after = doc.slice(cursor) - const newText = `${doc.slice(0, triggerIdx)}[[${entryTitle}]]${after}` - const newCursor = triggerIdx + entryTitle.length + 4 + const replacement = replaceActiveWikilinkQuery(doc, cursor, target) + if (!replacement) return view.dispatch({ - changes: { from: 0, to: doc.length, insert: newText }, - selection: { anchor: newCursor }, + changes: { from: 0, to: doc.length, insert: replacement.text }, + selection: { anchor: replacement.cursor }, }) trackEvent('wikilink_inserted') setAutocomplete(null) if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = null - latestDocRef.current = newText - onContentChangeRef.current(pathRef.current, newText) + latestDocRef.current = replacement.text + onContentChangeRef.current(pathRef.current, replacement.text) view.focus() }, [viewRef]) @@ -165,9 +146,9 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, } else if (e.key === 'Enter') { e.preventDefault() const item = autocomplete.items[autocomplete.selectedIndex] - if (item) insertWikilink(item.entryTitle ?? item.title) + if (item) item.onItemClick() } - }, [autocomplete, insertWikilink]) + }, [autocomplete]) // Flush pending debounce on unmount useEffect(() => { @@ -179,15 +160,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, } }, []) - const dropdownBelow = autocomplete - ? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight - : true - const dropdownTop = autocomplete - ? (dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 24) - : 0 - const dropdownLeft = autocomplete - ? Math.min(autocomplete.caretLeft, window.innerWidth - 260) - : 0 + const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window) return (
@@ -212,8 +185,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
`${item.title}-${item.path ?? i}`} - onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)} + onItemClick={(item) => item.onItemClick()} onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)} />
diff --git a/src/components/inspector/RelationshipsPanel.tsx b/src/components/inspector/RelationshipsPanel.tsx index 097cee4c..04e5bb57 100644 --- a/src/components/inspector/RelationshipsPanel.tsx +++ b/src/components/inspector/RelationshipsPanel.tsx @@ -6,7 +6,12 @@ import { containsWikilinks } from '../DynamicPropertiesPanel' import type { FrontmatterValue } from '../Inspector' import { NoteSearchList } from '../NoteSearchList' import { useNoteSearch } from '../../hooks/useNoteSearch' -import { resolveEntry } from '../../utils/wikilink' +import { + resolveEntry, + canonicalWikilinkTargetForEntry, + canonicalWikilinkTargetForTitle, + formatWikilinkRef, +} from '../../utils/wikilink' import { isWikilink, resolveRefProps } from './shared' import { LinkButton } from './LinkButton' @@ -15,6 +20,61 @@ function hasExactTitleMatch(entries: VaultEntry[], title: string): boolean { return resolveEntry(entries, title) !== undefined } +function inferVaultPath(entries: VaultEntry[]): string { + if (entries.length === 0) return '' + const segments = entries.map((entry) => entry.path.split('/').slice(0, -1)) + const prefix: string[] = [] + const maxDepth = Math.min(...segments.map((parts) => parts.length)) + for (let i = 0; i < maxDepth; i += 1) { + const segment = segments[0][i] + if (segments.every((parts) => parts[i] === segment)) prefix.push(segment) + else break + } + return prefix.join('/') +} + +function canonicalRefForEntry(entry: VaultEntry, vaultPath: string): string { + return formatWikilinkRef(canonicalWikilinkTargetForEntry(entry, vaultPath)) +} + +function canonicalRefForTitle(title: string, entries: VaultEntry[], vaultPath: string): string { + return formatWikilinkRef(canonicalWikilinkTargetForTitle(title, entries, vaultPath)) +} + +function shouldShowSearchDropdown(focused: boolean, trimmed: string, resultCount: number, showCreate: boolean): boolean { + return focused && trimmed.length > 0 && (resultCount > 0 || showCreate) +} + +function confirmRelationshipSelection({ + showCreate, + selectedIndex, + createIndex, + trimmed, + selectedEntry, + onCreate, + onSelectEntry, + onFallback, +}: { + showCreate: boolean + selectedIndex: number + createIndex: number + trimmed: string + selectedEntry?: VaultEntry + onCreate?: (title: string) => void + onSelectEntry?: (entry: VaultEntry) => void + onFallback?: () => void +}): void { + if (showCreate && selectedIndex === createIndex && trimmed) { + onCreate?.(trimmed) + return + } + if (selectedEntry) { + onSelectEntry?.(selectedEntry) + return + } + onFallback?.() +} + /** Shared keyboard navigation for search dropdowns with an optional "create" item. */ function useSearchKeyboard( search: ReturnType, @@ -90,7 +150,7 @@ function CreateAndOpenOption({ title, selected, onClick, onHover }: { function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: { search: ReturnType - onSelect: (title: string) => void + onSelect: (entry: VaultEntry) => void query: string entries: VaultEntry[] onCreateAndOpen?: (title: string) => void @@ -108,7 +168,7 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn items={search.results} selectedIndex={search.selectedIndex} getItemKey={(item) => item.entry.path} - onItemClick={(item) => onSelect(item.entry.title)} + onItemClick={(item) => onSelect(item.entry)} onItemHover={(i) => search.setSelectedIndex(i)} className="max-h-[160px] overflow-y-auto" /> @@ -125,11 +185,12 @@ function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAn ) } -function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: { - entries: VaultEntry[] - onAdd: (noteTitle: string) => void - onCreateAndOpenNote?: (title: string) => Promise -}) { +function useInlineAddNoteState( + entries: VaultEntry[], + vaultPath: string, + onAdd: (ref: string) => void, + onCreateAndOpenNote?: (title: string) => Promise, +) { const [active, setActive] = useState(false) const [query, setQuery] = useState('') const inputRef = useRef(null) @@ -138,25 +199,82 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: { const trimmed = query.trim() const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote) - const dismiss = useCallback(() => { setQuery(''); setActive(false) }, []) + const dismiss = useCallback(() => { + setQuery('') + setActive(false) + }, []) - const selectAndClose = useCallback((title: string) => { - onAdd(title) + const selectAndClose = useCallback((ref: string) => { + onAdd(ref) dismiss() }, [onAdd, dismiss]) - const handleCreateAndOpen = useCreateAndOpen(onCreateAndOpenNote, onAdd, dismiss) + const selectEntryAndClose = useCallback((entry: VaultEntry) => { + selectAndClose(canonicalRefForEntry(entry, vaultPath)) + }, [selectAndClose, vaultPath]) + + const handleCreateAndOpen = useCreateAndOpen( + onCreateAndOpenNote, + (title) => onAdd(canonicalRefForTitle(title, entries, vaultPath)), + dismiss, + ) + + const handleFallback = useCallback(() => { + if (!trimmed) return + selectAndClose(canonicalRefForTitle(trimmed, entries, vaultPath)) + }, [trimmed, selectAndClose, entries, vaultPath]) const handleConfirm = useCallback(() => { - if (showCreate && search.selectedIndex === createIndex) { - handleCreateAndOpen(trimmed) - return - } - const title = search.selectedEntry?.title ?? trimmed - if (title) selectAndClose(title) - }, [search.selectedEntry, search.selectedIndex, trimmed, selectAndClose, showCreate, createIndex, handleCreateAndOpen]) + confirmRelationshipSelection({ + showCreate, + selectedIndex: search.selectedIndex, + createIndex, + trimmed, + selectedEntry: search.selectedEntry, + onCreate: handleCreateAndOpen, + onSelectEntry: selectEntryAndClose, + onFallback: handleFallback, + }) + }, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, handleCreateAndOpen, selectEntryAndClose, handleFallback]) const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, dismiss) + const showDropdown = shouldShowSearchDropdown(active, trimmed, search.results.length, showCreate) + + return { + active, + setActive, + query, + setQuery, + inputRef, + search, + dismiss, + handleKeyDown, + showDropdown, + selectEntryAndClose, + showCreate, + handleCreateAndOpen, + } +} + +function InlineAddNote({ entries, vaultPath, onAdd, onCreateAndOpenNote }: { + entries: VaultEntry[] + vaultPath: string + onAdd: (ref: string) => void + onCreateAndOpenNote?: (title: string) => Promise +}) { + const { + active, + setActive, + query, + setQuery, + inputRef, + search, + dismiss, + handleKeyDown, + showDropdown, + selectEntryAndClose, + handleCreateAndOpen, + } = useInlineAddNoteState(entries, vaultPath, onAdd, onCreateAndOpenNote) if (!active) { return ( @@ -171,8 +289,6 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: { ) } - const showDropdown = trimmed.length > 0 && (search.results.length > 0 || showCreate) - return (
@@ -197,7 +313,7 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: { {showDropdown && ( { handleCreateAndOpen(title) } : undefined} @@ -207,11 +323,11 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: { ) } -function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: { - label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record +function RelationshipGroup({ label, refs, entries, typeEntryMap, vaultPath, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: { + label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record; vaultPath: string onNavigate: (target: string) => void onRemoveRef?: (ref: string) => void - onAddRef?: (noteTitle: string) => void + onAddRef?: (ref: string) => void onCreateAndOpenNote?: (title: string) => Promise }) { if (refs.length === 0) return null @@ -234,6 +350,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR {onAddRef && ( @@ -269,22 +386,30 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat const trimmed = value.trim() const { showCreate, createIndex, totalItems } = useCreateOption(entries, trimmed, search.results.length, !!onCreateAndOpenNote) - const handleConfirm = useCallback(() => { - if (showCreate && search.selectedIndex === createIndex) { - onSubmitWithCreate?.(trimmed) - } else if (search.selectedEntry) { - onChange(search.selectedEntry.title) - setFocused(false) - } else { - onSubmit?.() - } - }, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onChange, onSubmit, onSubmitWithCreate]) + const selectEntry = useCallback((entry: VaultEntry) => { + onChange(entry.title) + setFocused(false) + }, [onChange]) - const handleEscape = useCallback(() => { onCancel?.() }, [onCancel]) + const handleConfirm = useCallback(() => { + confirmRelationshipSelection({ + showCreate, + selectedIndex: search.selectedIndex, + createIndex, + trimmed, + selectedEntry: search.selectedEntry, + onCreate: onSubmitWithCreate, + onSelectEntry: selectEntry, + onFallback: onSubmit, + }) + }, [showCreate, search.selectedIndex, search.selectedEntry, createIndex, trimmed, onSubmitWithCreate, selectEntry, onSubmit]) + + const handleEscape = useCallback(() => { + onCancel?.() + }, [onCancel]) const handleKeyDown = useSearchKeyboard(search, totalItems, handleConfirm, handleEscape) - - const showDropdown = focused && trimmed.length > 0 && (search.results.length > 0 || showCreate) + const showDropdown = shouldShowSearchDropdown(focused, trimmed, search.results.length, showCreate) return (
@@ -301,7 +426,7 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat {showDropdown && ( { onChange(title); setFocused(false) }} + onSelect={selectEntry} query={value} entries={entries} onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined} @@ -311,8 +436,56 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreat ) } -function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: { +function useRelationshipPanelState( + frontmatter: ParsedFrontmatter, + entries: VaultEntry[], + vaultPath: string | undefined, + onAddProperty?: (key: string, value: FrontmatterValue) => void, + onUpdateProperty?: (key: string, value: FrontmatterValue) => void, + onDeleteProperty?: (key: string) => void, +) { + const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter]) + const resolvedVaultPath = useMemo(() => vaultPath ?? inferVaultPath(entries), [vaultPath, entries]) + + const handleRemoveRef = useCallback((key: string, refToRemove: string) => { + if (!onUpdateProperty || !onDeleteProperty) return + const group = relationshipEntries.find(g => g.key === key) + if (!group) return + const result = updateRefsForRemoval(group.refs, refToRemove) + if (result === null) onDeleteProperty(key) + else onUpdateProperty(key, result) + }, [relationshipEntries, onUpdateProperty, onDeleteProperty]) + + const handleAddRef = useCallback((key: string, ref: string) => { + if (!onUpdateProperty) return + const existing = relationshipEntries.find(g => g.key === key)?.refs ?? [] + const result = updateRefsForAddition(existing, ref) + if (result !== false) onUpdateProperty(key, result) + }, [relationshipEntries, onUpdateProperty]) + + const canEdit = !!onUpdateProperty && !!onDeleteProperty + const existingRelKeys = useMemo( + () => new Set(relationshipEntries.map(g => g.key.toLowerCase())), + [relationshipEntries], + ) + const missingSuggestedRels = useMemo( + () => (onAddProperty ? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase())) : []), + [onAddProperty, existingRelKeys], + ) + + return { + relationshipEntries, + resolvedVaultPath, + handleRemoveRef, + handleAddRef, + canEdit, + missingSuggestedRels, + } +} + +function AddRelationshipForm({ entries, vaultPath, onAddProperty, onCreateAndOpenNote }: { entries: VaultEntry[] + vaultPath: string onAddProperty: (key: string, value: FrontmatterValue) => void onCreateAndOpenNote?: (title: string) => Promise }) { @@ -327,16 +500,16 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: { const submitForm = useCallback((targetOverride?: string) => { const key = relKey.trim() - const target = (targetOverride ?? relTarget).trim() - if (!key || !target) return - onAddProperty(key, `[[${target}]]`) + const rawTarget = (targetOverride ?? relTarget).trim() + if (!key || !rawTarget) return + onAddProperty(key, canonicalRefForTitle(rawTarget, entries, vaultPath)) resetForm() - }, [relKey, relTarget, onAddProperty, resetForm]) + }, [relKey, relTarget, entries, vaultPath, onAddProperty, resetForm]) const addPropertyForKey = useCallback((title: string) => { const key = relKey.trim() - if (key) onAddProperty(key, `[[${title}]]`) - }, [relKey, onAddProperty]) + if (key) onAddProperty(key, canonicalRefForTitle(title, entries, vaultPath)) + }, [relKey, entries, vaultPath, onAddProperty]) const handleCreateAndSubmit = useCreateAndOpen(onCreateAndOpenNote, addPropertyForKey, resetForm) @@ -381,10 +554,9 @@ function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterV return remaining.length === 1 ? remaining[0] : remaining } -function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false { - const newRef = `[[${noteTitle}]]` - if (refs.includes(newRef)) return false - const updated = [...refs, newRef] +function updateRefsForAddition(refs: string[], refToAdd: string): FrontmatterValue | false { + if (refs.includes(refToAdd)) return false + const updated = [...refs, refToAdd] return updated.length === 1 ? updated[0] : updated } @@ -396,10 +568,11 @@ function DisabledLinkButton() { const SUGGESTED_RELATIONSHIPS = ['Belongs to', 'Related to', 'Has'] as const -function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote }: { +function SuggestedRelationshipSlot({ label, entries, vaultPath, onAdd, onCreateAndOpenNote }: { label: string entries: VaultEntry[] - onAdd: (noteTitle: string) => void + vaultPath: string + onAdd: (ref: string) => void onCreateAndOpenNote?: (title: string) => Promise }) { return ( @@ -407,6 +580,7 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote {label} @@ -414,50 +588,30 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote ) } -export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: { - frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record +export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, vaultPath, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: { + frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record; vaultPath?: string onNavigate: (target: string) => void onAddProperty?: (key: string, value: FrontmatterValue) => void onUpdateProperty?: (key: string, value: FrontmatterValue) => void onDeleteProperty?: (key: string) => void onCreateAndOpenNote?: (title: string) => Promise }) { - const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter]) - - const handleRemoveRef = useCallback((key: string, refToRemove: string) => { - if (!onUpdateProperty || !onDeleteProperty) return - const group = relationshipEntries.find(g => g.key === key) - if (!group) return - const result = updateRefsForRemoval(group.refs, refToRemove) - if (result === null) onDeleteProperty(key) - else onUpdateProperty(key, result) - }, [relationshipEntries, onUpdateProperty, onDeleteProperty]) - - const handleAddRef = useCallback((key: string, noteTitle: string) => { - if (!onUpdateProperty) return - const existing = relationshipEntries.find(g => g.key === key)?.refs ?? [] - const result = updateRefsForAddition(existing, noteTitle) - if (result !== false) onUpdateProperty(key, result) - }, [relationshipEntries, onUpdateProperty]) - - const canEdit = !!onUpdateProperty && !!onDeleteProperty - - const existingRelKeys = useMemo( - () => new Set(relationshipEntries.map(g => g.key.toLowerCase())), - [relationshipEntries], - ) - - const missingSuggestedRels = onAddProperty - ? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase())) - : [] + const { + relationshipEntries, + resolvedVaultPath, + handleRemoveRef, + handleAddRef, + canEdit, + missingSuggestedRels, + } = useRelationshipPanelState(frontmatter, entries, vaultPath, onAddProperty, onUpdateProperty, onDeleteProperty) return (
{relationshipEntries.map(({ key, refs }) => ( handleRemoveRef(key, ref) : undefined} - onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined} + onAddRef={canEdit ? (ref) => handleAddRef(key, ref) : undefined} onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined} /> ))} @@ -466,12 +620,13 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, key={label} label={label} entries={entries} - onAdd={(noteTitle) => onAddProperty!(label, `[[${noteTitle}]]`)} + vaultPath={resolvedVaultPath} + onAdd={(ref) => onAddProperty!(label, ref)} onCreateAndOpenNote={onCreateAndOpenNote} /> ))} {onAddProperty - ? + ? : }
diff --git a/src/components/inspector/useInspectorData.ts b/src/components/inspector/useInspectorData.ts index 7b7304d3..786a5c8b 100644 --- a/src/components/inspector/useInspectorData.ts +++ b/src/components/inspector/useInspectorData.ts @@ -11,10 +11,10 @@ function targetMatchesEntry(target: string, entryPath: string, matchTargets: Set return false } -function refsMatchTargets(refs: string[], targets: Set): boolean { +function refsMatchTargets(refs: string[], entryPath: string, targets: Set): boolean { return refs.some((ref) => { const target = wikilinkTarget(ref) - return targets.has(target) || targets.has(target.split('/').pop() ?? '') + return targetMatchesEntry(target, entryPath, targets) }) } @@ -29,7 +29,7 @@ export function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]) for (const other of entries) { if (other.path === entry.path) continue for (const [key, refs] of Object.entries(other.relationships)) { - if (key !== 'Type' && refsMatchTargets(refs, matchTargets)) { + if (key !== 'Type' && refsMatchTargets(refs, entry.path, matchTargets)) { results.push({ entry: other, viaKey: key }) } } diff --git a/src/hooks/useNoteActions.hook.test.ts b/src/hooks/useNoteActions.hook.test.ts index 9d3b1269..7b585b0f 100644 --- a/src/hooks/useNoteActions.hook.test.ts +++ b/src/hooks/useNoteActions.hook.test.ts @@ -566,7 +566,7 @@ describe('useNoteActions hook', () => { new_title: 'Sprint Retro', old_title: 'Weekly Review', })) - expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links') + expect(setToastMessage).toHaveBeenCalledWith('Updated 2 notes') }) it('handleRenameNote passes null old_title when entry not found', async () => { diff --git a/src/hooks/useNoteRename.test.ts b/src/hooks/useNoteRename.test.ts index 2e81e2af..f5c8e519 100644 --- a/src/hooks/useNoteRename.test.ts +++ b/src/hooks/useNoteRename.test.ts @@ -66,11 +66,11 @@ describe('renameToastMessage', () => { }) it('returns singular when 1 file updated', () => { - expect(renameToastMessage(1)).toBe('Renamed — updated 1 wiki link') + expect(renameToastMessage(1)).toBe('Updated 1 note') }) it('returns plural when multiple files updated', () => { - expect(renameToastMessage(3)).toBe('Renamed — updated 3 wiki links') + expect(renameToastMessage(3)).toBe('Updated 3 notes') }) }) @@ -110,7 +110,7 @@ describe('useNoteRename hook', () => { new_title: 'New', old_title: 'Old', })) - expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links') + expect(setToastMessage).toHaveBeenCalledWith('Updated 2 notes') expect(onEntryRenamed).toHaveBeenCalled() }) @@ -199,7 +199,7 @@ describe('useNoteRename hook', () => { }), '# Project Kickoff\n', ) - expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 1 wiki link') + expect(setToastMessage).toHaveBeenCalledWith('Updated 1 note') }) it('handleRenameFilename surfaces backend conflict errors', async () => { diff --git a/src/hooks/useNoteRename.ts b/src/hooks/useNoteRename.ts index dd683397..c253f964 100644 --- a/src/hooks/useNoteRename.ts +++ b/src/hooks/useNoteRename.ts @@ -65,7 +65,7 @@ export async function loadNoteContent(path: string): Promise { export function renameToastMessage(updatedFiles: number): string { if (updatedFiles === 0) return 'Renamed' - return `Renamed — updated ${updatedFiles} wiki link${updatedFiles > 1 ? 's' : ''}` + return `Updated ${updatedFiles} note${updatedFiles > 1 ? 's' : ''}` } /** Reload content for open tabs whose wikilinks may have changed after a rename. */ diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 7eab20b3..7e5b6647 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -110,35 +110,81 @@ let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vaul let mockDeviceFlowPollCount = 0 +function escapeRegex({ text }: { text: string }) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function relativePathStem({ path, vaultPath }: { path: string; vaultPath: string }) { + const prefix = vaultPath.endsWith('/') ? vaultPath : `${vaultPath}/` + if (path.startsWith(prefix)) return path.slice(prefix.length).replace(/\.md$/, '') + return (path.split('/').pop() ?? path).replace(/\.md$/, '') +} + +function canonicalRenameTargets({ oldTitle, oldPathStem }: { oldTitle: string; oldPathStem: string }) { + const oldFilenameStem = oldPathStem.split('/').pop() ?? oldPathStem + return [...new Set([oldTitle, oldPathStem, oldFilenameStem].filter(Boolean))] +} + +function slugifyMockTitle({ title }: { title: string }) { + return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') +} + +function buildRenamedMockPath({ oldPath, newTitle }: { oldPath: string; newTitle: string }) { + const parentDir = oldPath.replace(/\/[^/]+$/, '') + return `${parentDir}/${slugifyMockTitle({ title: newTitle })}.md` +} + +function replaceMockTitleFrontmatter({ content, newTitle }: { content: string; newTitle: string }) { + return /^title:\s*/m.test(content) + ? content.replace(/^title:\s*.*$/m, `title: ${newTitle}`) + : content +} + +function replaceRenamedWikilinks({ content, oldTargets, newPathStem }: { + content: string + oldTargets: string[] + newPathStem: string +}) { + if (oldTargets.length === 0) return content + const pattern = new RegExp(`\\[\\[(?:${oldTargets.map((target) => escapeRegex({ text: target })).join('|')})(\\|[^\\]]*?)?\\]\\]`, 'g') + return content.replace(pattern, (_match: string, pipe: string | undefined) => + pipe ? `[[${newPathStem}${pipe}]]` : `[[${newPathStem}]]` + ) +} + +function updateMockRenameReferences({ newPath, newPathStem, oldTargets }: { + newPath: string + newPathStem: string + oldTargets: string[] +}) { + let updatedFiles = 0 + for (const [path, content] of Object.entries(MOCK_CONTENT)) { + if (path === newPath) continue + const replaced = replaceRenamedWikilinks({ content, oldTargets, newPathStem }) + if (replaced === content) continue + MOCK_CONTENT[path] = replaced + updatedFiles += 1 + } + return updatedFiles +} + function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) { const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path) const oldTitle = args.old_title ?? oldEntry?.title ?? '' - if (oldTitle === args.new_title) { + const oldContent = MOCK_CONTENT[args.old_path] ?? '' + const newPath = buildRenamedMockPath({ oldPath: args.old_path, newTitle: args.new_title }) + const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path }) + const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path }) + + if (oldTitle === args.new_title && newPath === args.old_path) { return { new_path: args.old_path, updated_files: 0 } } - const oldContent = MOCK_CONTENT[args.old_path] ?? '' - const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') - const parentDir = args.old_path.replace(/\/[^/]+$/, '') - const newPath = `${parentDir}/${slug}.md` - const newContent = oldContent.replace(/^# .+$/m, `# ${args.new_title}`) - + const newContent = replaceMockTitleFrontmatter({ content: oldContent, newTitle: args.new_title }) delete MOCK_CONTENT[args.old_path] MOCK_CONTENT[newPath] = newContent - let updatedFiles = 0 - if (oldTitle) { - const pattern = new RegExp(`\\[\\[${oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\|[^\\]]*?)?\\]\\]`, 'g') - for (const [path, content] of Object.entries(MOCK_CONTENT)) { - if (path === newPath) continue - const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) => - pipe ? `[[${args.new_title}${pipe}]]` : `[[${args.new_title}]]` - ) - if (replaced !== content) { - MOCK_CONTENT[path] = replaced - updatedFiles++ - } - } - } + const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem }) + const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets }) syncWindowContent() return { new_path: newPath, updated_files: updatedFiles } diff --git a/src/utils/rawEditorUtils.test.ts b/src/utils/rawEditorUtils.test.ts new file mode 100644 index 00000000..00fed270 --- /dev/null +++ b/src/utils/rawEditorUtils.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest' +import type { VaultEntry } from '../types' +import { buildRawEditorBaseItems, detectYamlError, extractWikilinkQuery, getRawEditorDropdownPosition, replaceActiveWikilinkQuery } from './rawEditorUtils' + +describe('extractWikilinkQuery', () => { + it('returns null when no [[ trigger', () => { + expect(extractWikilinkQuery('hello world', 5)).toBeNull() + }) + + it('returns empty string immediately after [[', () => { + const text = 'see [[' + expect(extractWikilinkQuery(text, text.length)).toBe('') + }) + + it('returns query after [[', () => { + const text = 'see [[Proj' + expect(extractWikilinkQuery(text, text.length)).toBe('Proj') + }) + + it('returns null when ]] closes the link', () => { + const text = '[[Proj]]' + expect(extractWikilinkQuery(text, text.length)).toBeNull() + }) + + it('returns null when newline is in query', () => { + const text = '[[Proj\ncontinued' + expect(extractWikilinkQuery(text, text.length)).toBeNull() + }) + + it('handles cursor before end of text', () => { + const text = '[[Proj after' + expect(extractWikilinkQuery(text, 6)).toBe('Proj') + }) +}) + +describe('replaceActiveWikilinkQuery', () => { + it('replaces the active wikilink query with the canonical target', () => { + expect(replaceActiveWikilinkQuery('See [[Proj', 10, 'projects/alpha')).toEqual({ + text: 'See [[projects/alpha]]', + cursor: 22, + }) + }) + + it('preserves text after the cursor', () => { + expect(replaceActiveWikilinkQuery('See [[Proj today', 10, 'projects/alpha')).toEqual({ + text: 'See [[projects/alpha]] today', + cursor: 22, + }) + }) + + it('returns null when no active wikilink trigger exists', () => { + expect(replaceActiveWikilinkQuery('See Proj', 8, 'projects/alpha')).toBeNull() + }) +}) + +describe('detectYamlError', () => { + it('returns null for content without frontmatter', () => { + expect(detectYamlError('# Title\n\nSome content.')).toBeNull() + }) + + it('returns null for valid frontmatter', () => { + expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull() + }) + + it('returns error for unclosed frontmatter', () => { + const error = detectYamlError('---\ntitle: My Note\n\n# Title') + expect(error).toContain('Unclosed frontmatter') + }) + + it('returns error for tab indentation in frontmatter', () => { + const error = detectYamlError('---\n\ttitle: My Note\n---\n') + expect(error).toContain('tab indentation') + }) + + it('returns null for content not starting with ---', () => { + expect(detectYamlError('Not frontmatter')).toBeNull() + }) +}) + +describe('buildRawEditorBaseItems', () => { + it('includes filename aliases and deduplicates entries by path', () => { + expect(buildRawEditorBaseItems([ + { + title: 'Project Alpha', + aliases: ['Alpha'], + filename: 'project-alpha.md', + isA: 'Project', + path: 'projects/project-alpha.md', + }, + { + title: 'Project Alpha', + aliases: ['Ignored'], + filename: 'project-alpha.md', + isA: 'Project', + path: 'projects/project-alpha.md', + }, + ] as VaultEntry[])).toEqual([ + { + title: 'Project Alpha', + aliases: ['project-alpha', 'Alpha'], + group: 'Project', + entryTitle: 'Project Alpha', + path: 'projects/project-alpha.md', + }, + ]) + }) +}) + +describe('getRawEditorDropdownPosition', () => { + it('renders above the cursor when there is not enough space below', () => { + expect(getRawEditorDropdownPosition( + { caretTop: 500, caretLeft: 900, selectedIndex: 0, items: [] }, + 200, + { innerHeight: 640, innerWidth: 1000 }, + )).toEqual({ top: 276, left: 740 }) + }) +}) diff --git a/src/utils/rawEditorUtils.ts b/src/utils/rawEditorUtils.ts index 7f31e110..1119e31e 100644 --- a/src/utils/rawEditorUtils.ts +++ b/src/utils/rawEditorUtils.ts @@ -1,3 +1,33 @@ +import type { EditorView } from '@codemirror/view' +import { attachClickHandlers, enrichSuggestionItems } from './suggestionEnrichment' +import { deduplicateByPath, preFilterWikilinks } from './wikilinkSuggestions' +import type { VaultEntry } from '../types' +import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu' + +export interface RawEditorBaseItem { + title: string + aliases: string[] + group: string + entryTitle: string + path: string +} + +export interface RawEditorAutocompleteState { + caretTop: number + caretLeft: number + selectedIndex: number + items: WikilinkSuggestionItem[] +} + +interface RawEditorAutocompleteParams { + view: EditorView + baseItems: RawEditorBaseItem[] + query: string + typeEntryMap: Record + onInsertTarget: (target: string) => void + vaultPath: string +} + /** Extract the wikilink query that the user is currently typing after [[ */ export function extractWikilinkQuery(text: string, cursor: number): string | null { const before = text.slice(0, cursor) @@ -9,6 +39,75 @@ export function extractWikilinkQuery(text: string, cursor: number): string | nul return afterTrigger } +export function replaceActiveWikilinkQuery( + text: string, + cursor: number, + target: string, +): { text: string; cursor: number } | null { + const before = text.slice(0, cursor) + const triggerIdx = before.lastIndexOf('[[') + if (triggerIdx === -1) return null + const after = text.slice(cursor) + return { + text: `${text.slice(0, triggerIdx)}[[${target}]]${after}`, + cursor: triggerIdx + target.length + 4, + } +} + +export function buildRawEditorBaseItems(entries: VaultEntry[]): RawEditorBaseItem[] { + return deduplicateByPath(entries.map(entry => ({ + title: entry.title, + aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])], + group: entry.isA || 'Note', + entryTitle: entry.title, + path: entry.path, + }))) +} + +export function getRawEditorCursorCoords(view: EditorView): { top: number; left: number } | null { + const pos = view.state.selection.main.head + const coords = view.coordsAtPos(pos) + if (!coords) return null + return { top: coords.bottom, left: coords.left } +} + +export function buildRawEditorAutocompleteState({ + view, + baseItems, + query, + typeEntryMap, + onInsertTarget, + vaultPath, +}: RawEditorAutocompleteParams): RawEditorAutocompleteState | null { + const coords = getRawEditorCursorCoords(view) + if (!coords) return null + + const candidates = preFilterWikilinks(baseItems, query) + const withHandlers = attachClickHandlers(candidates, onInsertTarget, vaultPath) + const items = enrichSuggestionItems(withHandlers, query, typeEntryMap) + + return { + caretTop: coords.top, + caretLeft: coords.left, + selectedIndex: 0, + items, + } +} + +export function getRawEditorDropdownPosition( + autocomplete: RawEditorAutocompleteState | null, + maxHeight: number, + viewport: { innerHeight: number; innerWidth: number }, +): { top: number; left: number } { + if (!autocomplete) return { top: 0, left: 0 } + + const dropdownBelow = autocomplete.caretTop + 20 + maxHeight <= viewport.innerHeight + return { + top: dropdownBelow ? autocomplete.caretTop + 4 : autocomplete.caretTop - maxHeight - 24, + left: Math.min(autocomplete.caretLeft, viewport.innerWidth - 260), + } +} + /** Basic YAML frontmatter structural checks. */ export function detectYamlError(content: string): string | null { if (!content.startsWith('---')) return null diff --git a/src/utils/suggestionEnrichment.test.ts b/src/utils/suggestionEnrichment.test.ts index 460ce116..c3bf3075 100644 --- a/src/utils/suggestionEnrichment.test.ts +++ b/src/utils/suggestionEnrichment.test.ts @@ -32,9 +32,9 @@ describe('attachClickHandlers', () => { expect(result).toHaveLength(2) result[0].onItemClick() - expect(insertWikilink).toHaveBeenCalledWith('a|Note A') + expect(insertWikilink).toHaveBeenCalledWith('a') result[1].onItemClick() - expect(insertWikilink).toHaveBeenCalledWith('b|Note B') + expect(insertWikilink).toHaveBeenCalledWith('b') }) it('preserves all original properties', () => { @@ -55,13 +55,13 @@ describe('attachClickHandlers', () => { const result = attachClickHandlers(candidates, insertWikilink, vaultPath) result[0].onItemClick() - expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack|ADR 001') + expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack') }) - it('omits pipe display when title matches path stem', () => { + it('omits any default alias even when the title differs from the path stem', () => { const insertWikilink = vi.fn() const candidates = [ - { title: 'roadmap', aliases: [], group: 'Note', entryTitle: 'roadmap', path: '/vault/roadmap.md' }, + { title: 'Roadmap', aliases: [], group: 'Note', entryTitle: 'Roadmap', path: '/vault/roadmap.md' }, ] const result = attachClickHandlers(candidates, insertWikilink, vaultPath) diff --git a/src/utils/suggestionEnrichment.ts b/src/utils/suggestionEnrichment.ts index 3c715634..b72f21db 100644 --- a/src/utils/suggestionEnrichment.ts +++ b/src/utils/suggestionEnrichment.ts @@ -17,17 +17,14 @@ interface BaseSuggestionItem { path: string } -/** 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. */ +/** Build the canonical wikilink target: vault-relative path stem without a default alias. */ function buildTarget(item: BaseSuggestionItem, vaultPath: string): string { - const stem = relativePathStem(item.path, vaultPath) - return stem === item.entryTitle ? stem : `${stem}|${item.entryTitle}` + return relativePathStem(item.path, vaultPath) } /** Add onItemClick to raw suggestion candidates. - * Always inserts the vault-relative path as the wikilink target - * so links are unambiguous and work across subfolders. */ + * Always inserts the canonical vault-relative path target so links are + * unambiguous and remain stable across renames. */ export function attachClickHandlers( candidates: BaseSuggestionItem[], insertWikilink: (target: string) => void, diff --git a/src/utils/wikilink.test.ts b/src/utils/wikilink.test.ts index f170733a..4fdf419f 100644 --- a/src/utils/wikilink.test.ts +++ b/src/utils/wikilink.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect } from 'vitest' import type { VaultEntry } from '../types' -import { wikilinkTarget, wikilinkDisplay, resolveEntry, relativePathStem } from './wikilink' +import { + wikilinkTarget, + wikilinkDisplay, + resolveEntry, + relativePathStem, + slugifyWikilinkTarget, + canonicalWikilinkTargetForEntry, + canonicalWikilinkTargetForTitle, + formatWikilinkRef, +} from './wikilink' function makeEntry(overrides: Partial): VaultEntry { return { @@ -124,3 +133,42 @@ describe('relativePathStem', () => { expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note') }) }) + +describe('slugifyWikilinkTarget', () => { + it('slugifies a human title to a canonical target', () => { + expect(slugifyWikilinkTarget('Weekly Review')).toBe('weekly-review') + }) + + it('falls back to untitled when the title has no slug characters', () => { + expect(slugifyWikilinkTarget('+++')).toBe('untitled') + }) +}) + +describe('canonicalWikilinkTargetForEntry', () => { + it('returns a vault-relative path stem', () => { + const entry = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha' }) + expect(canonicalWikilinkTargetForEntry(entry, '/vault')).toBe('projects/alpha') + }) +}) + +describe('canonicalWikilinkTargetForTitle', () => { + const project = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha Project' }) + + it('resolves an existing entry to its canonical path target', () => { + expect(canonicalWikilinkTargetForTitle('Alpha Project', [project], '/vault')).toBe('projects/alpha') + }) + + it('keeps a canonical path input canonical', () => { + expect(canonicalWikilinkTargetForTitle('projects/alpha', [project], '/vault')).toBe('projects/alpha') + }) + + it('falls back to a slug for a newly created note title', () => { + expect(canonicalWikilinkTargetForTitle('Brand New Note', [], '/vault')).toBe('brand-new-note') + }) +}) + +describe('formatWikilinkRef', () => { + it('wraps a canonical target in wikilink syntax', () => { + expect(formatWikilinkRef('projects/alpha')).toBe('[[projects/alpha]]') + }) +}) diff --git a/src/utils/wikilink.ts b/src/utils/wikilink.ts index beab91af..9551f41a 100644 --- a/src/utils/wikilink.ts +++ b/src/utils/wikilink.ts @@ -27,6 +27,86 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin return filename.replace(/\.md$/, '') } +/** Slugify a human-readable title into the canonical wikilink filename stem. */ +export function slugifyWikilinkTarget(title: string): string { + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + return slug || 'untitled' +} + +/** Build the canonical wikilink target for a vault entry. */ +export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: string): string { + return relativePathStem(entry.path, vaultPath) +} + +/** Resolve a user-facing title/path input to the canonical wikilink target. */ +export function canonicalWikilinkTargetForTitle( + titleOrTarget: string, + entries: VaultEntry[], + vaultPath: string, +): string { + const trimmed = titleOrTarget.trim() + const resolved = resolveEntry(entries, trimmed) + return resolved + ? canonicalWikilinkTargetForEntry(resolved, vaultPath) + : trimmed.includes('/') + ? trimmed.replace(/^\/+/, '').replace(/\.md$/, '') + : slugifyWikilinkTarget(trimmed) +} + +/** Wrap a target in wikilink syntax. */ +export function formatWikilinkRef(target: string): string { + return `[[${target}]]` +} + +interface ResolutionKey { + exactTarget: string + lastSegment: string + pathSuffix: string | null + humanizedTarget: string | null +} + +function buildResolutionKey(rawTarget: string): ResolutionKey { + const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget + const normalizedTarget = exactTarget.toLowerCase() + const lastSegment = exactTarget.includes('/') ? (exactTarget.split('/').pop() ?? exactTarget).toLowerCase() : normalizedTarget + const humanizedTarget = lastSegment.replace(/-/g, ' ') + + return { + exactTarget: normalizedTarget, + lastSegment, + pathSuffix: exactTarget.includes('/') ? `/${normalizedTarget}.md` : null, + humanizedTarget: humanizedTarget === normalizedTarget ? null : humanizedTarget, + } +} + +function findEntryByPathSuffix(entries: VaultEntry[], pathSuffix: string | null): VaultEntry | undefined { + if (!pathSuffix) return undefined + return entries.find(entry => entry.path.toLowerCase().endsWith(pathSuffix)) +} + +function findEntryByFilename(entries: VaultEntry[], { exactTarget, lastSegment }: ResolutionKey): VaultEntry | undefined { + return entries.find((entry) => { + const stem = entry.filename.replace(/\.md$/, '').toLowerCase() + return stem === exactTarget || stem === lastSegment + }) +} + +function findEntryByAlias(entries: VaultEntry[], exactTarget: string): VaultEntry | undefined { + return entries.find(entry => entry.aliases.some(alias => alias.toLowerCase() === exactTarget)) +} + +function findEntryByTitle(entries: VaultEntry[], exactTarget: string, lastSegment: string): VaultEntry | undefined { + return entries.find((entry) => { + const lowerTitle = entry.title.toLowerCase() + return lowerTitle === exactTarget || lowerTitle === lastSegment + }) +} + +function findEntryByHumanizedTitle(entries: VaultEntry[], humanizedTarget: string | null): VaultEntry | undefined { + if (!humanizedTarget) return undefined + return entries.find(entry => entry.title.toLowerCase() === humanizedTarget) +} + /** * Unified wikilink resolution: find the VaultEntry matching a wikilink target. * Handles pipe syntax, case-insensitive matching. @@ -38,37 +118,12 @@ export function relativePathStem(absolutePath: string, vaultPath: string): strin * 5. Humanized title match (kebab-case → words) */ export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined { - const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget - const keyLower = key.toLowerCase() - const lastSegment = key.includes('/') ? (key.split('/').pop() ?? key) : key - const lastSegmentLower = lastSegment.toLowerCase() - const asWords = lastSegmentLower.replace(/-/g, ' ') - const pathSuffix = key.includes('/') ? '/' + key.toLowerCase() + '.md' : null - - // Pass 1: path-suffix match (for subfolder targets like "docs/adr/0031-foo") - if (pathSuffix) { - for (const e of entries) { - if (e.path.toLowerCase().endsWith(pathSuffix)) return e - } - } - // Pass 2: filename stem (strongest for flat vault) - for (const e of entries) { - const stem = e.filename.replace(/\.md$/, '').toLowerCase() - if (stem === keyLower || stem === lastSegmentLower) return e - } - // Pass 3: alias - for (const e of entries) { - if (e.aliases.some(a => a.toLowerCase() === keyLower)) return e - } - // Pass 4: exact title - for (const e of entries) { - if (e.title.toLowerCase() === keyLower || e.title.toLowerCase() === lastSegmentLower) return e - } - // Pass 5: humanized title (kebab-case → words) - if (asWords !== keyLower) { - for (const e of entries) { - if (e.title.toLowerCase() === asWords) return e - } - } - return undefined + const resolutionKey = buildResolutionKey(rawTarget) + return ( + findEntryByPathSuffix(entries, resolutionKey.pathSuffix) + ?? findEntryByFilename(entries, resolutionKey) + ?? findEntryByAlias(entries, resolutionKey.exactTarget) + ?? findEntryByTitle(entries, resolutionKey.exactTarget, resolutionKey.lastSegment) + ?? findEntryByHumanizedTitle(entries, resolutionKey.humanizedTarget) + ) }