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:
273
src/App.tsx
273
src/App.tsx
@@ -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
|
||||
|
||||
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