fix: harden inbox auto-advance merge

This commit is contained in:
lucaronin
2026-04-24 14:35:54 +02:00
parent c24fd30266
commit 11655c0283
5 changed files with 70 additions and 27 deletions

View File

@@ -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}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={handleSaveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />

View File

@@ -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')
})
})
})

View File

@@ -3,7 +3,7 @@ import type { VaultEntry } from '../types'
interface BulkEntryActions {
handleArchiveNote: (path: string) => Promise<void>
handleToggleOrganized: (path: string) => Promise<void>
handleToggleOrganized: (path: string) => Promise<boolean>
}
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])

View File

@@ -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()

View File

@@ -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])