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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<VaultEntry> & { path: string }, newContent: string) => void
|
||||
}
|
||||
|
||||
async function performMoveToTypeFolder(
|
||||
vaultPath: string, notePath: string, newType: string,
|
||||
): Promise<MoveResult> {
|
||||
if (isTauri()) {
|
||||
return invoke<MoveResult>('move_note_to_type_folder', { vaultPath, notePath, newType })
|
||||
}
|
||||
return mockInvoke<MoveResult>('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,
|
||||
|
||||
Reference in New Issue
Block a user