From 968c4d05a98858be454054f546a23bf47c7e83d4 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 10 Apr 2026 17:59:21 +0200 Subject: [PATCH] feat: add breadcrumb filename rename controls --- src-tauri/src/commands/vault.rs | 11 + src-tauri/src/lib.rs | 1 + src-tauri/src/vault/mod.rs | 4 +- src-tauri/src/vault/rename.rs | 222 ++++++- src/App.tsx | 8 + src/components/BreadcrumbBar.test.tsx | 73 +++ src/components/BreadcrumbBar.tsx | 560 +++++++++++++----- src/components/Editor.tsx | 5 +- .../editor-content/EditorContentLayout.tsx | 7 + .../editor-content/useEditorContentModel.ts | 70 ++- src/hooks/useNoteActions.ts | 1 + src/hooks/useNoteRename.test.ts | 49 ++ src/hooks/useNoteRename.ts | 93 ++- src/mock-tauri/vault-api.ts | 10 + .../smoke/breadcrumb-filename-rename.spec.ts | 107 ++++ vite.config.ts | 415 +++++++------ 16 files changed, 1261 insertions(+), 375 deletions(-) create mode 100644 tests/smoke/breadcrumb-filename-rename.spec.ts diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs index a2be3ea0..acad69e7 100644 --- a/src-tauri/src/commands/vault.rs +++ b/src-tauri/src/commands/vault.rs @@ -45,6 +45,17 @@ pub fn rename_note( vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref()) } +#[tauri::command] +pub fn rename_note_filename( + vault_path: String, + old_path: String, + new_filename_stem: String, +) -> Result { + let vault_path = expand_tilde(&vault_path); + let old_path = expand_tilde(&old_path); + vault::rename_note_filename(&vault_path, &old_path, &new_filename_stem) +} + #[tauri::command] pub fn auto_rename_untitled( vault_path: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ec24727c..62db9d4d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -144,6 +144,7 @@ pub fn run() { commands::update_frontmatter, commands::delete_frontmatter_property, commands::rename_note, + commands::rename_note_filename, commands::auto_rename_untitled, commands::detect_renames, commands::update_wikilinks_for_renames, diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 581919e0..8cf669b1 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -20,8 +20,8 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul pub use image::{copy_image_to_vault, save_image}; pub use migration::migrate_is_a_to_type; pub use rename::{ - auto_rename_untitled, detect_renames, rename_note, update_wikilinks_for_renames, - DetectedRename, RenameResult, + auto_rename_untitled, detect_renames, rename_note, rename_note_filename, + update_wikilinks_for_renames, DetectedRename, RenameResult, }; pub use title_sync::{sync_title_on_open, SyncAction}; pub use trash::{batch_delete_notes, delete_note}; diff --git a/src-tauri/src/vault/rename.rs b/src-tauri/src/vault/rename.rs index 769f3ef0..88081b61 100644 --- a/src-tauri/src/vault/rename.rs +++ b/src-tauri/src/vault/rename.rs @@ -28,13 +28,17 @@ pub(super) fn title_to_slug(title: &str) -> String { .join("-") } -/// Build a regex that matches wiki links referencing old title or path stem. -fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option { - let pattern_str = format!( - r"\[\[(?:{}|{})(\|[^\]]*?)?\]\]", - regex::escape(old_title), - regex::escape(old_path_stem), - ); +/// Build a regex that matches wiki links referencing any of the provided targets. +fn build_wikilink_pattern(targets: &[&str]) -> Option { + let escaped_targets: Vec = targets + .iter() + .filter(|target| !target.is_empty()) + .map(|target| regex::escape(target)) + .collect(); + if escaped_targets.is_empty() { + return None; + } + let pattern_str = format!(r"\[\[(?:{})(\|[^\]]*?)?\]\]", escaped_targets.join("|")); Regex::new(&pattern_str).ok() } @@ -81,45 +85,76 @@ fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec usize { - let re = match build_wikilink_pattern(params.old_title, params.old_path_stem) { + let re = match build_wikilink_pattern(&[params.old_title, params.old_path_stem]) { Some(r) => r, None => return 0, }; + replace_wikilinks_in_files( + collect_md_files(params.vault_path, params.exclude_path), + &re, + params.new_title, + ) +} - let files = collect_md_files(params.vault_path, params.exclude_path); +fn replace_wikilinks_in_files( + files: Vec, + re: &Regex, + replacement: &str, +) -> usize { files .iter() - .filter(|path| { - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(_) => return false, - }; - match replace_wikilinks_in_content(&content, &re, params.new_title) { - Some(new_content) => fs::write(path, &new_content).is_ok(), - None => false, - } - }) + .filter(|path| rewrite_wikilinks_in_file(path, re, replacement)) .count() } +fn rewrite_wikilinks_in_file(path: &Path, re: &Regex, replacement: &str) -> bool { + let Ok(content) = fs::read_to_string(path) else { + return false; + }; + + let Some(new_content) = replace_wikilinks_in_content(&content, re, replacement) else { + return false; + }; + + 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") { return None; } let fm = content[4..].split("\n---").next()?; - for line in fm.lines() { - let t = line.trim_start(); - for prefix in &["title:", "\"title\":"] { - if let Some(rest) = t.strip_prefix(prefix) { - let val = rest.trim().trim_matches('"').trim_matches('\''); - if !val.is_empty() { - return Some(val.to_string()); - } - } - } - } - None + fm.lines() + .map(str::trim_start) + .find_map(extract_title_value_from_frontmatter_line) +} + +fn extract_title_value_from_frontmatter_line(line: &str) -> Option { + ["title:", "\"title\":"] + .iter() + .find_map(|prefix| line.strip_prefix(prefix)) + .map(str::trim) + .map(|value| value.trim_matches('"').trim_matches('\'')) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) } /// Update the `title:` frontmatter field in content. @@ -168,6 +203,22 @@ fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::pat } } +fn normalize_filename_stem(new_filename_stem: &str) -> Result { + let trimmed = new_filename_stem.trim(); + let stem = trimmed.strip_suffix(".md").unwrap_or(trimmed).trim(); + if stem.is_empty() { + return Err("New filename cannot be empty".to_string()); + } + if is_invalid_filename_stem(stem) { + return Err("Invalid filename".to_string()); + } + Ok(stem.to_string()) +} + +fn is_invalid_filename_stem(stem: &str) -> bool { + stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\') +} + /// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault. /// /// When `old_title_hint` is provided it is used instead of extracting the title from @@ -251,6 +302,63 @@ pub fn rename_note( }) } +/// Rename only the file path stem while preserving title/frontmatter content. +pub fn rename_note_filename( + vault_path: &str, + old_path: &str, + new_filename_stem: &str, +) -> Result { + let vault = Path::new(vault_path); + let old_file = Path::new(old_path); + + if !old_file.exists() { + return Err(format!("File does not exist: {}", old_path)); + } + + let normalized_stem = normalize_filename_stem(new_filename_stem)?; + let old_filename = old_file + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let new_filename = format!("{}.md", normalized_stem); + + if old_filename == new_filename { + return Ok(RenameResult { + new_path: old_path.to_string(), + updated_files: 0, + }); + } + + let parent_dir = old_file + .parent() + .ok_or("Cannot determine parent directory")?; + let new_file = parent_dir.join(&new_filename); + if new_file.exists() && new_file != old_file { + return Err("A note with that name already exists".to_string()); + } + + fs::rename(old_file, &new_file).map_err(|e| { + format!( + "Failed to rename {} to {}: {}", + old_path, + new_file.to_string_lossy(), + e + ) + })?; + + let vault_prefix = format!("{}/", vault.to_string_lossy()); + 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); + + Ok(RenameResult { + new_path, + updated_files, + }) +} + /// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md"). fn is_untitled_filename(filename: &str) -> bool { let stem = filename.strip_suffix(".md").unwrap_or(filename); @@ -698,6 +806,58 @@ mod tests { assert!(vault.join("note/my-note.md").exists()); } + #[test] + fn test_rename_note_filename_preserves_title_and_updates_path_wikilinks() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file( + vault, + "note/project-kickoff.md", + "---\ntitle: Project Kickoff\ntype: Note\n---\n\n# Project Kickoff\n\nBody.\n", + ); + create_test_file( + vault, + "note/ref.md", + "# Ref\n\nSee [[note/project-kickoff]] and [[Project Kickoff]].\n", + ); + + let old_path = vault.join("note/project-kickoff.md"); + let result = rename_note_filename( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "manual-name", + ) + .unwrap(); + + assert!(result.new_path.ends_with("manual-name.md")); + assert!(!old_path.exists()); + + let renamed = fs::read_to_string(&result.new_path).unwrap(); + assert!(renamed.contains("title: Project Kickoff")); + assert!(renamed.contains("# Project Kickoff")); + + 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("[[note/project-kickoff]]")); + } + + #[test] + fn test_rename_note_filename_rejects_existing_destination() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/current.md", "# Current\n"); + create_test_file(vault, "note/manual-name.md", "# Existing\n"); + + let result = rename_note_filename( + vault.to_str().unwrap(), + vault.join("note/current.md").to_str().unwrap(), + "manual-name", + ); + + assert_eq!(result.unwrap_err(), "A note with that name already exists"); + } + #[test] fn test_rename_note_with_old_title_hint_updates_wikilinks() { // Simulates H1 sync: content already saved with new H1, but wikilinks still use old title. diff --git a/src/App.tsx b/src/App.tsx index 6f2721ca..829a92b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -288,6 +288,13 @@ function App() { replaceEntry: vault.replaceEntry, resolvedPath, }) + const handleFilenameRename = useCallback((path: string, newFilenameStem: string) => { + appSave.savePendingForPath(path) + .then(() => notes.handleRenameFilename(path, newFilenameStem, resolvedPath, vault.replaceEntry)) + .then(vault.loadModifiedFiles) + .catch((err) => console.error('Filename rename failed:', err)) + }, [appSave, notes, resolvedPath, vault]) + const aiActivity = useAiActivity({ onOpenNote: vaultBridge.openNoteByPath, onOpenTab: vaultBridge.openNoteByPath, @@ -701,6 +708,7 @@ function App() { onContentChange={appSave.handleContentChange} onSave={appSave.handleSave} onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync} + onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename} rawToggleRef={rawToggleRef} diffToggleRef={diffToggleRef} canGoBack={canGoBack} diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index c2120c95..bcc1d4f7 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -153,6 +153,79 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden') }) + + it('keeps the breadcrumb title visible when the separate title section is absent', () => { + const { container } = render( + , + ) + + expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden') + }) +}) + +describe('BreadcrumbBar — filename controls', () => { + it('shows the sync button when the filename diverges from the title slug', () => { + const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' } + render() + expect(screen.getByTestId('breadcrumb-sync-button')).toBeInTheDocument() + }) + + it('hides the sync button when the filename already matches the title slug', () => { + const entry = { ...baseEntry, title: 'Test Note', filename: 'test-note.md' } + render() + expect(screen.queryByTestId('breadcrumb-sync-button')).not.toBeInTheDocument() + }) + + it('clicking the sync button renames the file to the title slug', () => { + const onRenameFilename = vi.fn() + const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' } + render() + fireEvent.click(screen.getByTestId('breadcrumb-sync-button')) + expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'fresh-title') + }) + + it('lets keyboard users press Enter on the filename to start editing', () => { + render() + fireEvent.keyDown(screen.getByTestId('breadcrumb-filename-trigger'), { key: 'Enter' }) + expect(screen.getByTestId('breadcrumb-filename-input')).toHaveValue('test') + }) + + it('double-clicking the filename enters edit mode and Enter confirms the rename', () => { + const onRenameFilename = vi.fn() + render() + + fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger')) + const input = screen.getByTestId('breadcrumb-filename-input') + fireEvent.change(input, { target: { value: 'renamed-file' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-file') + }) + + it('pressing Escape while editing cancels the inline rename', () => { + const onRenameFilename = vi.fn() + render() + + fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger')) + const input = screen.getByTestId('breadcrumb-filename-input') + fireEvent.change(input, { target: { value: 'renamed-file' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + + expect(onRenameFilename).not.toHaveBeenCalled() + expect(screen.queryByTestId('breadcrumb-filename-input')).not.toBeInTheDocument() + }) + + it('blur confirms the inline rename when the value changed', () => { + const onRenameFilename = vi.fn() + render() + + fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger')) + const input = screen.getByTestId('breadcrumb-filename-input') + fireEvent.change(input, { target: { value: 'renamed-on-blur' } }) + fireEvent.blur(input) + + expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-on-blur') + }) }) describe('BreadcrumbBar — action buttons always right-aligned', () => { diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 76fb7afe..94f873d4 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -1,6 +1,9 @@ -import { memo } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react' import type { VaultEntry } from '../types' import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { MagnifyingGlass, GitBranch, @@ -14,8 +17,10 @@ import { ArrowUUpLeft, Star, CheckCircle, + ArrowsClockwise, } from '@phosphor-icons/react' import { NoteTitleIcon } from './NoteTitleIcon' +import { slugify } from '../hooks/useNoteCreation' interface BreadcrumbBarProps { entry: VaultEntry @@ -37,170 +42,451 @@ interface BreadcrumbBarProps { onDelete?: () => void onArchive?: () => void onUnarchive?: () => void + onRenameFilename?: (path: string, newFilenameStem: string) => void + showTitleSection?: boolean /** Ref for direct DOM manipulation — avoids re-render on scroll. */ barRef?: React.Ref } const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const -function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) { +function IconActionButton({ + title, + onClick, + className, + style, + disabled, + tabIndex, + children, + testId, +}: { + title: string + onClick?: () => void + className?: string + style?: CSSProperties + disabled?: boolean + tabIndex?: number + children: ReactNode + testId?: string +}) { return ( - + {children} + ) } -function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff, - rawMode, onToggleRaw, forceRawMode, - showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector, - onToggleFavorite, onToggleOrganized, onDelete, onArchive, onUnarchive, -}: Omit) { +function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) { return ( -
- - {onToggleOrganized && ( - - )} - - {showDiffToggle ? ( - - ) : ( - - )} - {!forceRawMode && } - - - {entry.archived ? ( - - ) : ( - - )} - - {inspectorCollapsed && ( - - )} - + + + + ) +} + +function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) { + return ( + + + + ) +} + +function OrganizedAction({ + organized, + onToggleOrganized, +}: { + organized: boolean + onToggleOrganized?: () => void +}) { + if (!onToggleOrganized) return null + return ( + + + + ) +} + +function SearchAction() { + return ( + + + + ) +} + +function DiffAction({ + showDiffToggle, + diffMode, + diffLoading, + onToggleDiff, +}: Pick) { + if (!showDiffToggle) { + return ( + + + + ) + } + + return ( + + + + ) +} + +function PlaceholderAction({ title, children }: { title: string; children: ReactNode }) { + return ( + + {children} + + ) +} + +function AIChatAction({ showAIChat, onToggleAIChat }: Pick) { + return ( + + + + ) +} + +function ArchiveAction({ + archived, + onArchive, + onUnarchive, +}: Pick & Pick) { + if (archived) { + return ( + + + + ) + } + + return ( + + + + ) +} + +function DeleteAction({ onDelete }: Pick) { + return ( + + + + ) +} + +function InspectorAction({ + inspectorCollapsed, + onToggleInspector, +}: Pick) { + if (!inspectorCollapsed) return null + return ( + + + + ) +} + +function normalizeFilenameStemInput(value: string): string { + const trimmed = value.trim() + return trimmed.replace(/\.md$/i, '').trim() +} + +function deriveSyncStem(entry: VaultEntry): string | null { + const expectedStem = slugify(entry.title.trim()) + const filenameStem = entry.filename.replace(/\.md$/, '') + if (!expectedStem || expectedStem === filenameStem) return null + return expectedStem +} + +function FilenameInput({ + inputRef, + draftStem, + onDraftStemChange, + onBlur, + onKeyDown, +}: { + inputRef: React.RefObject + draftStem: string + onDraftStemChange: (nextValue: string) => void + onBlur: () => void + onKeyDown: (event: KeyboardEvent) => void +}) { + return ( + onDraftStemChange(event.target.value)} + onBlur={onBlur} + onKeyDown={onKeyDown} + className="h-7 w-[180px] text-sm" + data-testid="breadcrumb-filename-input" + aria-label="Rename filename" + /> + ) +} + +function FilenameTrigger({ + entry, + filenameStem, + onStartEditing, +}: { + entry: VaultEntry + filenameStem: string + onStartEditing: () => void +}) { + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key !== 'Enter') return + event.preventDefault() + onStartEditing() + }, [onStartEditing]) + + return ( + + ) +} + +function SyncFilenameButton({ + entryPath, + syncStem, + onRenameFilename, +}: { + entryPath: string + syncStem: string | null + onRenameFilename?: (path: string, newFilenameStem: string) => void +}) { + if (!syncStem || !onRenameFilename) return null + return ( + + + + + + + Rename file to match title + + + + ) +} + +function FilenameDisplay({ + entry, + filenameStem, + syncStem, + onRenameFilename, + onStartEditing, +}: { + entry: VaultEntry + filenameStem: string + syncStem: string | null + onRenameFilename?: (path: string, newFilenameStem: string) => void + onStartEditing: () => void +}) { + return ( +
+ +
) } -function BreadcrumbTitle({ entry }: { entry: VaultEntry }) { +function FilenameCrumb({ entry, onRenameFilename }: Pick) { + const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename]) + const syncStem = useMemo(() => deriveSyncStem(entry), [entry]) + const [isEditing, setIsEditing] = useState(false) + const [draftStem, setDraftStem] = useState(filenameStem) + const inputRef = useRef(null) + + useEffect(() => { + if (!isEditing) return + inputRef.current?.focus() + inputRef.current?.select() + }, [isEditing]) + + const startEditing = useCallback(() => { + if (!onRenameFilename) return + setDraftStem(filenameStem) + setIsEditing(true) + }, [onRenameFilename, filenameStem]) + + const cancelEditing = useCallback(() => { + setDraftStem(filenameStem) + setIsEditing(false) + }, [filenameStem]) + + const submitRename = useCallback(() => { + const nextStem = normalizeFilenameStemInput(draftStem) + setIsEditing(false) + if (!nextStem || nextStem === filenameStem) return + onRenameFilename?.(entry.path, nextStem) + }, [draftStem, filenameStem, onRenameFilename, entry.path]) + + const handleInputKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + submitRename() + return + } + if (event.key === 'Escape') { + event.preventDefault() + cancelEditing() + } + }, [submitRename, cancelEditing]) + + if (isEditing) { + return ( + + ) + } + + return ( + + ) +} + +function BreadcrumbActions({ + entry, + showDiffToggle, + diffMode, + diffLoading, + onToggleDiff, + rawMode, + onToggleRaw, + forceRawMode, + showAIChat, + onToggleAIChat, + inspectorCollapsed, + onToggleInspector, + onToggleFavorite, + onToggleOrganized, + onDelete, + onArchive, + onUnarchive, +}: Omit) { + return ( +
+ + + + + {!forceRawMode && } + + + + + + + + + + +
+ ) +} + +function BreadcrumbTitle({ + entry, + onRenameFilename, +}: Pick) { const typeLabel = entry.isA ?? 'Note' - const filenameStem = entry.filename.replace(/\.md$/, '') return (
{typeLabel} - - - {filenameStem} - +
+ +
) } export const BreadcrumbBar = memo(function BreadcrumbBar({ - entry, barRef, ...actionProps + entry, + barRef, + onRenameFilename, + showTitleSection = true, + ...actionProps }: BreadcrumbBarProps) { // In raw/diff mode the title section is not rendered — always show title in breadcrumb. // Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect. - const titleAlwaysVisible = actionProps.rawMode || actionProps.diffMode + const titleAlwaysVisible = !showTitleSection || actionProps.rawMode || actionProps.diffMode return (
- +
diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 2f785f92..fceb6dd9 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -56,6 +56,8 @@ interface EditorProps { onSave?: () => void /** Called when the user edits the title in TitleField. */ onTitleSync?: (path: string, newTitle: string) => void + /** Called when the user explicitly renames the filename from the breadcrumb. */ + onRenameFilename?: (path: string, newFilenameStem: string) => void canGoBack?: boolean canGoForward?: boolean onGoBack?: () => void @@ -203,7 +205,7 @@ export const Editor = memo(function Editor(props: EditorProps) { showAIChat, onToggleAIChat, vaultPath, noteList, noteListFilter, onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote, - onContentChange, onSave, onTitleSync, + onContentChange, onSave, onTitleSync, onRenameFilename, onFileCreated, onFileModified, onVaultChanged, isConflicted, onKeepMine, onKeepTheirs, } = props @@ -255,6 +257,7 @@ export const Editor = memo(function Editor(props: EditorProps) { vaultPath={vaultPath} rawLatestContentRef={rawLatestContentRef} onTitleChange={onTitleSync} + onRenameFilename={onRenameFilename} isConflicted={isConflicted} onKeepMine={onKeepMine} onKeepTheirs={onKeepTheirs} diff --git a/src/components/editor-content/EditorContentLayout.tsx b/src/components/editor-content/EditorContentLayout.tsx index 29d50560..35c8a0f4 100644 --- a/src/components/editor-content/EditorContentLayout.tsx +++ b/src/components/editor-content/EditorContentLayout.tsx @@ -29,6 +29,7 @@ type BreadcrumbActions = Pick< | 'onDeleteNote' | 'onArchiveNote' | 'onUnarchiveNote' + | 'onRenameFilename' > function EditorLoadingSkeleton() { @@ -98,12 +99,14 @@ function ActiveTabBreadcrumb({ barRef, wordCount, path, + showTitleSection, actions, }: { activeTab: NonNullable barRef: React.RefObject wordCount: number path: string + showTitleSection: boolean actions: BreadcrumbActions }) { return ( @@ -111,6 +114,7 @@ function ActiveTabBreadcrumb({ entry={activeTab.entry} wordCount={wordCount} barRef={barRef} + showTitleSection={showTitleSection} showDiffToggle={actions.showDiffToggle} diffMode={actions.diffMode} diffLoading={actions.diffLoading} @@ -127,6 +131,7 @@ function ActiveTabBreadcrumb({ onDelete={bindPath(actions.onDeleteNote, path)} onArchive={bindPath(actions.onArchiveNote, path)} onUnarchive={bindPath(actions.onUnarchiveNote, path)} + onRenameFilename={actions.onRenameFilename} /> ) } @@ -316,6 +321,7 @@ export function EditorContentLayout(model: EditorContentModel) { barRef={breadcrumbBarRef} wordCount={wordCount} path={path} + showTitleSection={showTitleSection} actions={{ diffMode: model.diffMode, diffLoading: model.diffLoading, @@ -333,6 +339,7 @@ export function EditorContentLayout(model: EditorContentModel) { onDeleteNote: model.onDeleteNote, onArchiveNote: model.onArchiveNote, onUnarchiveNote: model.onUnarchiveNote, + onRenameFilename: model.onRenameFilename, }} /> onTitleChange?: (path: string, newTitle: string) => void + onRenameFilename?: (path: string, newFilenameStem: string) => void isConflicted?: boolean onKeepMine?: (path: string) => void onKeepTheirs?: (path: string) => void } +function useBreadcrumbTitleVisibility({ + showEditor, + showTitleSection, + path, + breadcrumbBarRef, + titleSectionRef, +}: { + showEditor: boolean + showTitleSection: boolean + path: string + breadcrumbBarRef: React.RefObject + titleSectionRef: React.RefObject +}) { + useEffect(() => { + if (!showEditor) return + + const bar = breadcrumbBarRef.current + const titleSection = titleSectionRef.current + if (!bar || !titleSection) return + + if (!showTitleSection) { + bar.setAttribute('data-title-hidden', '') + return () => { + bar.removeAttribute('data-title-hidden') + } + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) bar.removeAttribute('data-title-hidden') + else bar.setAttribute('data-title-hidden', '') + }, + { threshold: 0 }, + ) + observer.observe(titleSection) + return () => { + observer.disconnect() + bar.removeAttribute('data-title-hidden') + } + }, [path, showEditor, showTitleSection, breadcrumbBarRef, titleSectionRef]) +} + export function useEditorContentModel(props: EditorContentProps) { const { activeTab, @@ -77,26 +120,13 @@ export function useEditorContentModel(props: EditorContentProps) { const titleSectionRef = useRef(null) const breadcrumbBarRef = useRef(null) - useEffect(() => { - if (!showEditor) return - - const bar = breadcrumbBarRef.current - const titleSection = titleSectionRef.current - if (!bar || !titleSection) return - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) bar.removeAttribute('data-title-hidden') - else bar.setAttribute('data-title-hidden', '') - }, - { threshold: 0 }, - ) - observer.observe(titleSection) - return () => { - observer.disconnect() - bar.removeAttribute('data-title-hidden') - } - }, [path, showEditor]) + useBreadcrumbTitleVisibility({ + showEditor, + showTitleSection, + path, + breadcrumbBarRef, + titleSectionRef, + }) return { ...props, diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index bbe38463..a1d359e4 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -148,5 +148,6 @@ export function useNoteActions(config: NoteActionsConfig) { config.onFrontmatterPersisted?.() }, [runFrontmatterOp, config]), handleRenameNote: rename.handleRenameNote, + handleRenameFilename: rename.handleRenameFilename, } } diff --git a/src/hooks/useNoteRename.test.ts b/src/hooks/useNoteRename.test.ts index 25f8f29a..2e81e2af 100644 --- a/src/hooks/useNoteRename.test.ts +++ b/src/hooks/useNoteRename.test.ts @@ -167,4 +167,53 @@ describe('useNoteRename hook', () => { expect(handleSwitchTab).toHaveBeenCalledWith('/vault/new.md') }) + + it('handleRenameFilename renames the file while preserving the existing title', async () => { + const entry = makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md', title: 'Project Kickoff' }) + vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { + if (cmd === 'rename_note_filename') return { new_path: '/vault/manual-name.md', updated_files: 1 } + if (cmd === 'get_note_content') return '# Project Kickoff\n' + return '' + }) + + const { result } = renderHook(() => useNoteRename( + { entries: [entry], setToastMessage }, + { tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent }, + )) + + const onEntryRenamed = vi.fn() + await act(async () => { + await result.current.handleRenameFilename('/vault/old-name.md', 'manual-name', '/vault', onEntryRenamed) + }) + + expect(mockInvoke).toHaveBeenCalledWith('rename_note_filename', expect.objectContaining({ + old_path: '/vault/old-name.md', + new_filename_stem: 'manual-name', + })) + expect(onEntryRenamed).toHaveBeenCalledWith( + '/vault/old-name.md', + expect.objectContaining({ + path: '/vault/manual-name.md', + filename: 'manual-name.md', + title: 'Project Kickoff', + }), + '# Project Kickoff\n', + ) + expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 1 wiki link') + }) + + it('handleRenameFilename surfaces backend conflict errors', async () => { + vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('A note with that name already exists')) + + const { result } = renderHook(() => useNoteRename( + { entries: [makeEntry({ path: '/vault/old-name.md', filename: 'old-name.md' })], setToastMessage }, + { tabs: [], setTabs, activeTabPathRef, handleSwitchTab, updateTabContent }, + )) + + await act(async () => { + await result.current.handleRenameFilename('/vault/old-name.md', 'manual-name', '/vault', vi.fn()) + }) + + expect(setToastMessage).toHaveBeenCalledWith('A note with that name already exists') + }) }) diff --git a/src/hooks/useNoteRename.ts b/src/hooks/useNoteRename.ts index aef96e8a..dd683397 100644 --- a/src/hooks/useNoteRename.ts +++ b/src/hooks/useNoteRename.ts @@ -28,11 +28,35 @@ export async function performRename( return mockInvoke('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null }) } +export async function performFilenameRename( + path: string, + newFilenameStem: string, + vaultPath: string, +): Promise { + if (isTauri()) { + return invoke('rename_note_filename', { + vaultPath, + oldPath: path, + newFilenameStem, + }) + } + return mockInvoke('rename_note_filename', { + vault_path: vaultPath, + old_path: path, + new_filename_stem: newFilenameStem, + }) +} + export function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry { const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') return { ...entry, path: newPath, filename: `${slug}.md`, title: newTitle } } +export function buildFilenameRenamedEntry(entry: VaultEntry, newPath: string): VaultEntry { + const filename = newPath.split('/').pop() ?? entry.filename + return { ...entry, path: newPath, filename } +} + export async function loadNoteContent(path: string): Promise { return isTauri() ? invoke('get_note_content', { path }) @@ -61,6 +85,18 @@ interface Tab { content: string } +function renameErrorMessage(err: unknown): string { + const message = typeof err === 'string' + ? err.trim() + : err instanceof Error + ? err.message.trim() + : '' + if (message === 'A note with that name already exists' || message === 'Invalid filename') { + return message + } + return 'Failed to rename note' +} + export interface NoteRenameConfig { entries: VaultEntry[] setToastMessage: (msg: string | null) => void @@ -82,6 +118,23 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps) // eslint-disable-next-line react-hooks/refs tabsRef.current = tabDeps.tabs + const applyRenameResult = useCallback(async ( + oldPath: string, + result: RenameResult, + buildEntry: (entry: VaultEntry | undefined, newPath: string) => VaultEntry, + onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void, + ) => { + const entry = entries.find((item) => item.path === oldPath) + const newContent = await loadNoteContent(result.new_path) + const newEntry = buildEntry(entry, result.new_path) + const otherTabPaths = tabsRef.current.filter((tab) => tab.entry.path !== oldPath).map((tab) => tab.entry.path) + setTabs((prev) => prev.map((tab) => tab.entry.path === oldPath ? { entry: newEntry, content: newContent } : tab)) + if (activeTabPathRef.current === oldPath) handleSwitchTab(result.new_path) + onEntryRenamed(oldPath, newEntry, newContent) + await reloadTabsAfterRename(otherTabPaths, updateTabContent) + setToastMessage(renameToastMessage(result.updated_files)) + }, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, setToastMessage]) + const handleRenameNote = useCallback(async ( path: string, newTitle: string, vaultPath: string, onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void, @@ -89,19 +142,37 @@ export function useNoteRename(config: NoteRenameConfig, tabDeps: RenameTabDeps) try { const entry = entries.find((e) => e.path === path) const result = await performRename(path, newTitle, vaultPath, entry?.title) - const newContent = await loadNoteContent(result.new_path) - const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path) - const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path).map(t => t.entry.path) - setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t)) - if (activeTabPathRef.current === path) handleSwitchTab(result.new_path) - onEntryRenamed(path, newEntry, newContent) - await reloadTabsAfterRename(otherTabPaths, updateTabContent) - setToastMessage(renameToastMessage(result.updated_files)) + await applyRenameResult( + path, + result, + (currentEntry, newPath) => buildRenamedEntry(currentEntry ?? {} as VaultEntry, newTitle, newPath), + onEntryRenamed, + ) } catch (err) { console.error('Failed to rename note:', err) - setToastMessage('Failed to rename note') + setToastMessage(renameErrorMessage(err)) } - }, [entries, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent, setToastMessage]) + }, [entries, applyRenameResult, setToastMessage]) - return { handleRenameNote, tabsRef } + const handleRenameFilename = useCallback(async ( + path: string, + newFilenameStem: string, + vaultPath: string, + onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void, + ) => { + try { + const result = await performFilenameRename(path, newFilenameStem, vaultPath) + await applyRenameResult( + path, + result, + (currentEntry, newPath) => buildFilenameRenamedEntry(currentEntry ?? {} as VaultEntry, newPath), + onEntryRenamed, + ) + } catch (err) { + console.error('Failed to rename note filename:', err) + setToastMessage(renameErrorMessage(err)) + } + }, [applyRenameResult, setToastMessage]) + + return { handleRenameNote, handleRenameFilename, tabsRef } } diff --git a/src/mock-tauri/vault-api.ts b/src/mock-tauri/vault-api.ts index fef5c5af..a23d9d49 100644 --- a/src/mock-tauri/vault-api.ts +++ b/src/mock-tauri/vault-api.ts @@ -46,6 +46,16 @@ const VAULT_API_COMMANDS: Record) => Vaul args.path ? { url: '/api/vault/save', method: 'POST', body: { path: args.path, content: args.content } } : null, rename_note: (args) => args.old_path ? { url: '/api/vault/rename', method: 'POST', body: { vault_path: args.vault_path, old_path: args.old_path, new_title: args.new_title } } : null, + rename_note_filename: (args) => + args.old_path ? { + url: '/api/vault/rename-filename', + method: 'POST', + body: { + vault_path: args.vault_path, + old_path: args.old_path, + new_filename_stem: args.new_filename_stem, + }, + } : null, delete_note: (args) => args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null, search_vault: (args) => { diff --git a/tests/smoke/breadcrumb-filename-rename.spec.ts b/tests/smoke/breadcrumb-filename-rename.spec.ts new file mode 100644 index 00000000..c4e318b1 --- /dev/null +++ b/tests/smoke/breadcrumb-filename-rename.spec.ts @@ -0,0 +1,107 @@ +import fs from 'fs' +import path from 'path' +import { test, expect, type Page } from '@playwright/test' +import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' +import { executeCommand, openCommandPalette } from './helpers' + +let tempVaultDir: string + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVault(page, tempVaultDir) +}) + +test.afterEach(async () => { + removeFixtureVaultCopy(tempVaultDir) +}) + +async function openNote(page: Page, title: string) { + const noteList = page.locator('[data-testid="note-list-container"]') + await noteList.getByText(title, { exact: true }).click() +} + +async function openRawMode(page: Page) { + await openCommandPalette(page) + await executeCommand(page, 'Toggle Raw') + await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 }) +} + +async function openBlockNoteMode(page: Page) { + await openCommandPalette(page) + await executeCommand(page, 'Toggle Raw') + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) +} + +async function getRawEditorContent(page: Page): Promise { + return page.evaluate(() => { + const el = document.querySelector('.cm-content') + if (!el) return '' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const view = (el as any).cmTile?.view + if (view) return view.state.doc.toString() as string + return el.textContent ?? '' + }) +} + +async function setRawEditorContent(page: Page, content: string) { + await page.evaluate((newContent) => { + const el = document.querySelector('.cm-content') + if (!el) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const view = (el as any).cmTile?.view + if (!view) return + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: newContent }, + }) + }, content) +} + +test('breadcrumb sync button and inline rename keep the file path aligned with H1 title changes', async ({ page }) => { + const syncedPath = path.join(tempVaultDir, 'note', 'breadcrumb-sync-target.md') + const manuallyRenamedPath = path.join(tempVaultDir, 'note', 'manual-breadcrumb-name.md') + const originalPath = path.join(tempVaultDir, 'note', 'note-b.md') + const updatedTitle = 'Breadcrumb Sync Target' + + await openNote(page, 'Note B') + await openRawMode(page) + + const rawContent = await getRawEditorContent(page) + expect(rawContent).toContain('# Note B') + + await setRawEditorContent(page, rawContent.replace('# Note B', `# ${updatedTitle}`)) + await page.keyboard.press('Meta+s') + await openBlockNoteMode(page) + + await expect(page.getByRole('heading', { name: updatedTitle, level: 1 })).toBeVisible({ timeout: 5_000 }) + await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('note-b') + await expect(page.getByTestId('breadcrumb-sync-button')).toBeVisible() + + await page.getByTestId('breadcrumb-sync-button').click() + await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5_000 }) + await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('breadcrumb-sync-target') + await expect(page.getByTestId('breadcrumb-sync-button')).toHaveCount(0) + + await expect.poll(() => fs.existsSync(originalPath)).toBe(false) + await expect.poll(() => fs.existsSync(syncedPath)).toBe(true) + + await page.getByTestId('breadcrumb-filename-trigger').focus() + await page.keyboard.press('Enter') + const firstInput = page.getByTestId('breadcrumb-filename-input') + await expect(firstInput).toHaveValue('breadcrumb-sync-target') + await page.keyboard.press('Escape') + await expect(page.getByTestId('breadcrumb-filename-input')).toHaveCount(0) + + await page.getByTestId('breadcrumb-filename-trigger').dblclick() + const renameInput = page.getByTestId('breadcrumb-filename-input') + await renameInput.fill('manual-breadcrumb-name') + await renameInput.press('Enter') + + await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5_000 }) + await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('manual-breadcrumb-name') + await expect.poll(() => fs.existsSync(syncedPath)).toBe(false) + await expect.poll(() => fs.existsSync(manuallyRenamedPath)).toBe(true) + + await openRawMode(page) + await expect.poll(async () => getRawEditorContent(page)).toContain(`# ${updatedTitle}`) +}) diff --git a/vite.config.ts b/vite.config.ts index 1fa707a2..57586c9f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ /// +import type { IncomingMessage, ServerResponse } from 'http' import path from 'path' import fs from 'fs' import { defineConfig, type Plugin } from 'vite' @@ -194,183 +195,251 @@ function findMarkdownFiles(dir: string): string[] { return results } +function sendJson(res: ServerResponse, payload: unknown, statusCode = 200): void { + res.statusCode = statusCode + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(payload)) +} + +function readExistingQueryPath(url: URL, res: ServerResponse, key: string): string | null { + const filePath = url.searchParams.get(key) + if (!filePath || !fs.existsSync(filePath)) { + sendJson(res, { error: 'Invalid or missing path' }, 400) + return null + } + return filePath +} + +function updateTitleWikilinks(vaultPath: string, oldTitle: string, newTitle: string, excludePath: string): number { + if (!oldTitle) return 0 + const allFiles = findMarkdownFiles(vaultPath) + const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g') + let updatedFiles = 0 + for (const filePath of allFiles) { + if (filePath === excludePath) continue + try { + const content = fs.readFileSync(filePath, 'utf-8') + const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) => + pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]` + ) + if (replaced !== content) { + fs.writeFileSync(filePath, replaced, 'utf-8') + updatedFiles++ + } + } catch { + // Skip unreadable files in the dev vault API. + } + } + return updatedFiles +} + +function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string): number { + const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '') + const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '') + const escaped = oldRelativeStem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g') + let updatedFiles = 0 + for (const filePath of findMarkdownFiles(vaultPath)) { + if (filePath === newPath) continue + try { + const content = fs.readFileSync(filePath, 'utf-8') + const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) => + pipe ? `[[${newRelativeStem}${pipe}]]` : `[[${newRelativeStem}]]` + ) + if (replaced !== content) { + fs.writeFileSync(filePath, replaced, 'utf-8') + updatedFiles++ + } + } catch { + // Skip unreadable files in the dev vault API. + } + } + return updatedFiles +} + +function handleVaultPing(url: URL, res: ServerResponse): boolean { + if (url.pathname !== '/api/vault/ping') return false + sendJson(res, { ok: true }) + return true +} + +function handleVaultList(url: URL, res: ServerResponse): boolean { + if (url.pathname !== '/api/vault/list') return false + const dirPath = readExistingQueryPath(url, res, 'path') + if (!dirPath) return true + const entries = findMarkdownFiles(dirPath).map(parseMarkdownFile).filter(Boolean) + sendJson(res, entries) + return true +} + +function handleVaultContent(url: URL, res: ServerResponse): boolean { + if (url.pathname !== '/api/vault/content') return false + const filePath = readExistingQueryPath(url, res, 'path') + if (!filePath) return true + sendJson(res, { content: fs.readFileSync(filePath, 'utf-8') }) + return true +} + +function handleVaultAllContent(url: URL, res: ServerResponse): boolean { + if (url.pathname !== '/api/vault/all-content') return false + const dirPath = readExistingQueryPath(url, res, 'path') + if (!dirPath) return true + const contentMap: Record = {} + for (const filePath of findMarkdownFiles(dirPath)) { + try { + contentMap[filePath] = fs.readFileSync(filePath, 'utf-8') + } catch { + // Skip unreadable files. + } + } + sendJson(res, contentMap) + return true +} + +function handleVaultEntry(url: URL, res: ServerResponse): boolean { + if (url.pathname !== '/api/vault/entry') return false + const filePath = readExistingQueryPath(url, res, 'path') + if (!filePath) return true + sendJson(res, parseMarkdownFile(filePath)) + return true +} + +function handleVaultSearch(url: URL, res: ServerResponse): boolean { + if (url.pathname !== '/api/vault/search') return false + const vaultPath = url.searchParams.get('vault_path') + const query = (url.searchParams.get('query') ?? '').toLowerCase() + const mode = url.searchParams.get('mode') ?? 'all' + if (!vaultPath || !query) { + sendJson(res, { results: [], elapsed_ms: 0, query, mode }) + return true + } + + const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = [] + for (const filePath of findMarkdownFiles(vaultPath)) { + const entry = parseMarkdownFile(filePath) + if (!entry || entry.trashed) continue + const raw = fs.readFileSync(filePath, 'utf-8') + if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) { + results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA }) + } + } + sendJson(res, { results: results.slice(0, 20), elapsed_ms: 1, query, mode }) + return true +} + +async function handleVaultSave(url: URL, req: IncomingMessage, res: ServerResponse): Promise { + if (url.pathname !== '/api/vault/save' || req.method !== 'POST') return false + try { + const body = await readRequestBody(req) + const { path: filePath, content } = JSON.parse(body) + if (!filePath || content === undefined) { + sendJson(res, { error: 'Missing path or content' }, 400) + return true + } + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, content, 'utf-8') + sendJson(res, null) + } catch (err: unknown) { + sendJson(res, { error: err instanceof Error ? err.message : 'Save failed' }, 500) + } + return true +} + +async function handleVaultRename(url: URL, req: IncomingMessage, res: ServerResponse): Promise { + if (url.pathname !== '/api/vault/rename' || req.method !== 'POST') return false + try { + const body = await readRequestBody(req) + const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle } = JSON.parse(body) + const oldContent = fs.readFileSync(oldPath, 'utf-8') + const oldTitle = oldContent.match(/^# (.+)$/m)?.[1]?.trim() ?? '' + const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + const newPath = path.join(path.dirname(oldPath), `${slug}.md`) + const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`) + + fs.writeFileSync(newPath, newContent, 'utf-8') + if (newPath !== oldPath) fs.unlinkSync(oldPath) + + const updatedFiles = vaultPath ? updateTitleWikilinks(vaultPath, oldTitle, newTitle, newPath) : 0 + sendJson(res, { new_path: newPath, updated_files: updatedFiles }) + } catch (err: unknown) { + sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500) + } + return true +} + +async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: ServerResponse): Promise { + if (url.pathname !== '/api/vault/rename-filename' || req.method !== 'POST') return false + try { + const body = await readRequestBody(req) + const { + vault_path: vaultPath, + old_path: oldPath, + new_filename_stem: newFilenameStem, + } = JSON.parse(body) + const trimmed = String(newFilenameStem ?? '').trim().replace(/\.md$/i, '').trim() + if (!trimmed) { + sendJson(res, { error: 'New filename cannot be empty' }, 400) + return true + } + if (trimmed === '.' || trimmed === '..' || trimmed.includes('/') || trimmed.includes('\\')) { + sendJson(res, { error: 'Invalid filename' }, 400) + return true + } + + const newPath = path.join(path.dirname(oldPath), `${trimmed}.md`) + if (newPath !== oldPath && fs.existsSync(newPath)) { + sendJson(res, { error: 'A note with that name already exists' }, 409) + return true + } + + fs.renameSync(oldPath, newPath) + const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath) : 0 + sendJson(res, { new_path: newPath, updated_files: updatedFiles }) + } catch (err: unknown) { + sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500) + } + return true +} + +async function handleVaultDelete(url: URL, req: IncomingMessage, res: ServerResponse): Promise { + if (url.pathname !== '/api/vault/delete' || req.method !== 'POST') return false + try { + const body = await readRequestBody(req) + const { path: filePath } = JSON.parse(body) + if (!filePath) { + sendJson(res, { error: 'Missing path' }, 400) + return true + } + fs.unlinkSync(filePath) + sendJson(res, filePath) + } catch (err: unknown) { + sendJson(res, { error: err instanceof Error ? err.message : 'Delete failed' }, 500) + } + return true +} + +async function handleVaultApiRequest(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`) + if (handleVaultPing(url, res)) return true + if (handleVaultList(url, res)) return true + if (handleVaultContent(url, res)) return true + if (handleVaultAllContent(url, res)) return true + if (handleVaultEntry(url, res)) return true + if (handleVaultSearch(url, res)) return true + if (await handleVaultSave(url, req, res)) return true + if (await handleVaultRename(url, req, res)) return true + if (await handleVaultRenameFilename(url, req, res)) return true + if (await handleVaultDelete(url, req, res)) return true + return false +} + function vaultApiPlugin(): Plugin { return { name: 'vault-api', configureServer(server) { server.middlewares.use(async (req, res, next) => { - const url = new URL(req.url ?? '/', `http://${req.headers.host}`) - - if (url.pathname === '/api/vault/ping') { - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ ok: true })) - return - } - - if (url.pathname === '/api/vault/list') { - const dirPath = url.searchParams.get('path') - if (!dirPath || !fs.existsSync(dirPath)) { - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Invalid or missing path' })) - return - } - const files = findMarkdownFiles(dirPath) - const entries = files.map(parseMarkdownFile).filter(Boolean) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(entries)) - return - } - - if (url.pathname === '/api/vault/content') { - const filePath = url.searchParams.get('path') - if (!filePath || !fs.existsSync(filePath)) { - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Invalid or missing path' })) - return - } - const content = fs.readFileSync(filePath, 'utf-8') - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ content })) - return - } - - if (url.pathname === '/api/vault/all-content') { - const dirPath = url.searchParams.get('path') - if (!dirPath || !fs.existsSync(dirPath)) { - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Invalid or missing path' })) - return - } - const files = findMarkdownFiles(dirPath) - const contentMap: Record = {} - for (const f of files) { - try { - contentMap[f] = fs.readFileSync(f, 'utf-8') - } catch { - // skip unreadable files - } - } - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(contentMap)) - return - } - - if (url.pathname === '/api/vault/entry') { - const filePath = url.searchParams.get('path') - if (!filePath || !fs.existsSync(filePath)) { - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Invalid or missing path' })) - return - } - const entry = parseMarkdownFile(filePath) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(entry)) - return - } - - if (url.pathname === '/api/vault/search') { - const vaultPath = url.searchParams.get('vault_path') - const query = (url.searchParams.get('query') ?? '').toLowerCase() - const mode = url.searchParams.get('mode') ?? 'all' - if (!vaultPath || !query) { - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ results: [], elapsed_ms: 0, query, mode })) - return - } - const files = findMarkdownFiles(vaultPath) - const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = [] - for (const f of files) { - const entry = parseMarkdownFile(f) - if (!entry || entry.trashed) continue - const raw = fs.readFileSync(f, 'utf-8') - if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) { - results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA }) - } - } - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ results: results.slice(0, 20), elapsed_ms: 1, query, mode })) - return - } - - // --- POST endpoints for write operations --- - - if (url.pathname === '/api/vault/save' && req.method === 'POST') { - try { - const body = await readRequestBody(req) - const { path: filePath, content } = JSON.parse(body) - if (!filePath || content === undefined) { - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Missing path or content' })) - return - } - fs.mkdirSync(path.dirname(filePath), { recursive: true }) - fs.writeFileSync(filePath, content, 'utf-8') - res.setHeader('Content-Type', 'application/json') - res.end('null') - } catch (err: unknown) { - res.statusCode = 500 - res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Save failed' })) - } - return - } - - if (url.pathname === '/api/vault/rename' && req.method === 'POST') { - try { - const body = await readRequestBody(req) - const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle } = JSON.parse(body) - const oldContent = fs.readFileSync(oldPath, 'utf-8') - const h1Match = oldContent.match(/^# (.+)$/m) - const oldTitle = h1Match ? h1Match[1].trim() : '' - const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') - const parentDir = path.dirname(oldPath) - const newPath = path.join(parentDir, `${slug}.md`) - const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`) - fs.writeFileSync(newPath, newContent, 'utf-8') - if (newPath !== oldPath) fs.unlinkSync(oldPath) - let updatedFiles = 0 - if (oldTitle && vaultPath) { - const allFiles = findMarkdownFiles(vaultPath) - const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g') - for (const f of allFiles) { - if (f === newPath) continue - try { - const c = fs.readFileSync(f, 'utf-8') - const replaced = c.replace(pattern, (_m: string, pipe: string | undefined) => - pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]` - ) - if (replaced !== c) { fs.writeFileSync(f, replaced, 'utf-8'); updatedFiles++ } - } catch { /* skip */ } - } - } - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ new_path: newPath, updated_files: updatedFiles })) - } catch (err: unknown) { - res.statusCode = 500 - res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Rename failed' })) - } - return - } - - if (url.pathname === '/api/vault/delete' && req.method === 'POST') { - try { - const body = await readRequestBody(req) - const { path: filePath } = JSON.parse(body) - if (!filePath) { - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Missing path' })) - return - } - fs.unlinkSync(filePath) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(filePath)) - } catch (err: unknown) { - res.statusCode = 500 - res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Delete failed' })) - } - return - } - + if (await handleVaultApiRequest(req, res)) return next() }) }, @@ -379,7 +448,7 @@ function vaultApiPlugin(): Plugin { // --- Proxy helpers --- -function readRequestBody(req: import('http').IncomingMessage): Promise { +function readRequestBody(req: IncomingMessage): Promise { return new Promise((resolve) => { let body = '' req.on('data', (chunk: Buffer) => { body += chunk.toString() })