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:
lucaronin
2026-03-29 10:35:13 +02:00
parent 334ad09089
commit a42a15c30c
7 changed files with 675 additions and 219 deletions

View File

@@ -20,7 +20,6 @@ import { useMcpStatus } from './hooks/useMcpStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { needsRenameOnSave } from './hooks/useNoteRename'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
@@ -36,12 +35,14 @@ import { useZoom } from './hooks/useZoom'
import { useVaultConfig } from './hooks/useVaultConfig'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useAppNavigation } from './hooks/useAppNavigation'
import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { useDeleteActions } from './hooks/useDeleteActions'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useVaultBridge } from './hooks/useVaultBridge'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { UpdateBanner } from './components/UpdateBanner'
@@ -49,12 +50,10 @@ import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner'
import { useFlatVaultMigration } from './hooks/useFlatVaultMigration'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, VaultEntry, InboxPeriod } from './types'
import type { SidebarSelection, InboxPeriod } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openLocalFile } from './utils/url'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { flushEditorContent } from './utils/autoSave'
import './App.css'
// Type declarations for mock content storage and test overrides
@@ -111,9 +110,6 @@ function App() {
onToast: (msg) => setToastMessage(msg),
})
// Ref bridges for conflict resolution callbacks (notes declared below)
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
const conflictResolver = useConflictResolver({
vaultPath: resolvedPath,
onResolved: () => {
@@ -123,70 +119,10 @@ function App() {
autoSync.triggerSync()
},
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => openConflictFileRef.current(relativePath),
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const handleOpenConflictResolver = useCallback(async () => {
let files = autoSync.conflictFiles
// If no cached conflicts, check directly — there may be pre-existing
// conflicts from a prior session that the pull flow didn't detect.
if (files.length === 0) {
try {
files = isTauri()
? await invoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
: await mockInvoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
} catch {
return
}
if (files.length === 0) {
setToastMessage('No merge conflicts to resolve')
return
}
}
autoSync.pausePull()
conflictResolver.initFiles(files)
dialogs.openConflictResolver()
}, [autoSync, conflictResolver, dialogs, resolvedPath])
const handleCloseConflictResolver = useCallback(() => {
autoSync.resumePull()
dialogs.closeConflictResolver()
}, [autoSync, dialogs])
/** Resolve a single file conflict from the in-editor banner. */
const handleResolveConflictInline = useCallback(async (filePath: string, strategy: 'ours' | 'theirs') => {
try {
const relativePath = filePath.replace(resolvedPath + '/', '')
const call = isTauri() ? invoke : mockInvoke
await call('git_resolve_conflict', { vaultPath: resolvedPath, file: relativePath, strategy })
// Reload the note content to show the resolved version
const content = await (isTauri() ? invoke<string> : mockInvoke<string>)('get_note_content', { path: filePath })
// Check remaining conflicts
const remaining = await (isTauri() ? invoke<string[]> : mockInvoke<string[]>)('get_conflict_files', { vaultPath: resolvedPath })
if (remaining.length === 0) {
// All resolved — auto-commit the merge and push
await (isTauri() ? invoke : mockInvoke)('git_commit_conflict_resolution', { vaultPath: resolvedPath })
vault.reloadVault()
autoSync.triggerSync()
setToastMessage('All conflicts resolved — merge committed')
} else {
void content // content reload happens via vault reload
vault.reloadVault()
setToastMessage(`Resolved — ${remaining.length} conflict${remaining.length > 1 ? 's' : ''} remaining`)
}
} catch (err) {
setToastMessage(`Failed to resolve conflict: ${err}`)
}
}, [resolvedPath, vault, autoSync, setToastMessage])
const handleKeepMine = useCallback((path: string) => handleResolveConflictInline(path, 'ours'), [handleResolveConflictInline])
const handleKeepTheirs = useCallback((path: string) => handleResolveConflictInline(path, 'theirs'), [handleResolveConflictInline])
// Ref bridges handleContentChange (created after notes) into useNoteActions.
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
// Keep note entry in sync with vault entries so banners (trash/archive)
// and read-only state react immediately without reopening the note.
@@ -211,57 +147,44 @@ function App() {
onSelectNote: notes.handleSelectNote,
})
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
const openNoteByPath = useCallback((path: string) => {
const entry = entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
if (entry) {
notes.handleSelectNote(entry)
} else {
// Entry not yet in vault (just created) — reload then open
vault.reloadVault().then(freshEntries => {
const fresh = (freshEntries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
if (fresh) notes.handleSelectNote(fresh)
})
}
}, [entriesByPath, vault, notes, resolvedPath])
const vaultBridge = useVaultBridge({
entriesByPath, resolvedPath,
reloadVault: vault.reloadVault,
onSelectNote: notes.handleSelectNote,
activeTabPath: notes.activeTabPath,
})
const conflictFlow = useConflictFlow({
resolvedPath, entries: vault.entries,
conflictFiles: autoSync.conflictFiles,
pausePull: autoSync.pausePull, resumePull: autoSync.resumePull,
triggerSync: autoSync.triggerSync, reloadVault: vault.reloadVault,
initConflictFiles: conflictResolver.initFiles,
openConflictResolver: dialogs.openConflictResolver,
closeConflictResolver: dialogs.closeConflictResolver,
onSelectNote: notes.handleSelectNote,
activeTabPath: notes.activeTabPath,
setToastMessage,
})
const appSave = useAppSave({
updateEntry: vault.updateEntry, setTabs: notes.setTabs, setToastMessage,
loadModifiedFiles: vault.loadModifiedFiles,
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
handleRenameNote: notes.handleRenameNote,
replaceEntry: vault.replaceEntry, resolvedPath,
})
const aiActivity = useAiActivity({
onOpenNote: openNoteByPath,
onOpenTab: openNoteByPath,
onOpenNote: vaultBridge.openNoteByPath,
onOpenTab: vaultBridge.openNoteByPath,
onSetFilter: (filterType) => {
handleSetSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: () => { vault.reloadVault() },
})
// Stable callback for Pulse "open note" — never triggers reloadVault.
// Pulse files always exist in the vault; if somehow not found, silently skip.
const handlePulseOpenNote = useCallback((relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
if (entry) notes.handleSelectNote(entry)
}, [entriesByPath, resolvedPath, notes])
// Agent file operation handlers: auto-open created notes, live-refresh modified notes
const handleAgentFileCreated = useCallback((relativePath: string) => {
vault.reloadVault().then(freshEntries => {
const entry = (freshEntries as VaultEntry[]).find(e => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
if (entry) notes.handleSelectNote(entry)
})
}, [vault, notes, resolvedPath])
const handleAgentFileModified = useCallback((relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const currentPath = notes.activeTabPath
if (currentPath === relativePath || currentPath === fullPath) {
vault.reloadVault()
}
}, [vault, notes.activeTabPath, resolvedPath])
const handleAgentVaultChanged = useCallback(() => {
vault.reloadVault()
}, [vault])
const handleSetNoteIcon = useCallback(async (path: string, emoji: string) => {
await notes.handleUpdateFrontmatter(path, 'icon', emoji)
}, [notes])
@@ -270,103 +193,24 @@ function App() {
await notes.handleDeleteProperty(path, 'icon')
}, [notes])
/** Command palette: open the emoji picker on the active note's NoteIcon. */
const handleSetNoteIconCommand = useCallback(() => {
window.dispatchEvent(new CustomEvent('laputa:open-icon-picker'))
}, [])
/** Command palette: remove the active note's emoji icon. */
const handleRemoveNoteIconCommand = useCallback(() => {
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
}, [notes.activeTabPath, handleRemoveNoteIcon])
/** Open the active note in a new window (command palette / keyboard shortcut). */
const handleOpenInNewWindow = useCallback(() => {
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
if (activeTab) openNoteInNewWindow(activeTab.entry.path, resolvedPath, activeTab.entry.title)
}, [notes.tabs, notes.activeTabPath, resolvedPath]) // eslint-disable-line react-hooks/exhaustive-deps
}, [notes.tabs, notes.activeTabPath, resolvedPath])
/** Open a specific note entry in a new window (Cmd+Shift+Click). */
const handleOpenEntryInNewWindow = useCallback((entry: VaultEntry) => {
const handleOpenEntryInNewWindow = useCallback((entry: { path: string; title: string }) => {
openNoteInNewWindow(entry.path, resolvedPath, entry.title)
}, [resolvedPath])
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
}, [vault])
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature required by useEditorSave
const onNotePersisted = useCallback((path: string, _content: string) => {
vault.clearUnsaved(path)
}, [vault])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateEntry: vault.updateEntry,
setTabs: notes.setTabs, setToastMessage, onAfterSave,
onNotePersisted,
})
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
// Refs for stable closure in flushBeforeAction (avoids re-creating on every tab/content change)
const tabsRef = useRef(notes.tabs)
tabsRef.current = notes.tabs // eslint-disable-line react-hooks/refs -- ref sync pattern
const unsavedPathsRef = useRef(vault.unsavedPaths)
unsavedPathsRef.current = vault.unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern
/** Auto-save unsaved editor content before a destructive action (trash/archive). */
const { clearUnsaved: vaultClearUnsaved } = vault
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) => { vaultClearUnsaved(p) },
})
} catch (err) {
setToastMessage(`Auto-save failed: ${err}`)
throw err
}
}, [savePendingForPath, vaultClearUnsaved, setToastMessage])
// Wire conflict file opener now that notes is available
useEffect(() => {
openConflictFileRef.current = (relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath)
if (entry) {
// Markdown note — open inside Laputa editor
notes.handleSelectNote(entry)
dialogs.closeConflictResolver()
} else {
// Non-note file (e.g. settings.json) —
// open with system default app so the user can inspect/edit it
openLocalFile(fullPath)
}
}
}, [resolvedPath, vault.entries, notes, dialogs])
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
await savePendingForPath(path)
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
}, [notes, resolvedPath, vault, savePendingForPath])
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
// and trigger file rename when the title slug doesn't match the filename.
const handleSave = useCallback(async () => {
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
? { path: activeTab.entry.path, content: activeTab.content }
: undefined
await handleSaveRaw(fallback)
// After saving, check if filename needs to match the current title
if (activeTab && needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)) {
await handleRenameTab(activeTab.entry.path, activeTab.entry.title)
}
}, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const entryActions = useEntryActions({
entries: vault.entries, updateEntry: vault.updateEntry,
@@ -374,7 +218,7 @@ function App() {
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
createTypeEntry: notes.createTypeEntrySilent,
onFrontmatterPersisted: vault.loadModifiedFiles,
onBeforeAction: flushBeforeAction,
onBeforeAction: appSave.flushBeforeAction,
})
const deleteActions = useDeleteActions({
@@ -392,14 +236,6 @@ function App() {
setToastMessage(`Type "${name}" created`)
}, [notes])
/** Title field change: save pending content then rename file + update wikilinks (non-blocking). */
const handleTitleSync = useCallback((path: string, newTitle: string) => {
savePendingForPath(path)
.then(() => notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry))
.then(vault.loadModifiedFiles)
.catch((err) => console.error('Title rename failed:', err))
}, [notes, resolvedPath, vault, savePendingForPath])
const bulkActions = useBulkActions(entryActions, setToastMessage)
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
@@ -428,7 +264,6 @@ function App() {
} else if (result === 'error') {
setToastMessage('Could not check for updates')
}
// 'available' → UpdateBanner handles it automatically
}, [updateActions, updateStatus.state, setToastMessage])
const handleRepairVault = useCallback(async () => {
@@ -454,13 +289,13 @@ function App() {
onCreateNote: notes.handleCreateNoteImmediate,
onOpenDailyNote: notes.handleOpenDailyNote,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: handleSave,
onSave: appSave.handleSave,
onOpenSettings: dialogs.openSettings,
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog,
onPull: autoSync.triggerSync,
onResolveConflicts: handleOpenConflictResolver,
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onToggleDiff: () => diffToggleRef.current(),
@@ -555,7 +390,7 @@ function App() {
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} />
)}
@@ -594,9 +429,9 @@ function App() {
onDeleteNote={deleteActions.handleDeleteNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onContentChange={handleContentChange}
onSave={handleSave}
onTitleSync={handleTitleSync}
onContentChange={appSave.handleContentChange}
onSave={appSave.handleSave}
onTitleSync={appSave.handleTitleSync}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
@@ -604,14 +439,14 @@ function App() {
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
onFileCreated={handleAgentFileCreated}
onFileModified={handleAgentFileModified}
onVaultChanged={handleAgentVaultChanged}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
onSetNoteIcon={handleSetNoteIcon}
onRemoveNoteIcon={handleRemoveNoteIcon}
isConflicted={!!notes.activeTabPath && autoSync.conflictFiles.some(f => notes.activeTabPath?.endsWith(f))}
onKeepMine={handleKeepMine}
onKeepTheirs={handleKeepTheirs}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
/>
</div>
</div>
@@ -627,7 +462,7 @@ function App() {
/>
)}
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
@@ -643,7 +478,7 @@ function App() {
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={handleCloseConflictResolver}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
<GitHubVaultModal

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