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:
Test
2026-03-29 10:35:13 +02:00
parent 0488a3c505
commit e46b9ecb1b
7 changed files with 675 additions and 219 deletions

View 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
View 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,
}
}

View 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)
})
})

View 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,
}
}

View 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()
})
})

View 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,
}
}