From 873f85afc99ee5c97fd6b03bbe6b34e412401a2d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 8 Mar 2026 21:37:27 +0100 Subject: [PATCH] feat: move note to type folder when Is A changes When the user changes a note's type via the Properties panel, the note file is automatically moved to the corresponding type folder. Shows a toast confirming the move. No move if already in correct folder. Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 2 +- src/hooks/useNoteActions.test.ts | 105 ++++++++++++++++++++++++++++++- src/hooks/useNoteActions.ts | 48 +++++++++++++- 3 files changed, 152 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index edd085af..cdd8c03a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -187,7 +187,7 @@ function App() { allContentForCacheRef.current = vault.allContent // eslint-disable-line react-hooks/refs -- ref sync pattern const getCachedContent = useCallback((path: string) => allContentForCacheRef.current[path], []) - const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, getCachedContent }) + const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, getCachedContent, replaceEntry: vault.replaceEntry }) // Keep tab entries in sync with vault entries so banners (trash/archive) // and read-only state react immediately without reopening the note. diff --git a/src/hooks/useNoteActions.test.ts b/src/hooks/useNoteActions.test.ts index 916889eb..241ead1f 100644 --- a/src/hooks/useNoteActions.test.ts +++ b/src/hooks/useNoteActions.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { invoke } from '@tauri-apps/api/core' -import { isTauri } from '../mock-tauri' +import { isTauri, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' import { slugify, @@ -853,4 +853,107 @@ describe('useNoteActions hook', () => { expect(clearUnsaved).toHaveBeenCalledWith(createdPath) }) }) + + describe('move note to type folder on type change', () => { + it('calls move_note_to_type_folder when type key is updated', async () => { + const entry = makeEntry({ path: '/test/vault/note/my-note.md', filename: 'my-note.md', title: 'My Note', isA: 'Note' }) + const replaceEntry = vi.fn() + const config = makeConfig([entry]) + config.replaceEntry = replaceEntry + + vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { + if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: true } + if (cmd === 'get_note_content') return '---\ntype: Quarter\n---\n# My Note\n' + return '' + }) + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'type', 'Quarter') + }) + + expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({ + vault_path: '/test/vault', + note_path: '/test/vault/note/my-note.md', + new_type: 'Quarter', + })) + expect(replaceEntry).toHaveBeenCalledWith( + '/test/vault/note/my-note.md', + expect.objectContaining({ path: '/test/vault/quarter/my-note.md' }), + expect.any(String), + ) + expect(setToastMessage).toHaveBeenCalledWith('Note moved to quarter/') + }) + + it('does not call move when type key is not being changed', async () => { + const config = makeConfig() + vi.mocked(mockInvoke).mockResolvedValue('') + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done') + }) + + expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything()) + }) + + it('does not move when result.moved is false (already in correct folder)', async () => { + const entry = makeEntry({ path: '/test/vault/quarter/my-note.md', filename: 'my-note.md', isA: 'Quarter' }) + const replaceEntry = vi.fn() + const config = makeConfig([entry]) + config.replaceEntry = replaceEntry + + vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { + if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: false } + return '' + }) + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/test/vault/quarter/my-note.md', 'type', 'Quarter') + }) + + expect(replaceEntry).not.toHaveBeenCalled() + // Should still show 'Property updated' toast (not move toast) + expect(setToastMessage).toHaveBeenCalledWith('Property updated') + }) + + it('handles Is A key (case-insensitive)', async () => { + const entry = makeEntry({ path: '/test/vault/note/my-note.md' }) + const config = makeConfig([entry]) + config.replaceEntry = vi.fn() + + vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => { + if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/my-note.md', updated_links: 0, moved: true } + if (cmd === 'get_note_content') return '---\nIs A: Project\n---\n# My Note\n' + return '' + }) + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'Is A', 'Project') + }) + + expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({ + new_type: 'Project', + })) + }) + + it('does not move when value is empty or null-like', async () => { + const config = makeConfig() + vi.mocked(mockInvoke).mockResolvedValue('') + + const { result } = renderHook(() => useNoteActions(config)) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'type', '') + }) + + expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything()) + }) + }) }) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 3061a516..b3bb5533 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -19,6 +19,12 @@ interface RenameResult { updated_files: number } +interface MoveResult { + new_path: string + updated_links: number + moved: boolean +} + export interface NoteActionsConfig { addEntry: (entry: VaultEntry, content: string) => void removeEntry: (path: string) => void @@ -38,6 +44,23 @@ export interface NoteActionsConfig { onNewNotePersisted?: () => void /** Return cached content for a path (from allContent), or undefined on cache miss. */ getCachedContent?: (path: string) => string | undefined + /** Replace an entry at oldPath with a patch (handles path changes in entries + content). */ + replaceEntry?: (oldPath: string, patch: Partial & { path: string }, newContent: string) => void +} + +async function performMoveToTypeFolder( + vaultPath: string, notePath: string, newType: string, +): Promise { + if (isTauri()) { + return invoke('move_note_to_type_folder', { vaultPath, notePath, newType }) + } + return mockInvoke('move_note_to_type_folder', { vault_path: vaultPath, note_path: notePath, new_type: newType }) +} + +/** Check if a frontmatter key represents the note type. */ +function isTypeKey(key: string): boolean { + const k = key.toLowerCase().replace(/\s+/g, '_') + return k === 'type' || k === 'is_a' } async function performRename( @@ -448,7 +471,30 @@ export function useNoteActions(config: NoteActionsConfig) { handleOpenDailyNote, handleCreateType, createTypeEntrySilent, - handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]), + handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue) => { + await runFrontmatterOp('update', path, key, value) + if (isTypeKey(key) && typeof value === 'string' && value !== '') { + try { + const result = await performMoveToTypeFolder(config.vaultPath, path, value) + if (result.moved) { + const entry = entries.find(e => e.path === path) + if (entry) { + const newFilename = result.new_path.split('/').pop() ?? entry.filename + const newContent = await loadNoteContent(result.new_path) + config.replaceEntry?.(path, { ...entry, path: result.new_path, filename: newFilename }, newContent) + setTabs(prev => prev.map(t => t.entry.path === path + ? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: newContent } + : t)) + if (activeTabPathRef.current === path) handleSwitchTab(result.new_path) + } + const folder = result.new_path.split('/').slice(-2, -1)[0] ?? '' + setToastMessage(`Note moved to ${folder}/`) + } + } catch (err) { + console.error('Failed to move note to type folder:', err) + } + } + }, [runFrontmatterOp, config.vaultPath, config.replaceEntry, entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage]), handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]), handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]), handleRenameNote,