From 5bcd344d5f2d338dc6027fcd35266ce5191d05aa Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Mar 2026 21:36:10 +0100 Subject: [PATCH] fix: replace 'Index error' with graceful handling on fresh installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/src/lib.rs | 4 +- src/App.tsx | 2 +- src/components/StatusBar.test.tsx | 35 +++++- src/components/StatusBar.tsx | 23 ++-- src/hooks/useIndexing.test.ts | 186 +++++++++--------------------- src/hooks/useIndexing.ts | 50 +++++--- 6 files changed, 141 insertions(+), 159 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b376c0c4..b68fb89e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index a4bc9365..4103ae08 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -467,7 +467,7 @@ function App() { - 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} /> + 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} /> setToastMessage(null)} /> diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index f0757361..23628b13 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -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( ) - expect(screen.getByText('Index error')).toBeInTheDocument() + expect(screen.getByText('Index failed — retry')).toBeInTheDocument() + }) + + it('hides indexing badge when phase is unavailable', () => { + render( + + ) + expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument() + }) + + it('calls onRetryIndexing when clicking error badge', () => { + const onRetryIndexing = vi.fn() + render( + + ) + fireEvent.click(screen.getByTestId('status-indexing')) + expect(onRetryIndexing).toHaveBeenCalledOnce() }) it('hides indexing badge when phase is idle', () => { diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 882275ae..26822c37 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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 = { 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 ( <> | - + {isActive ? : @@ -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 && } - {indexingProgress && } + {indexingProgress && }
{noteCount.toLocaleString()} notes diff --git a/src/hooks/useIndexing.test.ts b/src/hooks/useIndexing.test.ts index e046b0e1..06ced88f 100644 --- a/src/hooks/useIndexing.test.ts +++ b/src/hooks/useIndexing.test.ts @@ -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') }) }) diff --git a/src/hooks/useIndexing.ts b/src/hooks/useIndexing.ts index 5c6fa133..9f5d5125 100644 --- a/src/hooks/useIndexing.ts +++ b/src/hooks/useIndexing.ts @@ -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 } }