fix: replace 'Index error' with graceful handling on fresh installs

Separate 'unavailable' (qmd not installed) from 'error' (indexing failed)
phases. Unavailable state is hidden from the status bar instead of showing
a persistent orange error. Actual errors show "Index failed — retry" with
click-to-retry. Both phases auto-dismiss after a timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-03 21:36:10 +01:00
parent 816e3ca8bd
commit 5bcd344d5f
6 changed files with 141 additions and 159 deletions

View File

@@ -391,11 +391,11 @@ async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Res
match indexing::auto_install_qmd() {
Ok(_) => log::info!("qmd auto-installed successfully"),
Err(e) => {
log::warn!("qmd auto-install failed: {e}");
log::info!("qmd not available (search disabled): {e}");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "error".to_string(),
phase: "unavailable".to_string(),
current: 0,
total: 0,
done: true,

View File

@@ -467,7 +467,7 @@ function App() {
</div>
</div>
<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={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRemoveVault={vaultSwitcher.removeVault} />
<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={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRetryIndexing={indexing.retryIndexing} onRemoveVault={vaultSwitcher.removeVault} />
<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} />

View File

@@ -265,17 +265,46 @@ describe('StatusBar', () => {
expect(screen.getByText('Index ready')).toBeInTheDocument()
})
it('shows error state in indexing badge', () => {
it('shows error state in indexing badge with retry label', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd not available' }}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
/>
)
expect(screen.getByText('Index error')).toBeInTheDocument()
expect(screen.getByText('Index failed — retry')).toBeInTheDocument()
})
it('hides indexing badge when phase is unavailable', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'unavailable', current: 0, total: 0, done: true, error: 'qmd not available' }}
/>
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('calls onRetryIndexing when clicking error badge', () => {
const onRetryIndexing = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
onRetryIndexing={onRetryIndexing}
/>
)
fireEvent.click(screen.getByTestId('status-indexing'))
expect(onRetryIndexing).toHaveBeenCalledOnce()
})
it('hides indexing badge when phase is idle', () => {

View File

@@ -32,6 +32,7 @@ interface StatusBarProps {
buildNumber?: string
onCheckForUpdates?: () => void
indexingProgress?: IndexingProgress
onRetryIndexing?: () => void
onRemoveVault?: (path: string) => void
}
@@ -237,23 +238,31 @@ const INDEXING_LABELS: Record<string, string> = {
scanning: 'Indexing…',
embedding: 'Embedding…',
complete: 'Index ready',
error: 'Index error',
error: 'Index failed — retry',
unavailable: 'Search unavailable',
}
function IndexingBadge({ progress }: { progress: IndexingProgress }) {
if (progress.phase === 'idle') return null
function IndexingBadge({ progress, onRetry }: { progress: IndexingProgress; onRetry?: () => void }) {
if (progress.phase === 'idle' || progress.phase === 'unavailable') return null
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const isError = progress.phase === 'error'
const showCount = progress.total > 0 && isActive
const displayText = showCount
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
: label
const color = progress.phase === 'error' ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
const color = isError ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
return (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color }} data-testid="status-indexing">
<span
role={isError && onRetry ? 'button' : undefined}
onClick={isError && onRetry ? onRetry : undefined}
style={{ ...ICON_STYLE, color, cursor: isError && onRetry ? 'pointer' : 'default' }}
title={isError ? 'Click to retry indexing' : undefined}
data-testid="status-indexing"
>
{isActive
? <Loader2 size={13} className="animate-spin" />
: <Search size={13} />
@@ -282,7 +291,7 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRemoveVault }: StatusBarProps) {
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRetryIndexing, onRemoveVault }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -308,7 +317,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} />}
{indexingProgress && <IndexingBadge progress={indexingProgress} onRetry={onRetryIndexing} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>

View File

@@ -1,23 +1,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useIndexing } from './useIndexing'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn().mockResolvedValue(vi.fn()) }))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn().mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
})
}
if (cmd === 'start_indexing') return Promise.resolve(null)
if (cmd === 'trigger_incremental_index') return Promise.resolve(null)
return Promise.resolve(null)
mockInvoke: vi.fn().mockResolvedValue({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
}),
}))
@@ -25,135 +20,60 @@ const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType
describe('useIndexing', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockInvoke.mockResolvedValue({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
})
})
it('starts with idle progress', () => {
const { result } = renderHook(() => useIndexing('/vault'))
afterEach(() => {
vi.useRealTimers()
})
it('starts with idle phase', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.progress.phase).toBe('idle')
expect(result.current.progress.done).toBe(false)
})
it('checks index status on mount', async () => {
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' })
})
it('auto-dismisses error phase after 15 seconds', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
// Simulate setting error state via retryIndexing
mockInvoke.mockRejectedValueOnce(new Error('qmd update failed'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('error')
act(() => { vi.advanceTimersByTime(15000) })
expect(result.current.progress.phase).toBe('idle')
})
it('does not start indexing when collection exists and no pending embeds', async () => {
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' })
})
expect(mockInvoke).not.toHaveBeenCalledWith('start_indexing', expect.anything())
it('sets unavailable phase for missing qmd errors', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
mockInvoke.mockRejectedValueOnce(new Error('bun not installed'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('unavailable')
})
it('starts indexing when collection is missing', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
return Promise.resolve(null)
})
it('auto-dismisses unavailable phase after 8 seconds', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
const { result } = renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
expect(result.current.progress.phase).not.toBe('idle')
mockInvoke.mockRejectedValueOnce(new Error('bun not installed'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('unavailable')
act(() => { vi.advanceTimersByTime(8000) })
expect(result.current.progress.phase).toBe('idle')
})
it('starts indexing when qmd is not installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
return Promise.resolve(null)
})
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
})
it('starts indexing when pending embeds exist', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 20,
})
}
return Promise.resolve(null)
})
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
})
it('triggerIncrementalIndex calls the command', async () => {
const { result } = renderHook(() => useIndexing('/vault'))
await act(async () => {
await result.current.triggerIncrementalIndex()
})
expect(mockInvoke).toHaveBeenCalledWith('trigger_incremental_index', { vaultPath: '/vault' })
})
it('handles index status check failure gracefully', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') return Promise.reject(new Error('network error'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
// Should remain idle — not crash
await waitFor(() => {
expect(result.current.progress.phase).toBe('idle')
})
})
it('sets error phase when start_indexing fails', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
if (cmd === 'start_indexing') return Promise.reject(new Error('install failed'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(result.current.progress.phase).toBe('error')
})
expect(result.current.progress.error).toContain('install failed')
it('exposes retryIndexing function', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(typeof result.current.retryIndexing).toBe('function')
})
})

