{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 }
}