import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' import type { VaultEntry, ModifiedFile, GitCommit, FolderNode } from '../types' import { useVaultLoader, resolveNoteStatus } from './useVaultLoader' const mockEntries: VaultEntry[] = [ { path: '/vault/note/hello.md', filename: 'hello.md', title: 'Hello', isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', archived: false, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [], sidebarLabel: null, view: null, visible: null, organized: false, favorite: false, favoriteIndex: null, listPropertiesDisplay: [], properties: {}, hasH1: false, }, ] const mockContent: Record = { '/vault/note/hello.md': '---\ntitle: Hello\n---\n\n# Hello\n', } const mockModifiedFiles: ModifiedFile[] = [ { path: '/vault/note/hello.md', relativePath: 'note/hello.md', status: 'modified' }, ] const mockGitHistory: GitCommit[] = [ { hash: 'abc1234567', shortHash: 'abc1234', message: 'initial commit', author: 'luca', date: 1700000000 }, ] type MockCommandHandler = (args?: Record) => unknown const defaultMockHandlers: Record = { list_vault: () => mockEntries, reload_vault: () => mockEntries, get_all_content: () => mockContent, get_modified_files: () => mockModifiedFiles, get_file_history: () => mockGitHistory, get_file_diff: () => '--- a/note.md\n+++ b/note.md', get_file_diff_at_commit: (args) => `diff for ${(args as Record)?.commitHash}`, git_commit: () => 'committed', git_push: () => ({ status: 'ok', message: 'Pushed to remote' }), } function defaultMockInvoke(cmd: string, args?: Record) { const handler = Reflect.get(defaultMockHandlers, cmd) as ((args?: Record) => unknown) | undefined return Promise.resolve(handler ? handler(args) : null) } let mockIsTauri = false const backendInvokeFn = vi.fn(defaultMockInvoke) const EMPTY_ARRAY_COMMANDS = new Set(['get_modified_files', 'list_vault_folders', 'list_views']) function isVaultLoadCommand(cmd: string) { return cmd === 'list_vault' || cmd === 'reload_vault' } function buildVaultLoaderMock(options: { entries?: VaultEntry[] modifiedFiles?: ModifiedFile[] pushResult?: { status: string; message: string } failHistory?: boolean } = {}) { const { entries = mockEntries, modifiedFiles = mockModifiedFiles, pushResult, failHistory = false, } = options return ((cmd: string, args?: Record) => { if (isVaultLoadCommand(cmd)) return Promise.resolve(entries) if (cmd === 'get_modified_files') return Promise.resolve(modifiedFiles) if (cmd === 'list_vault_folders') return Promise.resolve([]) if (cmd === 'list_views') return Promise.resolve([]) if (cmd === 'get_file_history' && failHistory) return Promise.reject(new Error('fail')) if (cmd === 'git_push' && pushResult) return Promise.resolve(pushResult) return defaultMockInvoke(cmd, args) }) as typeof defaultMockInvoke } function entryAt(path: string, title: string, metadata: Partial = {}): VaultEntry { return { ...mockEntries[0], path, filename: path.split('/').pop() ?? 'note.md', title, ...metadata, } } function mockCachedStartupEntries(cachedPath: string, freshPath: string, metadata: Partial = {}) { backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'list_vault') return Promise.resolve([entryAt(cachedPath, 'Cached', metadata)]) if (cmd === 'reload_vault') return Promise.resolve([entryAt(freshPath, 'Fresh', metadata)]) if (EMPTY_ARRAY_COMMANDS.has(cmd)) return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) } function buildReloadVaultPathMock(loads: Record>) { return ((cmd: string, args?: Record) => { const path = typeof args?.path === 'string' ? args.path : undefined if (cmd === 'reload_vault' && path) return loads[path] ?? Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve([]) if (cmd === 'list_views') return Promise.resolve([]) if (cmd === 'get_modified_files') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke } function createDeferred() { let resolve!: (value: T | PromiseLike) => void let reject!: (reason?: unknown) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } } vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), })) vi.mock('../mock-tauri', () => ({ isTauri: () => mockIsTauri, mockInvoke: (cmd: string, args?: Record) => backendInvokeFn(cmd, args), })) async function waitForEntries( result: ReturnType, undefined>>['result'], length = 1, ) { await waitFor(() => { expect(result.current.entries).toHaveLength(length) }) } async function waitForModifiedFiles( result: ReturnType, undefined>>['result'], length = 1, ) { await waitFor(() => { expect(result.current.modifiedFiles).toHaveLength(length) }) } /** Render the vault loader hook and wait for initial data to load. */ async function renderVaultLoader() { const hook = renderHook(() => useVaultLoader('/vault')) await waitForEntries(hook.result) return hook } async function enableTauriMode() { mockIsTauri = true const tauri = await import('@tauri-apps/api/core') vi.mocked(tauri.invoke).mockImplementation((command: string, args?: Record) => backendInvokeFn(command, args), ) } describe('useVaultLoader', () => { beforeEach(() => { mockIsTauri = false backendInvokeFn.mockReset() backendInvokeFn.mockImplementation(defaultMockInvoke) window.history.replaceState({}, '', '/') }) it('loads entries on mount', async () => { const { result } = await renderVaultLoader() expect(result.current.entries[0].title).toBe('Hello') }) it('normalizes missing entry and view string metadata from vault load', async () => { backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) { return Promise.resolve([ { path: '/vault/note/missing-title.md', filename: undefined, title: undefined, aliases: undefined, outgoingLinks: undefined, relationships: undefined, properties: undefined, }, ]) } if (cmd === 'list_views') return Promise.resolve([{ filename: undefined, definition: {} }]) if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = renderHook(() => useVaultLoader('/vault')) await waitForEntries(result) await waitFor(() => { expect(result.current.views).toHaveLength(1) }) expect(result.current.entries[0]).toMatchObject({ path: '/vault/note/missing-title.md', filename: 'missing-title.md', title: 'missing-title', aliases: [], outgoingLinks: [], relationships: {}, properties: {}, }) expect(result.current.views[0]).toMatchObject({ filename: 'view-1.yml', definition: { name: 'View 1', icon: null, color: null, sort: null, filters: { all: [] }, }, }) }) it('reports initial vault loading until the note scan resolves', async () => { const entriesLoad = createDeferred() backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) return entriesLoad.promise if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve([]) if (cmd === 'list_views') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = renderHook(() => useVaultLoader('/vault')) expect(result.current.isLoading).toBe(true) await act(async () => { entriesLoad.resolve(mockEntries) await entriesLoad.promise }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) }) it('loads folders while the initial note scan is still pending', async () => { const entriesLoad = createDeferred() const folders: FolderNode[] = [{ name: 'Projects', path: 'Projects', children: [] }] backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) return entriesLoad.promise if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve(folders) if (cmd === 'list_views') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = renderHook(() => useVaultLoader('/vault')) await waitFor(() => { expect(result.current.folders).toEqual(folders) }) expect(result.current.isLoading).toBe(true) await act(async () => { entriesLoad.resolve(mockEntries) await entriesLoad.promise }) await waitFor(() => { expect(result.current.entries).toEqual(mockEntries) expect(result.current.isLoading).toBe(false) }) }) it('loads modified files on mount', async () => { const { result } = renderHook(() => useVaultLoader('/vault')) await waitForModifiedFiles(result) expect(result.current.modifiedFiles[0].status).toBe('modified') }) it('does nothing until a real vault path exists', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { result } = renderHook(() => useVaultLoader('')) await waitFor(() => { expect(result.current.entries).toEqual([]) expect(result.current.modifiedFiles).toEqual([]) expect(result.current.modifiedFilesError).toBeNull() }) expect(backendInvokeFn).not.toHaveBeenCalled() expect(warnSpy).not.toHaveBeenCalled() warnSpy.mockRestore() }) it('loads initial main-window vault entries from the cached listing in Tauri mode', async () => { await enableTauriMode() mockCachedStartupEntries('/vault/cached.md', '/vault/fresh.md', { isA: 'Type' }) const { result } = renderHook(() => useVaultLoader('/vault')) await waitFor(() => { expect(result.current.entries.map((entry) => entry.title)).toEqual(['Cached']) }) const issuedCommands = backendInvokeFn.mock.calls.map(([command]) => command) expect(issuedCommands).toContain('list_vault') expect(issuedCommands).not.toContain('reload_vault') }) it('uses cached initial vault entries in Tauri note-window mode', async () => { await enableTauriMode() window.history.replaceState( {}, '', '/?window=note&path=%2Fvault%2Fnote%2Fhello.md&vault=%2Fvault&title=Hello', ) mockCachedStartupEntries('/vault/note/cached.md', '/vault/note/fresh.md') const { result } = renderHook(() => useVaultLoader('/vault')) await waitFor(() => { expect(result.current.entries.map((entry) => entry.title)).toEqual(['Cached']) }) const issuedCommands = backendInvokeFn.mock.calls.map(([command]) => command) expect(issuedCommands).toContain('list_vault') expect(issuedCommands).not.toContain('reload_vault') }) it('uses cached active mounted workspace entries on startup in Tauri mode', async () => { await enableTauriMode() const brian = { label: 'Brian', path: '/brian', alias: 'brian', available: true, mounted: true } const laputa = { label: 'Laputa', path: '/laputa', alias: 'laputa', available: true, mounted: true } const vaults = [laputa, brian] const laputaStartupResponses: Partial> = { reload_vault: [ { ...mockEntries[0], path: '/laputa/note/fresh.md', filename: 'fresh.md', title: 'Fresh' }, ], list_vault: [ { ...mockEntries[0], path: '/laputa/note/cached.md', filename: 'cached.md', title: 'Cached' }, ], } backendInvokeFn.mockImplementation(((cmd: string, args?: Record) => { const response = args?.path === '/laputa' ? laputaStartupResponses[cmd] : undefined if (response) return Promise.resolve(response) if (EMPTY_ARRAY_COMMANDS.has(cmd)) return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = renderHook(() => useVaultLoader('/laputa', vaults, '/laputa', vaults)) await waitFor(() => { expect(result.current.entries.map((entry) => entry.title)).toEqual(['Cached']) }) const laputaLoadCommands = backendInvokeFn.mock.calls .filter(([, args]) => args?.path === '/laputa') .map(([command]) => command) expect(laputaLoadCommands).toContain('list_vault') expect(laputaLoadCommands).not.toContain('reload_vault') }) it('marks the vault unavailable when the initial load finds a missing active vault', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) return Promise.reject(new Error('No such file or directory')) if (cmd === 'check_vault_exists') return Promise.resolve(false) if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles) if (cmd === 'list_vault_folders') return Promise.reject(new Error('Active vault is not available')) if (cmd === 'list_views') return Promise.reject(new Error('Active vault is not available')) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = renderHook(() => useVaultLoader('/vault')) await waitFor(() => { expect(result.current.unavailableVaultPath).toBe('/vault') }) expect(result.current.entries).toEqual([]) expect(result.current.folders).toEqual([]) expect(result.current.views).toEqual([]) expect(result.current.modifiedFiles).toEqual([]) warnSpy.mockRestore() }) it('ignores stale reload_vault results after the vault path changes', async () => { await enableTauriMode() const firstLoad = createDeferred() const secondLoad = createDeferred() backendInvokeFn.mockImplementation(buildReloadVaultPathMock({ '/vault-a': firstLoad.promise, '/vault-b': secondLoad.promise, })) const { result, rerender } = renderHook( ({ path }) => useVaultLoader(path), { initialProps: { path: '/vault-a' } }, ) rerender({ path: '/vault-b' }) await act(async () => { firstLoad.resolve([ { ...mockEntries[0], path: '/vault-a/stale.md', filename: 'stale.md', title: 'Stale', isA: 'Type' }, ]) await firstLoad.promise }) expect(result.current.entries).toEqual([]) await act(async () => { secondLoad.resolve([ { ...mockEntries[0], path: '/vault-b/journal.md', filename: 'journal.md', title: 'Journal', isA: 'Type' }, { ...mockEntries[0], path: '/vault-b/2026-03-11.md', filename: '2026-03-11.md', title: 'March 11', isA: 'Journal' }, ]) await secondLoad.promise }) await waitFor(() => { expect(result.current.entries.map((entry) => entry.title)).toEqual(['Journal', 'March 11']) }) }) describe('addEntry', () => { it('prepends new entry', async () => { const { result } = await renderVaultLoader() const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' } act(() => { result.current.addEntry(newEntry) }) expect(result.current.entries).toHaveLength(2) expect(result.current.entries[0].title).toBe('New Note') }) it('ignores duplicate entry with same path', async () => { const { result } = await renderVaultLoader() const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' } act(() => { result.current.addEntry(newEntry) result.current.addEntry(newEntry) }) expect(result.current.entries).toHaveLength(2) }) }) describe('removeEntry', () => { it('removes entry by path', async () => { const { result } = await renderVaultLoader() act(() => { result.current.removeEntry('/vault/note/hello.md') }) expect(result.current.entries).toHaveLength(0) }) it('is a no-op for non-existent paths', async () => { const { result } = await renderVaultLoader() const entriesBefore = result.current.entries act(() => { result.current.removeEntry('/vault/note/nonexistent.md') }) expect(result.current.entries).toHaveLength(1) expect(result.current.entries).toBe(entriesBefore) }) }) describe('removeEntries', () => { it('removes multiple entries in one state update', async () => { const { result } = await renderVaultLoader() const secondEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/second.md', filename: 'second.md', title: 'Second' } act(() => { result.current.addEntry(secondEntry) result.current.removeEntries(['/vault/note/hello.md', '/vault/note/second.md']) }) expect(result.current.entries).toHaveLength(0) }) it('preserves entries reference when none of the paths exist', async () => { const { result } = await renderVaultLoader() const entriesBefore = result.current.entries act(() => { result.current.removeEntries(['/vault/note/nonexistent.md']) }) expect(result.current.entries).toBe(entriesBefore) }) }) describe('updateEntry', () => { it('patches an existing entry by path', async () => { const { result } = await renderVaultLoader() act(() => { result.current.updateEntry('/vault/note/hello.md', { archived: true, status: 'Done' }) }) expect(result.current.entries[0].archived).toBe(true) expect(result.current.entries[0].status).toBe('Done') }) it('preserves entries reference when path does not exist (no-op)', async () => { const { result } = await renderVaultLoader() const entriesBefore = result.current.entries act(() => { result.current.updateEntry('/vault/note/nonexistent.md', { archived: true }) }) expect(result.current.entries).toBe(entriesBefore) }) it('keeps entry metadata safe when a stale reload patch has undefined fields', async () => { const { result } = await renderVaultLoader() act(() => { result.current.updateEntry('/vault/note/hello.md', { title: undefined, filename: undefined, aliases: undefined, outgoingLinks: undefined, relationships: undefined, properties: undefined, snippet: undefined, } as unknown as Partial) }) expect(result.current.entries[0]).toEqual(expect.objectContaining({ title: 'hello', filename: 'hello.md', aliases: [], outgoingLinks: [], relationships: {}, properties: {}, snippet: '', })) }) }) describe('getNoteStatus', () => { it('returns modified for git-modified files', async () => { const { result } = renderHook(() => useVaultLoader('/vault')) await waitFor(() => { expect(result.current.modifiedFiles).toHaveLength(1) }) expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('modified') expect(result.current.getNoteStatus('/vault/note/other.md')).toBe('clean') }) it('returns new for freshly added entries', async () => { const { result } = await renderVaultLoader() const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/brand-new.md', filename: 'brand-new.md', title: 'Brand New' } act(() => { result.current.addEntry(newEntry) }) expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new') }) it.each([ { name: 'returns new for git-untracked files (saved but not committed)', path: '/vault/note/brand-new.md', relativePath: 'note/brand-new.md', status: 'untracked', }, { name: 'returns new for git-added files (staged but not committed)', path: '/vault/note/staged.md', relativePath: 'note/staged.md', status: 'added', }, { name: 'treats untracked files as new (green dot, not orange)', path: '/vault/note/hello.md', relativePath: 'note/hello.md', status: 'untracked', }, ])('$name', async ({ path, relativePath, status }) => { backendInvokeFn.mockImplementation(buildVaultLoaderMock({ modifiedFiles: [{ path, relativePath, status }], })) const { result } = renderHook(() => useVaultLoader('/vault')) await waitForModifiedFiles(result) expect(result.current.getNoteStatus(path)).toBe('new') }) it('new status takes priority over git modified', async () => { // If a path is both new and in modifiedFiles, it should show as new backendInvokeFn.mockImplementation(buildVaultLoaderMock({ modifiedFiles: [ { path: '/vault/note/new.md', relativePath: 'note/new.md', status: 'modified' }, ], })) const { result } = renderHook(() => useVaultLoader('/vault')) await waitFor(() => { expect(result.current.modifiedFiles).toHaveLength(1) }) const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New', } act(() => { result.current.addEntry(newEntry) }) expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new') }) it('returns unsaved for paths in unsavedPaths', async () => { const { result } = await renderVaultLoader() const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' } act(() => { result.current.addEntry(newEntry) result.current.trackUnsaved('/vault/note/draft.md') }) expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved') }) it('unsaved has higher priority than new', async () => { const { result } = await renderVaultLoader() const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' } act(() => { result.current.addEntry(newEntry) result.current.trackUnsaved('/vault/note/draft.md') }) // addEntry also calls trackNew, so path is in both newPaths and unsavedPaths expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved') }) it('clearUnsaved transitions from unsaved to new', async () => { const { result } = await renderVaultLoader() const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' } act(() => { result.current.addEntry(newEntry) result.current.trackUnsaved('/vault/note/draft.md') }) expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved') act(() => { result.current.clearUnsaved('/vault/note/draft.md') }) expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('new') }) it('keeps unsaved state stable when repeated edits do not change tracked paths', async () => { const { result } = await renderVaultLoader() const path = '/vault/note/draft.md' act(() => { result.current.trackUnsaved(path) }) const trackedPaths = result.current.unsavedPaths act(() => { result.current.trackUnsaved(path) }) expect(result.current.unsavedPaths).toBe(trackedPaths) act(() => { result.current.clearUnsaved(path) }) const clearedPaths = result.current.unsavedPaths act(() => { result.current.clearUnsaved(path) }) expect(result.current.unsavedPaths).toBe(clearedPaths) }) it('tracks and clears pendingSave states separately from unsaved/new markers', async () => { const { result } = await renderVaultLoader() act(() => { result.current.addPendingSave('/vault/note/hello.md') }) expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('pendingSave') act(() => { result.current.removePendingSave('/vault/note/hello.md') }) expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('modified') }) }) describe('loadGitHistory', () => { it('returns git commits for a file', async () => { const { result } = await renderVaultLoader() let history: GitCommit[] = [] await act(async () => { history = await result.current.loadGitHistory('/vault/note/hello.md') }) expect(history).toHaveLength(1) expect(history[0].shortHash).toBe('abc1234') }) it('returns empty array on error', async () => { backendInvokeFn.mockImplementation(buildVaultLoaderMock({ entries: [], modifiedFiles: [], failHistory: true, })) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { result } = renderHook(() => useVaultLoader('/vault')) let history: GitCommit[] = [] await act(async () => { history = await result.current.loadGitHistory('/vault/note/hello.md') }) expect(history).toEqual([]) warnSpy.mockRestore() }) }) describe('loadDiff', () => { it('returns diff string for a file', async () => { const { result } = await renderVaultLoader() let diff = '' await act(async () => { diff = await result.current.loadDiff('/vault/note/hello.md') }) expect(diff).toContain('--- a/note.md') }) }) describe('loadDiffAtCommit', () => { it('returns diff for a specific commit', async () => { const { result } = await renderVaultLoader() let diff = '' await act(async () => { diff = await result.current.loadDiffAtCommit('/vault/note/hello.md', 'abc1234') }) expect(diff).toBe('diff for abc1234') }) }) describe('commitAndPush', () => { it('commits and pushes in mock mode', async () => { const { result } = await renderVaultLoader() let response: { status: string; message: string } = { status: '', message: '' } await act(async () => { response = await result.current.commitAndPush('test commit') }) expect(response.status).toBe('ok') }) it('commits and pushes through the Tauri invoke path', async () => { await enableTauriMode() const { result } = renderHook(() => useVaultLoader('/vault')) await waitForEntries(result) let response: { status: string; message: string } = { status: '', message: '' } await act(async () => { response = await result.current.commitAndPush('tauri commit') }) expect(response.status).toBe('ok') expect(backendInvokeFn).toHaveBeenCalledWith('git_commit', { vaultPath: '/vault', message: 'tauri commit', }) expect(backendInvokeFn).toHaveBeenCalledWith('git_push', { vaultPath: '/vault', }) }) it.each([ { name: 'returns rejected status when push is rejected', pushResult: { status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' }, expectedStatus: 'rejected', expectedMessage: 'Pull first', }, { name: 'returns network error status on network failure', pushResult: { status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' }, expectedStatus: 'network_error', expectedMessage: 'network error', }, ])('$name', async ({ pushResult, expectedStatus, expectedMessage }) => { backendInvokeFn.mockImplementation(buildVaultLoaderMock({ modifiedFiles: [], pushResult, })) const { result } = await renderVaultLoader() let response: { status: string; message: string } = { status: '', message: '' } await act(async () => { response = await result.current.commitAndPush('test commit') }) expect(response.status).toBe(expectedStatus) expect(response.message).toContain(expectedMessage) }) }) describe('reloadFolders', () => { it('refreshes folder tree from backend', async () => { const folders = [{ name: 'projects', path: 'projects', children: [] }] backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries) if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve(folders) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = await renderVaultLoader() expect(result.current.folders).toEqual(folders) const updatedFolders = [...folders, { name: 'journal', path: 'journal', children: [] }] backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'list_vault_folders') return Promise.resolve(updatedFolders) return defaultMockInvoke(cmd) }) as typeof defaultMockInvoke) await act(async () => { await result.current.reloadFolders() }) expect(result.current.folders).toEqual(updatedFolders) }) it('returns an empty folder list when the refresh fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries) if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.reject(new Error('no folders')) if (cmd === 'list_views') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = renderHook(() => useVaultLoader('/vault')) let folders: Array<{ name: string; path: string; children: [] }> = [] await act(async () => { folders = await result.current.reloadFolders() }) expect(folders).toEqual([]) warnSpy.mockRestore() }) }) describe('loadModifiedFiles', () => { it('coalesces overlapping modified-file refreshes while git status is in flight', async () => { const firstStatus = createDeferred() const secondStatus = createDeferred() let statusCalls = 0 backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries) if (cmd === 'list_vault_folders' || cmd === 'list_views') return Promise.resolve([]) if (cmd === 'get_modified_files') { statusCalls += 1 return statusCalls === 1 ? firstStatus.promise : secondStatus.promise } return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = renderHook(() => useVaultLoader('/vault')) await waitForEntries(result) await act(async () => { void result.current.loadModifiedFiles() void result.current.loadModifiedFiles() await Promise.resolve() }) expect(statusCalls).toBe(1) await act(async () => { firstStatus.resolve([]) await Promise.resolve() }) await waitFor(() => { expect(statusCalls).toBe(2) }) await act(async () => { secondStatus.resolve(mockModifiedFiles) await Promise.resolve() }) await waitForModifiedFiles(result) }) it('refreshes modified files list', async () => { let statusCalls = 0 backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries) if (cmd === 'list_vault_folders' || cmd === 'list_views') return Promise.resolve([]) if (cmd === 'get_modified_files') { statusCalls += 1 return Promise.resolve(statusCalls === 1 ? [] : mockModifiedFiles) } return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = renderHook(() => useVaultLoader('/vault')) await waitForEntries(result) await waitFor(() => { expect(statusCalls).toBe(1) }) await act(async () => { await result.current.loadModifiedFiles() }) await waitForModifiedFiles(result) expect(statusCalls).toBe(2) }) it('captures backend errors when modified files cannot be loaded', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries) if (cmd === 'get_modified_files') return Promise.reject('git unavailable') if (cmd === 'list_vault_folders') return Promise.resolve([]) if (cmd === 'list_views') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = renderHook(() => useVaultLoader('/vault')) await waitFor(() => { expect(result.current.modifiedFiles).toEqual([]) expect(result.current.modifiedFilesError).toBe('git unavailable') }) expect(warnSpy).toHaveBeenCalledWith('Failed to load modified files:', 'git unavailable') warnSpy.mockRestore() }) }) describe('replaceEntry', () => { it('replaces an entry path and metadata in place', async () => { const { result } = await renderVaultLoader() act(() => { result.current.replaceEntry('/vault/note/hello.md', { path: '/vault/note/renamed.md', filename: 'renamed.md', title: 'Renamed', }) }) expect(result.current.entries[0]).toEqual(expect.objectContaining({ path: '/vault/note/renamed.md', filename: 'renamed.md', title: 'Renamed', })) }) it('normalizes stale replacement metadata during reload-heavy note switching', async () => { const { result } = await renderVaultLoader() act(() => { result.current.replaceEntry('/vault/note/hello.md', { path: '/vault/note/reloaded.md', title: undefined, filename: undefined, aliases: undefined, outgoingLinks: undefined, relationships: undefined, properties: undefined, snippet: undefined, } as unknown as Partial & { path: string }) }) expect(result.current.entries[0]).toEqual(expect.objectContaining({ path: '/vault/note/reloaded.md', filename: 'reloaded.md', title: 'reloaded', aliases: [], outgoingLinks: [], relationships: {}, properties: {}, snippet: '', })) }) it('preserves entries reference when the old path does not exist', async () => { const { result } = await renderVaultLoader() const entriesBefore = result.current.entries act(() => { result.current.replaceEntry('/vault/note/nonexistent.md', { path: '/vault/note/renamed.md', filename: 'renamed.md', }) }) expect(result.current.entries).toBe(entriesBefore) }) }) describe('reloadVault', () => { it('reports reload progress while reload_vault is pending', async () => { const reload = createDeferred() backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'list_vault') return Promise.resolve(mockEntries) if (cmd === 'reload_vault') return reload.promise if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve([]) if (cmd === 'list_views') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = await renderVaultLoader() let pendingReload: Promise | null = null act(() => { pendingReload = result.current.reloadVault() }) expect(result.current.isReloading).toBe(true) await act(async () => { reload.resolve(mockEntries) await pendingReload! }) expect(result.current.isReloading).toBe(false) }) it('serializes overlapping vault reloads and runs one trailing reload', async () => { const firstReload = createDeferred() const secondReload = createDeferred() let reloadCalls = 0 backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'list_vault') return Promise.resolve(mockEntries) if (cmd === 'reload_vault') { reloadCalls += 1 return reloadCalls === 1 ? firstReload.promise : secondReload.promise } if (cmd === 'get_modified_files' || cmd === 'list_vault_folders' || cmd === 'list_views') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = await renderVaultLoader() let firstReloadPromise: Promise | undefined await act(async () => { firstReloadPromise = result.current.reloadVault() void result.current.reloadVault() await Promise.resolve() }) expect(reloadCalls).toBe(1) await act(async () => { firstReload.resolve([mockEntries[0]]) await firstReloadPromise }) await waitFor(() => { expect(reloadCalls).toBe(2) }) await act(async () => { secondReload.resolve([ { ...mockEntries[0], path: '/vault/note/trailing.md', filename: 'trailing.md', title: 'Trailing' }, ]) await Promise.resolve() }) await waitFor(() => { expect(result.current.entries[0]?.title).toBe('Trailing') }) }) it('refreshes entries from reload_vault and reloads modified files', async () => { const reloadedEntry = { ...mockEntries[0], path: '/vault/note/reloaded.md', filename: 'reloaded.md', title: 'Reloaded', } backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'list_vault') return Promise.resolve(mockEntries) if (cmd === 'reload_vault') return Promise.resolve([reloadedEntry]) if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve([]) if (cmd === 'list_views') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = await renderVaultLoader() let entries: VaultEntry[] = [] await act(async () => { entries = await result.current.reloadVault() }) expect(entries.map((entry) => entry.title)).toEqual(['Reloaded']) expect(result.current.entries.map((entry) => entry.title)).toEqual(['Reloaded']) expect(result.current.modifiedFiles).toEqual([]) }) it('returns an empty list when reloading the vault fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'reload_vault') return Promise.reject(new Error('reload failed')) if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries) if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve([]) if (cmd === 'list_views') return Promise.resolve([]) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = await renderVaultLoader() let entries: VaultEntry[] = [] await act(async () => { entries = await result.current.reloadVault() }) expect(entries).toEqual([]) expect(result.current.entries).toEqual(mockEntries) warnSpy.mockRestore() }) it('clears stale entries and marks the vault unavailable when the active vault disappears', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const initialViews = [{ filename: 'work.yml', definition: { name: 'Work', icon: null, color: null, order: null, sort: null, filters: { all: [] }, }, }] backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'list_vault') return Promise.resolve(mockEntries) if (cmd === 'reload_vault') return Promise.reject(new Error('No such file or directory')) if (cmd === 'check_vault_exists') return Promise.resolve(false) if (cmd === 'get_modified_files') return Promise.resolve(mockModifiedFiles) if (cmd === 'list_vault_folders') return Promise.resolve([{ name: 'note', path: '/vault/note', children: [] }]) if (cmd === 'list_views') return Promise.resolve(initialViews) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = await renderVaultLoader() await waitFor(() => expect(result.current.views).toHaveLength(1)) let entries: VaultEntry[] = [] await act(async () => { entries = await result.current.reloadVault() }) expect(entries).toEqual([]) expect(result.current.entries).toEqual([]) expect(result.current.folders).toEqual([]) expect(result.current.views).toEqual([]) expect(result.current.modifiedFiles).toEqual([]) expect(result.current.unavailableVaultPath).toBe('/vault') warnSpy.mockRestore() }) }) describe('reloadViews', () => { it('refreshes views and falls back to an empty array when they are unavailable', async () => { const initialViews = [{ filename: 'work.view', definition: { name: 'Work', icon: null, color: null, sort: null, filters: { all: [] }, }, }] const updatedViews = [{ filename: 'projects.view', definition: { name: 'Projects', icon: null, color: null, sort: null, filters: { all: [] }, }, }] backendInvokeFn.mockImplementation(((cmd: string) => { if (isVaultLoadCommand(cmd)) return Promise.resolve(mockEntries) if (cmd === 'get_modified_files') return Promise.resolve([]) if (cmd === 'list_vault_folders') return Promise.resolve([]) if (cmd === 'list_views') return Promise.resolve(initialViews) return Promise.resolve(null) }) as typeof defaultMockInvoke) const { result } = await renderVaultLoader() expect(result.current.views).toEqual(initialViews) backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'list_views') return Promise.resolve(updatedViews) return defaultMockInvoke(cmd) }) as typeof defaultMockInvoke) await act(async () => { const views = await result.current.reloadViews() expect(views).toEqual(updatedViews) }) expect(result.current.views).toEqual(updatedViews) backendInvokeFn.mockImplementation(((cmd: string) => { if (cmd === 'list_views') return Promise.reject(new Error('views unavailable')) return defaultMockInvoke(cmd) }) as typeof defaultMockInvoke) await act(async () => { const views = await result.current.reloadViews() expect(views).toEqual([]) }) }) }) }) describe('resolveNoteStatus', () => { const mf = (path: string, status: string): ModifiedFile => ({ path, relativePath: path.replace('/vault/', ''), status }) const status = ( path: string, newPaths: Set, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set, unsavedPaths?: Set, ) => resolveNoteStatus({ path, newPaths, modifiedFiles, pendingSavePaths, unsavedPaths }) it('returns new when path is in newPaths (not yet on disk)', () => { expect(status('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new') }) it('returns new for untracked files in git', () => { expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'untracked')])).toBe('new') }) it('returns new for added files in git', () => { expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'added')])).toBe('new') }) it('returns modified for git-modified files', () => { expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified') }) it('returns clean for files not in git status', () => { expect(status('/vault/x.md', new Set(), [])).toBe('clean') }) it('returns modified for deleted files so deleted previews keep diff affordances', () => { expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('modified') }) it('returns clean for unsupported git statuses', () => { expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'renamed')])).toBe('clean') }) it('newPaths takes priority over git modified', () => { expect(status('/vault/x.md', new Set(['/vault/x.md']), [mf('/vault/x.md', 'modified')])).toBe('new') }) it('pendingSave takes priority over new status', () => { const pendingSave = new Set(['/vault/x.md']) expect(status('/vault/x.md', new Set(['/vault/x.md']), [], pendingSave)).toBe('pendingSave') }) it('pendingSave takes priority over modified status', () => { const pendingSave = new Set(['/vault/x.md']) expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], pendingSave)).toBe('pendingSave') }) it('pendingSave takes priority over clean status', () => { const pendingSave = new Set(['/vault/x.md']) expect(status('/vault/x.md', new Set(), [], pendingSave)).toBe('pendingSave') }) it('without pendingSavePaths parameter, behavior is unchanged', () => { // Omitting the optional parameter should produce the same results as before expect(status('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new') expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified') expect(status('/vault/x.md', new Set(), [])).toBe('clean') }) it('empty pendingSavePaths set does not affect other statuses', () => { const emptyPending = new Set() expect(status('/vault/x.md', new Set(['/vault/x.md']), [], emptyPending)).toBe('new') expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], emptyPending)).toBe('modified') expect(status('/vault/x.md', new Set(), [], emptyPending)).toBe('clean') }) it('unsaved takes priority over all other statuses', () => { const unsaved = new Set(['/vault/x.md']) expect(status('/vault/x.md', new Set(['/vault/x.md']), [], undefined, unsaved)).toBe('unsaved') expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], undefined, unsaved)).toBe('unsaved') expect(status('/vault/x.md', new Set(['/vault/x.md']), [], new Set(['/vault/x.md']), unsaved)).toBe('unsaved') }) it('without unsavedPaths parameter, behavior is unchanged', () => { expect(status('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new') expect(status('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified') }) })