diff --git a/src/App.tsx b/src/App.tsx index 37e1eab9..965d55d6 100644 --- a/src/App.tsx +++ b/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('get_conflict_files', { vaultPath: resolvedPath }) - : await mockInvoke('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 : mockInvoke)('get_note_content', { path: filePath }) - // Check remaining conflicts - const remaining = await (isTauri() ? invoke : mockInvoke)('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() { <>
{selection.kind === 'filter' && selection.filter === 'pulse' ? ( - setViewMode('all')} /> + setViewMode('all')} /> ) : ( )} @@ -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} />
@@ -627,7 +462,7 @@ function App() { /> )} - 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} /> + 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} /> setToastMessage(null)} /> @@ -643,7 +478,7 @@ function App() { onResolveFile={conflictResolver.resolveFile} onOpenInEditor={conflictResolver.openInEditor} onCommit={conflictResolver.commitResolution} - onClose={handleCloseConflictResolver} + onClose={conflictFlow.handleCloseConflictResolver} /> ({ + 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(), + 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') + }) +}) diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts new file mode 100644 index 00000000..aaef4adf --- /dev/null +++ b/src/hooks/useAppSave.ts @@ -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, +): { 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) => void + setTabs: Parameters[0]['setTabs'] + setToastMessage: (msg: string | null) => void + loadModifiedFiles: () => void + clearUnsaved: (path: string) => void + unsavedPaths: Set + tabs: TabState[] + activeTabPath: string | null + handleRenameNote: (path: string, newTitle: string, vaultPath: string, onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void) => Promise + replaceEntry: (oldPath: string, newEntry: Partial & { 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, + } +} diff --git a/src/hooks/useConflictFlow.test.ts b/src/hooks/useConflictFlow.test.ts new file mode 100644 index 00000000..3c91f20f --- /dev/null +++ b/src/hooks/useConflictFlow.test.ts @@ -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) + }) +}) diff --git a/src/hooks/useConflictFlow.ts b/src/hooks/useConflictFlow.ts new file mode 100644 index 00000000..783d9604 --- /dev/null +++ b/src/hooks/useConflictFlow.ts @@ -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(cmd: string, args: Record): Promise { + return isTauri() ? invoke(cmd, args) : mockInvoke(cmd, args) +} + +interface ConflictFlowDeps { + resolvedPath: string + entries: VaultEntry[] + conflictFiles: string[] + pausePull: () => void + resumePull: () => void + triggerSync: () => void + reloadVault: () => Promise + 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 { + return tauriCall('get_conflict_files', { vaultPath }) +} + +async function resolveAndCheck( + vaultPath: string, filePath: string, strategy: 'ours' | 'theirs', +): Promise { + const relativePath = filePath.replace(vaultPath + '/', '') + await tauriCall('git_resolve_conflict', { vaultPath, file: relativePath, strategy }) + return fetchConflictFiles(vaultPath) +} + +async function commitMergeResolution(vaultPath: string): Promise { + 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, + } +} diff --git a/src/hooks/useVaultBridge.test.ts b/src/hooks/useVaultBridge.test.ts new file mode 100644 index 00000000..ae871284 --- /dev/null +++ b/src/hooks/useVaultBridge.test.ts @@ -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 + + 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() + }) +}) diff --git a/src/hooks/useVaultBridge.ts b/src/hooks/useVaultBridge.ts new file mode 100644 index 00000000..9e8e1d17 --- /dev/null +++ b/src/hooks/useVaultBridge.ts @@ -0,0 +1,56 @@ +import { useCallback } from 'react' +import type { VaultEntry } from '../types' + +interface VaultBridgeDeps { + entriesByPath: Map + resolvedPath: string + reloadVault: () => Promise + onSelectNote: (entry: VaultEntry) => void + activeTabPath: string | null +} + +function findEntry(entriesByPath: Map, 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, + } +}