From e0bf514f5d88ea090da8ec9a6ff1a39afbcf5edf Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 11 Mar 2026 17:18:37 +0100 Subject: [PATCH] feat: rename file on save when title slug doesn't match filename When Cmd+S is pressed, after saving content, checks if the note's title slug differs from its current filename. If so, triggers rename_note to update the file on disk, tabs, breadcrumbs, and wikilinks. Adds needsRenameOnSave() utility with tests. Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 20 +++++++++++++------- src/hooks/useNoteActions.test.ts | 17 +++++++++++++++++ src/hooks/useNoteActions.ts | 5 +++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 49ee7e44..b37d0988 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,7 @@ import { WelcomeScreen } from './components/WelcomeScreen' import { useMcpStatus } from './hooks/useMcpStatus' import { useVaultLoader } from './hooks/useVaultLoader' import { useSettings } from './hooks/useSettings' -import { useNoteActions } from './hooks/useNoteActions' +import { useNoteActions, needsRenameOnSave } from './hooks/useNoteActions' import { useCommitFlow } from './hooks/useCommitFlow' import { useViewMode } from './hooks/useViewMode' import { useEntryActions } from './hooks/useEntryActions' @@ -358,14 +358,25 @@ function App() { } }, [resolvedPath, vault.entries, notes, dialogs]) + const handleRenameTab = useCallback(async (path: string, newTitle: string) => { + await savePendingForPath(path) + await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles) + }, [notes, resolvedPath, vault, savePendingForPath]) + // Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing) + // and trigger file rename when the title slug doesn't match the filename. const handleSave = useCallback(async () => { const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath) const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path) ? { path: activeTab.entry.path, content: activeTab.content } : undefined await handleSaveRaw(fallback) - }, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths]) + + // After saving, check if filename needs to match the current title + if (activeTab && needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)) { + await handleRenameTab(activeTab.entry.path, activeTab.entry.title) + } + }, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths]) const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage }) @@ -397,11 +408,6 @@ function App() { setToastMessage(`Type "${name}" created`) }, [notes]) - const handleRenameTab = useCallback(async (path: string, newTitle: string) => { - await savePendingForPath(path) - await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles) - }, [notes, resolvedPath, vault, savePendingForPath]) - /** H1→title sync: update VaultEntry.title and tab entry in memory. */ const handleTitleSync = useCallback((path: string, newTitle: string) => { vault.updateEntry(path, { title: newTitle }) diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 4341bac6..e30cb09a 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -5,6 +5,7 @@ import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' import { slugify, + needsRenameOnSave, buildNewEntry, generateUntitledName, entryMatchesTarget, @@ -94,6 +95,22 @@ describe('slugify', () => { }) }) +describe('needsRenameOnSave', () => { + it('returns true when filename does not match title slug', () => { + expect(needsRenameOnSave('My New Note', 'untitled-note.md')).toBe(true) + expect(needsRenameOnSave('Run good ads for newsletter', 'untitled-note-9.md')).toBe(true) + }) + + it('returns false when filename matches title slug', () => { + expect(needsRenameOnSave('My Note', 'my-note.md')).toBe(false) + expect(needsRenameOnSave('Hello World', 'hello-world.md')).toBe(false) + }) + + it('returns false for untitled note with matching slug', () => { + expect(needsRenameOnSave('Untitled note', 'untitled-note.md')).toBe(false) + }) +}) + describe('buildNewEntry', () => { it('creates a VaultEntry with correct fields', () => { const entry = buildNewEntry({ diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index fffa9719..570efad5 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -104,6 +104,11 @@ export function slugify(text: string): string { return result || 'untitled' } +/** Check if a note's filename doesn't match the slug of its current title. */ +export function needsRenameOnSave(title: string, filename: string): boolean { + return `${slugify(title)}.md` !== filename +} + /** Generate a unique "Untitled " name by checking existing entries and pending names. */ export function generateUntitledName(entries: VaultEntry[], type: string, pending?: Set): string { const baseName = `Untitled ${type.toLowerCase()}`