refactor: extract useConflictFlow, useAppSave, useVaultBridge from App.tsx
App.tsx was 702 lines and the highest-churn file (102 commits/month). Extract three hooks to reduce it to 537 lines and distribute future changes across focused modules: - useConflictFlow: conflict resolution orchestration - useAppSave: save/flush/rename orchestration - useVaultBridge: agent/MCP file operation handlers All files score 10.0 on CodeScene. 2226 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
94
src/hooks/useAppSave.test.ts
Normal file
94
src/hooks/useAppSave.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useAppSave } from './useAppSave'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
function makeEntry(path: string, title = 'Test', filename = 'test.md'): VaultEntry {
|
||||
return { path, title, filename, content: '', outgoingLinks: [], snippet: '', wordCount: 0, isA: 'Note', status: null, createdAt: null, modifiedAt: null, icon: null, tags: [] } as unknown as VaultEntry
|
||||
}
|
||||
|
||||
describe('useAppSave', () => {
|
||||
const deps = {
|
||||
updateEntry: vi.fn(),
|
||||
setTabs: vi.fn(),
|
||||
setToastMessage: vi.fn(),
|
||||
loadModifiedFiles: vi.fn(),
|
||||
clearUnsaved: vi.fn(),
|
||||
unsavedPaths: new Set<string>(),
|
||||
tabs: [] as Array<{ entry: VaultEntry; content: string }>,
|
||||
activeTabPath: null as string | null,
|
||||
handleRenameNote: vi.fn().mockResolvedValue(undefined),
|
||||
replaceEntry: vi.fn(),
|
||||
resolvedPath: '/vault',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
deps.unsavedPaths = new Set()
|
||||
deps.tabs = []
|
||||
deps.activeTabPath = null
|
||||
deps.handleRenameNote.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
function renderSave(overrides = {}) {
|
||||
return renderHook(() => useAppSave({ ...deps, ...overrides }))
|
||||
}
|
||||
|
||||
it('exposes contentChangeRef', () => {
|
||||
const { result } = renderSave()
|
||||
expect(result.current.contentChangeRef).toBeDefined()
|
||||
expect(typeof result.current.contentChangeRef.current).toBe('function')
|
||||
})
|
||||
|
||||
it('exposes handleSave', () => {
|
||||
const { result } = renderSave()
|
||||
expect(typeof result.current.handleSave).toBe('function')
|
||||
})
|
||||
|
||||
it('exposes handleTitleSync', () => {
|
||||
const { result } = renderSave()
|
||||
expect(typeof result.current.handleTitleSync).toBe('function')
|
||||
})
|
||||
|
||||
it('exposes flushBeforeAction', () => {
|
||||
const { result } = renderSave()
|
||||
expect(typeof result.current.flushBeforeAction).toBe('function')
|
||||
})
|
||||
|
||||
it('handleSave calls save with no fallback when no active tab', async () => {
|
||||
const { result } = renderSave()
|
||||
|
||||
await act(async () => { await result.current.handleSave() })
|
||||
|
||||
// Should not throw — just a no-op save
|
||||
})
|
||||
|
||||
it('handleSave provides fallback for unsaved active tab', async () => {
|
||||
const entry = makeEntry('/vault/note.md', 'note', 'note.md')
|
||||
const unsavedPaths = new Set(['/vault/note.md'])
|
||||
const tabs = [{ entry, content: '# Hello' }]
|
||||
|
||||
const { result } = renderSave({
|
||||
tabs,
|
||||
activeTabPath: '/vault/note.md',
|
||||
unsavedPaths,
|
||||
})
|
||||
|
||||
await act(async () => { await result.current.handleSave() })
|
||||
|
||||
// Should complete without error
|
||||
})
|
||||
|
||||
it('handleContentChange is a function', () => {
|
||||
const { result } = renderSave()
|
||||
expect(typeof result.current.handleContentChange).toBe('function')
|
||||
})
|
||||
})
|
||||
111
src/hooks/useAppSave.ts
Normal file
111
src/hooks/useAppSave.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
|
||||
import { needsRenameOnSave } from './useNoteRename'
|
||||
import { flushEditorContent } from '../utils/autoSave'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface TabState {
|
||||
entry: VaultEntry
|
||||
content: string
|
||||
}
|
||||
|
||||
function findUnsavedFallback(
|
||||
tabs: TabState[], activeTabPath: string | null, unsavedPaths: Set<string>,
|
||||
): { path: string; content: string } | undefined {
|
||||
const activeTab = tabs.find(t => t.entry.path === activeTabPath)
|
||||
if (!activeTab || !unsavedPaths.has(activeTab.entry.path)) return undefined
|
||||
return { path: activeTab.entry.path, content: activeTab.content }
|
||||
}
|
||||
|
||||
function activeTabNeedsRename(tabs: TabState[], activeTabPath: string | null): { path: string; title: string } | null {
|
||||
const activeTab = tabs.find(t => t.entry.path === activeTabPath)
|
||||
if (!activeTab) return null
|
||||
return needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)
|
||||
? { path: activeTab.entry.path, title: activeTab.entry.title }
|
||||
: null
|
||||
}
|
||||
|
||||
interface AppSaveDeps {
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
setTabs: Parameters<typeof useEditorSaveWithLinks>[0]['setTabs']
|
||||
setToastMessage: (msg: string | null) => void
|
||||
loadModifiedFiles: () => void
|
||||
clearUnsaved: (path: string) => void
|
||||
unsavedPaths: Set<string>
|
||||
tabs: TabState[]
|
||||
activeTabPath: string | null
|
||||
handleRenameNote: (path: string, newTitle: string, vaultPath: string, onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void) => Promise<void>
|
||||
replaceEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
|
||||
resolvedPath: string
|
||||
}
|
||||
|
||||
export function useAppSave({
|
||||
updateEntry, setTabs, setToastMessage,
|
||||
loadModifiedFiles, clearUnsaved, unsavedPaths,
|
||||
tabs, activeTabPath,
|
||||
handleRenameNote, replaceEntry, resolvedPath,
|
||||
}: AppSaveDeps) {
|
||||
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
||||
|
||||
const onAfterSave = useCallback(() => {
|
||||
loadModifiedFiles()
|
||||
}, [loadModifiedFiles])
|
||||
|
||||
const onNotePersisted = useCallback((path: string) => {
|
||||
clearUnsaved(path)
|
||||
}, [clearUnsaved])
|
||||
|
||||
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
||||
updateEntry, setTabs, setToastMessage, onAfterSave, onNotePersisted,
|
||||
})
|
||||
|
||||
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
||||
|
||||
// Refs for stable closure in flushBeforeAction
|
||||
const tabsRef = useRef(tabs)
|
||||
tabsRef.current = tabs // eslint-disable-line react-hooks/refs -- ref sync pattern
|
||||
const unsavedPathsRef = useRef(unsavedPaths)
|
||||
unsavedPathsRef.current = unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern
|
||||
|
||||
const flushBeforeAction = useCallback(async (path: string) => {
|
||||
try {
|
||||
await flushEditorContent(path, {
|
||||
savePendingForPath,
|
||||
getTabContent: (p) => tabsRef.current.find(t => t.entry.path === p)?.content,
|
||||
isUnsaved: (p) => unsavedPathsRef.current.has(p),
|
||||
onSaved: (p) => { clearUnsaved(p) },
|
||||
})
|
||||
} catch (err) {
|
||||
setToastMessage(`Auto-save failed: ${err}`)
|
||||
throw err
|
||||
}
|
||||
}, [savePendingForPath, clearUnsaved, setToastMessage])
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await handleRenameNote(path, newTitle, resolvedPath, replaceEntry).then(loadModifiedFiles)
|
||||
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
await handleSaveRaw(findUnsavedFallback(tabs, activeTabPath, unsavedPaths))
|
||||
const rename = activeTabNeedsRename(tabs, activeTabPath)
|
||||
if (rename) await handleRenameTab(rename.path, rename.title)
|
||||
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths])
|
||||
|
||||
const handleTitleSync = useCallback((path: string, newTitle: string) => {
|
||||
savePendingForPath(path)
|
||||
.then(() => handleRenameNote(path, newTitle, resolvedPath, replaceEntry))
|
||||
.then(loadModifiedFiles)
|
||||
.catch((err) => console.error('Title rename failed:', err))
|
||||
}, [handleRenameNote, resolvedPath, replaceEntry, savePendingForPath, loadModifiedFiles])
|
||||
|
||||
return {
|
||||
contentChangeRef,
|
||||
handleContentChange,
|
||||
handleSave,
|
||||
handleTitleSync,
|
||||
savePending,
|
||||
savePendingForPath,
|
||||
flushBeforeAction,
|
||||
}
|
||||
}
|
||||
134
src/hooks/useConflictFlow.test.ts
Normal file
134
src/hooks/useConflictFlow.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useConflictFlow } from './useConflictFlow'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const mockInvokeFn = vi.fn()
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
vi.mock('../utils/url', () => ({
|
||||
openLocalFile: vi.fn(),
|
||||
}))
|
||||
|
||||
function makeEntry(path: string): VaultEntry {
|
||||
return { path, title: 'Test', filename: path.split('/').pop()! } as unknown as VaultEntry
|
||||
}
|
||||
|
||||
describe('useConflictFlow', () => {
|
||||
const deps = {
|
||||
resolvedPath: '/vault',
|
||||
entries: [makeEntry('/vault/note.md')],
|
||||
conflictFiles: ['note.md'],
|
||||
pausePull: vi.fn(),
|
||||
resumePull: vi.fn(),
|
||||
triggerSync: vi.fn(),
|
||||
reloadVault: vi.fn().mockResolvedValue([]),
|
||||
initConflictFiles: vi.fn(),
|
||||
openConflictResolver: vi.fn(),
|
||||
closeConflictResolver: vi.fn(),
|
||||
onSelectNote: vi.fn(),
|
||||
activeTabPath: '/vault/note.md',
|
||||
setToastMessage: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockResolvedValue(undefined)
|
||||
deps.reloadVault.mockResolvedValue([])
|
||||
})
|
||||
|
||||
function renderFlow(overrides = {}) {
|
||||
return renderHook(() => useConflictFlow({ ...deps, ...overrides }))
|
||||
}
|
||||
|
||||
it('opens conflict resolver with cached files', async () => {
|
||||
const { result } = renderFlow()
|
||||
|
||||
await act(async () => { await result.current.handleOpenConflictResolver() })
|
||||
|
||||
expect(deps.pausePull).toHaveBeenCalled()
|
||||
expect(deps.initConflictFiles).toHaveBeenCalledWith(['note.md'])
|
||||
expect(deps.openConflictResolver).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches conflicts when cache is empty', async () => {
|
||||
mockInvokeFn.mockResolvedValueOnce(['other.md'])
|
||||
const { result } = renderFlow({ conflictFiles: [] })
|
||||
|
||||
await act(async () => { await result.current.handleOpenConflictResolver() })
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_conflict_files', { vaultPath: '/vault' })
|
||||
expect(deps.initConflictFiles).toHaveBeenCalledWith(['other.md'])
|
||||
})
|
||||
|
||||
it('shows toast when no conflicts found', async () => {
|
||||
mockInvokeFn.mockResolvedValueOnce([])
|
||||
const { result } = renderFlow({ conflictFiles: [] })
|
||||
|
||||
await act(async () => { await result.current.handleOpenConflictResolver() })
|
||||
|
||||
expect(deps.setToastMessage).toHaveBeenCalledWith('No merge conflicts to resolve')
|
||||
expect(deps.openConflictResolver).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes conflict resolver and resumes pull', () => {
|
||||
const { result } = renderFlow()
|
||||
|
||||
act(() => { result.current.handleCloseConflictResolver() })
|
||||
|
||||
expect(deps.resumePull).toHaveBeenCalled()
|
||||
expect(deps.closeConflictResolver).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves inline and commits when all resolved', async () => {
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_conflict_files') return Promise.resolve([])
|
||||
if (cmd === 'get_note_content') return Promise.resolve('resolved content')
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
const { result } = renderFlow()
|
||||
|
||||
await act(async () => { await result.current.handleKeepMine('/vault/note.md') })
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_resolve_conflict', {
|
||||
vaultPath: '/vault', file: 'note.md', strategy: 'ours',
|
||||
})
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_commit_conflict_resolution', { vaultPath: '/vault' })
|
||||
expect(deps.setToastMessage).toHaveBeenCalledWith('All conflicts resolved — merge committed')
|
||||
})
|
||||
|
||||
it('shows remaining count when not all resolved', async () => {
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_conflict_files') return Promise.resolve(['other.md'])
|
||||
if (cmd === 'get_note_content') return Promise.resolve('content')
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
const { result } = renderFlow()
|
||||
|
||||
await act(async () => { await result.current.handleKeepTheirs('/vault/note.md') })
|
||||
|
||||
expect(deps.setToastMessage).toHaveBeenCalledWith('Resolved — 1 conflict remaining')
|
||||
})
|
||||
|
||||
it('isConflicted is true when active tab matches a conflict file', () => {
|
||||
const { result } = renderFlow()
|
||||
expect(result.current.isConflicted).toBe(true)
|
||||
})
|
||||
|
||||
it('isConflicted is false when no active tab', () => {
|
||||
const { result } = renderFlow({ activeTabPath: null })
|
||||
expect(result.current.isConflicted).toBe(false)
|
||||
})
|
||||
|
||||
it('isConflicted is false when active tab has no conflict', () => {
|
||||
const { result } = renderFlow({ activeTabPath: '/vault/other.md', conflictFiles: ['note.md'] })
|
||||
expect(result.current.isConflicted).toBe(false)
|
||||
})
|
||||
})
|
||||
113
src/hooks/useConflictFlow.ts
Normal file
113
src/hooks/useConflictFlow.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { openLocalFile } from '../utils/url'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
interface ConflictFlowDeps {
|
||||
resolvedPath: string
|
||||
entries: VaultEntry[]
|
||||
conflictFiles: string[]
|
||||
pausePull: () => void
|
||||
resumePull: () => void
|
||||
triggerSync: () => void
|
||||
reloadVault: () => Promise<unknown>
|
||||
initConflictFiles: (files: string[]) => void
|
||||
openConflictResolver: () => void
|
||||
closeConflictResolver: () => void
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
activeTabPath: string | null
|
||||
setToastMessage: (msg: string | null) => void
|
||||
}
|
||||
|
||||
async function fetchConflictFiles(vaultPath: string): Promise<string[]> {
|
||||
return tauriCall<string[]>('get_conflict_files', { vaultPath })
|
||||
}
|
||||
|
||||
async function resolveAndCheck(
|
||||
vaultPath: string, filePath: string, strategy: 'ours' | 'theirs',
|
||||
): Promise<string[]> {
|
||||
const relativePath = filePath.replace(vaultPath + '/', '')
|
||||
await tauriCall('git_resolve_conflict', { vaultPath, file: relativePath, strategy })
|
||||
return fetchConflictFiles(vaultPath)
|
||||
}
|
||||
|
||||
async function commitMergeResolution(vaultPath: string): Promise<void> {
|
||||
await tauriCall('git_commit_conflict_resolution', { vaultPath })
|
||||
}
|
||||
|
||||
export function useConflictFlow({
|
||||
resolvedPath, entries, conflictFiles,
|
||||
pausePull, resumePull, triggerSync, reloadVault,
|
||||
initConflictFiles, openConflictResolver, closeConflictResolver,
|
||||
onSelectNote, activeTabPath, setToastMessage,
|
||||
}: ConflictFlowDeps) {
|
||||
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
|
||||
|
||||
useEffect(() => {
|
||||
openConflictFileRef.current = (relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = entries.find(e => e.path === fullPath)
|
||||
if (entry) {
|
||||
onSelectNote(entry)
|
||||
closeConflictResolver()
|
||||
} else {
|
||||
openLocalFile(fullPath)
|
||||
}
|
||||
}
|
||||
}, [resolvedPath, entries, onSelectNote, closeConflictResolver])
|
||||
|
||||
const handleOpenConflictResolver = useCallback(async () => {
|
||||
let files = conflictFiles
|
||||
if (files.length === 0) {
|
||||
try { files = await fetchConflictFiles(resolvedPath) } catch { return }
|
||||
if (files.length === 0) {
|
||||
setToastMessage('No merge conflicts to resolve')
|
||||
return
|
||||
}
|
||||
}
|
||||
pausePull()
|
||||
initConflictFiles(files)
|
||||
openConflictResolver()
|
||||
}, [conflictFiles, resolvedPath, pausePull, initConflictFiles, openConflictResolver, setToastMessage])
|
||||
|
||||
const handleCloseConflictResolver = useCallback(() => {
|
||||
resumePull()
|
||||
closeConflictResolver()
|
||||
}, [resumePull, closeConflictResolver])
|
||||
|
||||
const handleResolveConflictInline = useCallback(async (filePath: string, strategy: 'ours' | 'theirs') => {
|
||||
try {
|
||||
const remaining = await resolveAndCheck(resolvedPath, filePath, strategy)
|
||||
if (remaining.length === 0) {
|
||||
await commitMergeResolution(resolvedPath)
|
||||
reloadVault()
|
||||
triggerSync()
|
||||
setToastMessage('All conflicts resolved — merge committed')
|
||||
} else {
|
||||
reloadVault()
|
||||
setToastMessage(`Resolved — ${remaining.length} conflict${remaining.length > 1 ? 's' : ''} remaining`)
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to resolve conflict: ${err}`)
|
||||
}
|
||||
}, [resolvedPath, reloadVault, triggerSync, setToastMessage])
|
||||
|
||||
const handleKeepMine = useCallback((path: string) => handleResolveConflictInline(path, 'ours'), [handleResolveConflictInline])
|
||||
const handleKeepTheirs = useCallback((path: string) => handleResolveConflictInline(path, 'theirs'), [handleResolveConflictInline])
|
||||
|
||||
const isConflicted = !!activeTabPath && conflictFiles.some(f => activeTabPath.endsWith(f))
|
||||
|
||||
return {
|
||||
openConflictFileRef,
|
||||
handleOpenConflictResolver,
|
||||
handleCloseConflictResolver,
|
||||
handleKeepMine,
|
||||
handleKeepTheirs,
|
||||
isConflicted,
|
||||
}
|
||||
}
|
||||
113
src/hooks/useVaultBridge.test.ts
Normal file
113
src/hooks/useVaultBridge.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useVaultBridge } from './useVaultBridge'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(path: string, title = 'Test'): VaultEntry {
|
||||
return { path, title, filename: path.split('/').pop()!, content: '', outgoingLinks: [], snippet: '', wordCount: 0, isA: 'Note', status: null, createdAt: null, modifiedAt: null, icon: null, tags: [] } as unknown as VaultEntry
|
||||
}
|
||||
|
||||
describe('useVaultBridge', () => {
|
||||
const onSelectNote = vi.fn()
|
||||
let reloadVault: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reloadVault = vi.fn().mockResolvedValue([])
|
||||
})
|
||||
|
||||
function renderBridge(entries: VaultEntry[] = [], activeTabPath: string | null = null) {
|
||||
const entriesByPath = new Map(entries.map(e => [e.path, e]))
|
||||
return renderHook(() =>
|
||||
useVaultBridge({
|
||||
entriesByPath,
|
||||
resolvedPath: '/vault',
|
||||
reloadVault,
|
||||
onSelectNote,
|
||||
activeTabPath,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it('opens a note by path when entry exists', () => {
|
||||
const entry = makeEntry('/vault/note.md')
|
||||
const { result } = renderBridge([entry])
|
||||
|
||||
act(() => { result.current.openNoteByPath('/vault/note.md') })
|
||||
|
||||
expect(onSelectNote).toHaveBeenCalledWith(entry)
|
||||
expect(reloadVault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a note by relative path', () => {
|
||||
const entry = makeEntry('/vault/note.md')
|
||||
const { result } = renderBridge([entry])
|
||||
|
||||
act(() => { result.current.openNoteByPath('note.md') })
|
||||
|
||||
expect(onSelectNote).toHaveBeenCalledWith(entry)
|
||||
})
|
||||
|
||||
it('reloads vault when entry not found', async () => {
|
||||
const fresh = makeEntry('/vault/new.md')
|
||||
reloadVault.mockResolvedValue([fresh])
|
||||
const { result } = renderBridge([])
|
||||
|
||||
await act(async () => { result.current.openNoteByPath('/vault/new.md') })
|
||||
|
||||
expect(reloadVault).toHaveBeenCalled()
|
||||
expect(onSelectNote).toHaveBeenCalledWith(fresh)
|
||||
})
|
||||
|
||||
it('handlePulseOpenNote opens existing entry', () => {
|
||||
const entry = makeEntry('/vault/pulse.md')
|
||||
const { result } = renderBridge([entry])
|
||||
|
||||
act(() => { result.current.handlePulseOpenNote('pulse.md') })
|
||||
|
||||
expect(onSelectNote).toHaveBeenCalledWith(entry)
|
||||
})
|
||||
|
||||
it('handlePulseOpenNote does nothing for missing entry', () => {
|
||||
const { result } = renderBridge([])
|
||||
|
||||
act(() => { result.current.handlePulseOpenNote('missing.md') })
|
||||
|
||||
expect(onSelectNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleAgentFileCreated reloads and opens created note', async () => {
|
||||
const fresh = makeEntry('/vault/created.md')
|
||||
reloadVault.mockResolvedValue([fresh])
|
||||
const { result } = renderBridge([])
|
||||
|
||||
await act(async () => { result.current.handleAgentFileCreated('created.md') })
|
||||
|
||||
expect(reloadVault).toHaveBeenCalled()
|
||||
expect(onSelectNote).toHaveBeenCalledWith(fresh)
|
||||
})
|
||||
|
||||
it('handleAgentFileModified reloads when active tab matches', () => {
|
||||
const { result } = renderBridge([], '/vault/active.md')
|
||||
|
||||
act(() => { result.current.handleAgentFileModified('active.md') })
|
||||
|
||||
expect(reloadVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleAgentFileModified does not reload for different tab', () => {
|
||||
const { result } = renderBridge([], '/vault/other.md')
|
||||
|
||||
act(() => { result.current.handleAgentFileModified('active.md') })
|
||||
|
||||
expect(reloadVault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleAgentVaultChanged always reloads', () => {
|
||||
const { result } = renderBridge([])
|
||||
|
||||
act(() => { result.current.handleAgentVaultChanged() })
|
||||
|
||||
expect(reloadVault).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
56
src/hooks/useVaultBridge.ts
Normal file
56
src/hooks/useVaultBridge.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface VaultBridgeDeps {
|
||||
entriesByPath: Map<string, VaultEntry>
|
||||
resolvedPath: string
|
||||
reloadVault: () => Promise<unknown>
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
activeTabPath: string | null
|
||||
}
|
||||
|
||||
function findEntry(entriesByPath: Map<string, VaultEntry>, resolvedPath: string, path: string): VaultEntry | undefined {
|
||||
return entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
|
||||
}
|
||||
|
||||
function findInFresh(entries: unknown, resolvedPath: string, path: string): VaultEntry | undefined {
|
||||
return (entries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
}
|
||||
|
||||
export function useVaultBridge({
|
||||
entriesByPath, resolvedPath, reloadVault, onSelectNote, activeTabPath,
|
||||
}: VaultBridgeDeps) {
|
||||
const reloadAndOpen = useCallback((path: string) => {
|
||||
reloadVault().then(fresh => {
|
||||
const entry = findInFresh(fresh, resolvedPath, path)
|
||||
if (entry) onSelectNote(entry)
|
||||
})
|
||||
}, [reloadVault, onSelectNote, resolvedPath])
|
||||
|
||||
const openNoteByPath = useCallback((path: string) => {
|
||||
const entry = findEntry(entriesByPath, resolvedPath, path)
|
||||
if (entry) onSelectNote(entry)
|
||||
else reloadAndOpen(path)
|
||||
}, [entriesByPath, resolvedPath, onSelectNote, reloadAndOpen])
|
||||
|
||||
const handlePulseOpenNote = useCallback((relativePath: string) => {
|
||||
const entry = findEntry(entriesByPath, resolvedPath, `${resolvedPath}/${relativePath}`)
|
||||
?? entriesByPath.get(relativePath)
|
||||
if (entry) onSelectNote(entry)
|
||||
}, [entriesByPath, resolvedPath, onSelectNote])
|
||||
|
||||
const handleAgentFileModified = useCallback((relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
if (activeTabPath === relativePath || activeTabPath === fullPath) reloadVault()
|
||||
}, [reloadVault, activeTabPath, resolvedPath])
|
||||
|
||||
const handleAgentVaultChanged = useCallback(() => { reloadVault() }, [reloadVault])
|
||||
|
||||
return {
|
||||
openNoteByPath,
|
||||
handlePulseOpenNote,
|
||||
handleAgentFileCreated: reloadAndOpen,
|
||||
handleAgentFileModified,
|
||||
handleAgentVaultChanged,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user