View File

@@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export interface IndexingProgress {
phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error'
phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error' | 'unavailable'
current: number
total: number
done: boolean
@@ -74,16 +74,17 @@ export function useIndexing(vaultPath: string) {
})
// Fire and forget — progress updates come via events
invokeCmd('start_indexing', { vaultPath }).catch((err) => {
if (!cancelled) {
setProgress({
phase: 'error',
current: 0,
total: 0,
done: true,
error: String(err),
})
indexingRef.current = false
}
if (cancelled) return
const msg = String(err)
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
setProgress({
phase: isUnavailable ? 'unavailable' : 'error',
current: 0,
total: 0,
done: true,
error: msg,
})
indexingRef.current = false
})
}
} catch {
@@ -95,12 +96,20 @@ export function useIndexing(vaultPath: string) {
return () => { cancelled = true }
}, [vaultPath])
// Auto-dismiss the "complete" status after 5 seconds
// Auto-dismiss transient statuses after a delay
useEffect(() => {
if (progress.phase === 'complete') {
const timer = setTimeout(() => setProgress(IDLE), 5000)
return () => clearTimeout(timer)
}
if (progress.phase === 'unavailable') {
const timer = setTimeout(() => setProgress(IDLE), 8000)
return () => clearTimeout(timer)
}
if (progress.phase === 'error') {
const timer = setTimeout(() => setProgress(IDLE), 15000)
return () => clearTimeout(timer)
}
}, [progress.phase])
const triggerIncrementalIndex = useCallback(async () => {
@@ -112,5 +121,20 @@ export function useIndexing(vaultPath: string) {
}
}, [])
return { progress, triggerIncrementalIndex }
const retryIndexing = useCallback(async () => {
if (indexingRef.current || !vaultPathRef.current) return
indexingRef.current = true
setProgress({ phase: 'scanning', current: 0, total: 0, done: false, error: null })
try {
await invokeCmd('start_indexing', { vaultPath: vaultPathRef.current })
} catch (err) {
const msg = String(err)
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
setProgress({ phase: isUnavailable ? 'unavailable' : 'error', current: 0, total: 0, done: true, error: msg })
} finally {
indexingRef.current = false
}
}, [])
return { progress, triggerIncrementalIndex, retryIndexing }
}