From 11655c0283c8ef18d53f9d0b861e183e0fcb87ef Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 24 Apr 2026 14:35:54 +0200 Subject: [PATCH] fix: harden inbox auto-advance merge --- src/App.tsx | 25 +++-------------- src/hooks/useBulkActions.test.ts | 15 ++++++++-- src/hooks/useBulkActions.ts | 5 ++-- src/hooks/useEntryActions.test.ts | 46 +++++++++++++++++++++++++++++++ src/hooks/useEntryActions.ts | 6 +++- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6dc6317a..1cbb8ed2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -74,7 +74,7 @@ import { DeleteProgressNotice } from './components/DeleteProgressNotice' import { UpdateBanner } from './components/UpdateBanner' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' -import type { Settings, SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types' +import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types' import type { NoteListItem } from './utils/ai-context' import { initializeNoteProperties } from './utils/initializeNoteProperties' import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' @@ -1103,23 +1103,6 @@ function App() { } }, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage]) - const handleSaveSettings = useCallback((nextSettings: Settings) => { - void (async () => { - const saved = await saveSettings(nextSettings) - if (!saved) return - - const guidancePreferenceChanged = ( - settings.default_resource_note_skill_enabled ?? false - ) !== ( - nextSettings.default_resource_note_skill_enabled ?? false - ) - - if (guidancePreferenceChanged) { - await restoreVaultAiGuidance('Tolaria AI guidance updated') - } - })() - }, [restoreVaultAiGuidance, saveSettings, settings.default_resource_note_skill_enabled]) - const activeDeletedFile = useMemo(() => { const activeTabPath = notes.activeTabPath if (!activeTabPath) return null @@ -1240,9 +1223,9 @@ function App() { ? getNextVisibleInboxEntry(visibleNotesRef.current, path) : null - await entryActions.handleToggleOrganized(path) + const organized = await entryActions.handleToggleOrganized(path) - if (nextVisibleInboxEntry) { + if (organized && nextVisibleInboxEntry) { void notes.handleSelectNote(nextVisibleInboxEntry) } }, [effectiveSelection, entryActions, notes, settings.auto_advance_inbox_after_organize, vault.entries]) @@ -1550,7 +1533,7 @@ function App() { onCommit={conflictResolver.commitResolution} onClose={conflictFlow.handleCloseConflictResolver} /> - + diff --git a/src/hooks/useBulkActions.test.ts b/src/hooks/useBulkActions.test.ts index 34b275b2..6b1eada4 100644 --- a/src/hooks/useBulkActions.test.ts +++ b/src/hooks/useBulkActions.test.ts @@ -16,7 +16,7 @@ describe('useBulkActions', () => { beforeEach(() => { handleArchiveNote = vi.fn().mockResolvedValue(undefined) - handleToggleOrganized = vi.fn().mockResolvedValue(undefined) + handleToggleOrganized = vi.fn().mockResolvedValue(true) setToastMessage = vi.fn() }) @@ -116,7 +116,7 @@ describe('useBulkActions', () => { it('skips failed organize actions and reports only successes', async () => { handleToggleOrganized - .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(true) .mockRejectedValueOnce(new Error('fail')) await runAction('handleBulkOrganize', [paths.a, paths.b]) @@ -124,5 +124,16 @@ describe('useBulkActions', () => { expect(handleToggleOrganized).toHaveBeenCalledTimes(2) expect(setToastMessage).toHaveBeenCalledWith('1 note organized') }) + + it('does not count organize rollbacks as successes', async () => { + handleToggleOrganized + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + + await runAction('handleBulkOrganize', [paths.a, paths.b]) + + expect(handleToggleOrganized).toHaveBeenCalledTimes(2) + expect(setToastMessage).toHaveBeenCalledWith('1 note organized') + }) }) }) diff --git a/src/hooks/useBulkActions.ts b/src/hooks/useBulkActions.ts index 74befe0f..d91bc1df 100644 --- a/src/hooks/useBulkActions.ts +++ b/src/hooks/useBulkActions.ts @@ -3,7 +3,7 @@ import type { VaultEntry } from '../types' interface BulkEntryActions { handleArchiveNote: (path: string) => Promise - handleToggleOrganized: (path: string) => Promise + handleToggleOrganized: (path: string) => Promise } function formatBulkToast(count: number, label: string) { @@ -43,8 +43,7 @@ export function useBulkActions( const handleBulkOrganize = useCallback(async (paths: string[]) => { const ok = await runBulkAction(paths, async (path) => { if (organizedPathSet.has(path)) return false - await entryActions.handleToggleOrganized(path) - return true + return entryActions.handleToggleOrganized(path) }) if (ok > 0) setToastMessage(formatBulkToast(ok, 'organized')) }, [entryActions, organizedPathSet, setToastMessage]) diff --git a/src/hooks/useEntryActions.test.ts b/src/hooks/useEntryActions.test.ts index 209aa7fd..716626c4 100644 --- a/src/hooks/useEntryActions.test.ts +++ b/src/hooks/useEntryActions.test.ts @@ -481,6 +481,52 @@ describe('useEntryActions', () => { }) }) + describe('handleToggleOrganized', () => { + it('returns true after organizing is persisted', async () => { + const entry = makeEntry({ path: '/vault/note/test.md', organized: false }) + const { result } = setup([entry]) + let organized = false + + await act(async () => { + organized = await result.current.handleToggleOrganized('/vault/note/test.md') + }) + + expect(organized).toBe(true) + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_organized', true, { silent: true }) + expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { organized: true }) + }) + + it('returns false and rolls back when organizing fails', async () => { + const entry = makeEntry({ path: '/vault/note/test.md', organized: false }) + handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full')) + const { result } = setup([entry]) + let organized = true + + await act(async () => { + organized = await result.current.handleToggleOrganized('/vault/note/test.md') + }) + + expect(organized).toBe(false) + expect(updateEntry).toHaveBeenCalledTimes(2) + expect(updateEntry).toHaveBeenNthCalledWith(1, '/vault/note/test.md', { organized: true }) + expect(updateEntry).toHaveBeenNthCalledWith(2, '/vault/note/test.md', { organized: false }) + expect(setToastMessage).toHaveBeenCalledWith('Failed to organize — rolled back') + }) + + it('returns false when the entry is missing', async () => { + const { result } = setup([]) + let organized = true + + await act(async () => { + organized = await result.current.handleToggleOrganized('/vault/missing.md') + }) + + expect(organized).toBe(false) + expect(handleUpdateFrontmatter).not.toHaveBeenCalled() + expect(handleDeleteProperty).not.toHaveBeenCalled() + }) + }) + describe('handleReorderFavorites', () => { it('updates _favorite_index for all reordered paths', async () => { const { result } = setup() diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index 00dc3211..5c609bf1 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -126,16 +126,18 @@ export function useEntryActions({ const handleToggleOrganized = useCallback(async (path: string) => { const entry = entries.find((e) => e.path === path) - if (!entry) return + if (!entry) return false if (entry.organized) { trackEvent('note_unorganized') updateEntry(path, { organized: false }) try { await handleDeleteProperty(path, '_organized', { silent: true }) onFrontmatterPersisted?.() + return true } catch { updateEntry(path, { organized: true }) setToastMessage('Failed to unorganize — rolled back') + return false } } else { trackEvent('note_organized') @@ -143,9 +145,11 @@ export function useEntryActions({ try { await handleUpdateFrontmatter(path, '_organized', true, { silent: true }) onFrontmatterPersisted?.() + return true } catch { updateEntry(path, { organized: false }) setToastMessage('Failed to organize — rolled back') + return false } } }, [entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted])