diff --git a/src/App.tsx b/src/App.tsx index 776560b0..fa89cd41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -552,6 +552,7 @@ function App() { unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, + onTypeStateChanged: async () => { await vault.reloadVault() }, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles, onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath), diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 16a74cdb..8d391dca 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -34,6 +34,8 @@ export interface NoteActionsConfig { onFrontmatterContentChanged?: (path: string, content: string) => void /** Called after a frontmatter mutation is fully persisted, including follow-up renames. */ onFrontmatterPersisted?: () => void + /** Called after type files or type assignments change, so derived type surfaces can reload. */ + onTypeStateChanged?: () => void | Promise } function isTitleKey(key: string): boolean { @@ -94,6 +96,18 @@ function shouldRenameOnTitleUpdate(key: string, value: FrontmatterValue): value return isTitleKey(key) && typeof value === 'string' && value !== '' } +function isTypeFieldKey(key: string): boolean { + const normalized = key.trim().toLowerCase().replace(/\s+/g, '_') + return normalized === 'type' || normalized === 'is_a' +} + +async function notifyFrontmatterPersisted(config: NoteActionsConfig, key: string): Promise { + config.onFrontmatterPersisted?.() + if (isTypeFieldKey(key)) { + await config.onTypeStateChanged?.() + } +} + interface NavigateWikilinkParams { entries: VaultEntry[] target: string @@ -192,7 +206,7 @@ async function updateFrontmatterAndMaybeRename({ if (!applyFrontmatterCallbacks({ config, path, newContent })) return await maybeRenameAfterFrontmatterUpdate({ path, key, value, deps }) - config.onFrontmatterPersisted?.() + await notifyFrontmatterPersisted(config, key) } function buildTabManagementOptions( @@ -280,7 +294,7 @@ function useFrontmatterActionHandlers({ const newContent = await runFrontmatterOp('delete', path, key, undefined, options) if (!applyFrontmatterCallbacks({ config, path, newContent })) return - config.onFrontmatterPersisted?.() + await notifyFrontmatterPersisted(config, key) }, [config, runFrontmatterOp]) const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => { @@ -289,7 +303,7 @@ function useFrontmatterActionHandlers({ const newContent = await runFrontmatterOp('update', path, key, value) if (!applyFrontmatterCallbacks({ config, path, newContent })) return - config.onFrontmatterPersisted?.() + await notifyFrontmatterPersisted(config, key) }, [config, runFrontmatterOp]) return { diff --git a/src/hooks/useNoteActions.typeSync.test.tsx b/src/hooks/useNoteActions.typeSync.test.tsx new file mode 100644 index 00000000..1dedd3ab --- /dev/null +++ b/src/hooks/useNoteActions.typeSync.test.tsx @@ -0,0 +1,105 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { VaultEntry } from '../types' +import { useNoteActions, type NoteActionsConfig } from './useNoteActions' + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) +vi.mock('../mock-tauri', () => ({ + isTauri: vi.fn(() => false), + addMockEntry: vi.fn(), + updateMockContent: vi.fn(), + trackMockChange: vi.fn(), + mockInvoke: vi.fn().mockResolvedValue(''), +})) +vi.mock('./mockFrontmatterHelpers', () => ({ + updateMockFrontmatter: vi.fn().mockReturnValue('---\ntype: Hotel\n---\n'), + deleteMockFrontmatterProperty: vi.fn().mockReturnValue('---\n---\n'), +})) + +const baseEntry: VaultEntry = { + path: '/vault/note.md', + filename: 'note.md', + title: 'Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: 1700000000, + createdAt: 1700000000, + fileSize: 10, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + outgoingLinks: [], + template: null, + sort: null, + sidebarLabel: null, + view: null, + visible: null, + properties: {}, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + hasH1: false, +} + +function renderActions(overrides: Partial = {}) { + const config: NoteActionsConfig = { + addEntry: vi.fn(), + removeEntry: vi.fn(), + entries: [baseEntry], + setToastMessage: vi.fn(), + updateEntry: vi.fn(), + vaultPath: '/vault', + ...overrides, + } + return { + ...renderHook(() => useNoteActions(config)), + config, + } +} + +describe('useNoteActions type state sync', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('refreshes type state after assigning a note type', async () => { + const onTypeStateChanged = vi.fn().mockResolvedValue(undefined) + const { result } = renderActions({ onTypeStateChanged }) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'type', 'Hotel') + }) + + expect(onTypeStateChanged).toHaveBeenCalledOnce() + }) + + it('does not refresh type state for unrelated frontmatter edits', async () => { + const onTypeStateChanged = vi.fn() + const { result } = renderActions({ onTypeStateChanged }) + + await act(async () => { + await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Active') + }) + + expect(onTypeStateChanged).not.toHaveBeenCalled() + }) + + it('refreshes type state after creating a type file', async () => { + const onTypeStateChanged = vi.fn().mockResolvedValue(undefined) + const { result } = renderActions({ onTypeStateChanged }) + + await act(async () => { + await result.current.handleCreateType('Hotel') + }) + + expect(onTypeStateChanged).toHaveBeenCalledOnce() + }) +}) diff --git a/src/hooks/useNoteCreation.extra.test.ts b/src/hooks/useNoteCreation.extra.test.ts index 011226e1..e1ebd496 100644 --- a/src/hooks/useNoteCreation.extra.test.ts +++ b/src/hooks/useNoteCreation.extra.test.ts @@ -310,7 +310,7 @@ describe('resolveNewType', () => { expect(entry.isA).toBe('Type') expect(entry.status).toBeNull() expect(content).toContain('type: Type') - expect(content).not.toContain('# Recipe') + expect(content).toContain('# Recipe') }) it('uses provided vault path instead of hardcoded path', () => { diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index 101db76a..de63ff1a 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef } from 'react' import { invoke } from '@tauri-apps/api/core' -import { isTauri, addMockEntry } from '../mock-tauri' +import { isTauri, addMockEntry, mockInvoke } from '../mock-tauri' import type { VaultEntry } from '../types' import { slugifyNoteStem as slugify } from '../utils/noteSlug' import { resolveEntry } from '../utils/wikilink' @@ -127,7 +127,7 @@ export interface NewTypeParams { export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } { const slug = slugify(typeName) const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null }) - return { entry, content: `---\ntype: Type\n---\n` } + return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n` } } type ResolvedEntry = { entry: VaultEntry; content: string } @@ -226,7 +226,7 @@ function createPersistFailureMessage(entry: VaultEntry, error: unknown): string /** Persist a newly created note to disk. Returns a Promise for error handling. */ export function persistNewNote(path: string, content: string): Promise { - if (!isTauri()) return Promise.resolve() + if (!isTauri()) return mockInvoke('save_note_content', { path, content }).then(() => {}) return invoke('create_note_content', { path, content }).then(() => {}) } @@ -515,6 +515,7 @@ export interface NoteCreationConfig { unsavedPaths?: Set markContentPending?: (path: string, content: string) => void onNewNotePersisted?: () => void + onTypeStateChanged?: () => void | Promise } interface CreationTabDeps { @@ -522,7 +523,17 @@ interface CreationTabDeps { } export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTabDeps) { - const { addEntry, removeEntry, entries, setToastMessage, addPendingSave, removePendingSave, vaultPath } = config + const { + addEntry, + removeEntry, + entries, + setToastMessage, + addPendingSave, + removePendingSave, + vaultPath, + onNewNotePersisted, + onTypeStateChanged, + } = config const { openTabWithContent } = tabDeps const persistResolvedEntry = useCallback(async ( @@ -535,13 +546,16 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab await persistOptimistic(resolved.entry.path, resolved.content, { onStart: addPendingSave, onEnd: removePendingSave, - onPersisted: config.onNewNotePersisted, + onPersisted: onNewNotePersisted, }) + if (resolved.entry.isA === 'Type') { + await onTypeStateChanged?.() + } } catch (error) { removeEntry(resolved.entry.path) throw error } - }, [openTabWithContent, addEntry, addPendingSave, removePendingSave, config.onNewNotePersisted, removeEntry]) + }, [openTabWithContent, addEntry, addPendingSave, removePendingSave, onNewNotePersisted, onTypeStateChanged, removeEntry]) const handleCreateNote = useCallback((title: string, type: string): Promise => createNamedNote({ entries, vaultPath, setToastMessage, persistResolvedEntry, title, type, creationPath: 'plus_button' }),