= {
unavailable: 'Search unavailable',
}
-function IndexingBadge({ progress, onRetry }: { progress: IndexingProgress; onRetry?: () => void }) {
- if (progress.phase === 'idle' || progress.phase === 'unavailable') return null
+function IndexingBadge({ progress, lastIndexedTime, onRetry, onReindex }: { progress: IndexingProgress; lastIndexedTime?: number | null; onRetry?: () => void; onReindex?: () => void }) {
+ const isIdle = progress.phase === 'idle' || progress.phase === 'unavailable'
+
+ // When idle, show "Indexed Xm ago" if we have a timestamp
+ if (isIdle) {
+ if (!lastIndexedTime) return null
+ const elapsed = formatIndexedElapsed(lastIndexedTime)
+ if (!elapsed) return null
+ return (
+ <>
+ |
+ { e.currentTarget.style.background = 'var(--hover)' } : undefined}
+ onMouseLeave={onReindex ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
+ >
+ {elapsed}
+
+ >
+ )
+ }
+
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const isError = progress.phase === 'error'
@@ -329,7 +356,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
)
}
-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, mcpStatus, onInstallMcp }: 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, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -355,7 +382,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
{lastCommitInfo && }
- {indexingProgress && }
+ {indexingProgress && }
{mcpStatus && }
diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts
index f0e701da..36f95866 100644
--- a/src/hooks/useAppCommands.ts
+++ b/src/hooks/useAppCommands.ts
@@ -66,6 +66,7 @@ interface AppCommandsConfig {
vaultCount?: number
mcpStatus?: string
onInstallMcp?: () => void
+ onReindexVault?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -148,6 +149,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onResolveConflicts: config.onResolveConflicts,
onViewChanges: viewChanges,
onInstallMcp: config.onInstallMcp,
+ onReindexVault: config.onReindexVault,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
@@ -201,6 +203,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
vaultCount: config.vaultCount,
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
+ onReindexVault: config.onReindexVault,
})
useKeyboardNavigation({
diff --git a/src/hooks/useAutoSync.test.ts b/src/hooks/useAutoSync.test.ts
index 49bfa149..0457e06f 100644
--- a/src/hooks/useAutoSync.test.ts
+++ b/src/hooks/useAutoSync.test.ts
@@ -255,6 +255,47 @@ describe('useAutoSync', () => {
expect(pullCalls).toHaveLength(0)
})
+ it('calls onSyncUpdated when pull has updates', async () => {
+ const onSyncUpdated = vi.fn()
+ mockInvokeFn.mockImplementation((cmd: string) => {
+ if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
+ return Promise.resolve(updated(['note.md']))
+ })
+ renderHook(() =>
+ useAutoSync({
+ vaultPath: '/Users/luca/Laputa',
+ intervalMinutes: 5,
+ onVaultUpdated,
+ onSyncUpdated,
+ onConflict,
+ onToast,
+ }),
+ )
+
+ await waitFor(() => {
+ expect(onSyncUpdated).toHaveBeenCalledOnce()
+ })
+ })
+
+ it('does not call onSyncUpdated when pull is up_to_date', async () => {
+ const onSyncUpdated = vi.fn()
+ renderHook(() =>
+ useAutoSync({
+ vaultPath: '/Users/luca/Laputa',
+ intervalMinutes: 5,
+ onVaultUpdated,
+ onSyncUpdated,
+ onConflict,
+ onToast,
+ }),
+ )
+
+ await waitFor(() => {
+ expect(onVaultUpdated).not.toHaveBeenCalled()
+ })
+ expect(onSyncUpdated).not.toHaveBeenCalled()
+ })
+
it('detects conflicts when git_pull returns error with unresolved conflicts', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_conflict_files') return Promise.resolve(['conflict.md'])
diff --git a/src/hooks/useAutoSync.ts b/src/hooks/useAutoSync.ts
index e1e39fe6..b4d148e3 100644
--- a/src/hooks/useAutoSync.ts
+++ b/src/hooks/useAutoSync.ts
@@ -13,6 +13,7 @@ interface UseAutoSyncOptions {
vaultPath: string
intervalMinutes: number | null
onVaultUpdated: () => void
+ onSyncUpdated?: () => void
onConflict: (files: string[]) => void
onToast: (msg: string) => void
}
@@ -33,6 +34,7 @@ export function useAutoSync({
vaultPath,
intervalMinutes,
onVaultUpdated,
+ onSyncUpdated,
onConflict,
onToast,
}: UseAutoSyncOptions): AutoSyncState {
@@ -42,8 +44,8 @@ export function useAutoSync({
const [lastCommitInfo, setLastCommitInfo] = useState(null)
const syncingRef = useRef(false)
const pauseRef = useRef(false)
- const callbacksRef = useRef({ onVaultUpdated, onConflict, onToast })
- callbacksRef.current = { onVaultUpdated, onConflict, onToast }
+ const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
+ callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
const checkExistingConflicts = useCallback(async (): Promise => {
@@ -77,6 +79,7 @@ export function useAutoSync({
setSyncStatus('idle')
setConflictFiles([])
callbacksRef.current.onVaultUpdated()
+ callbacksRef.current.onSyncUpdated?.()
callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
} else if (result.status === 'conflict') {
setSyncStatus('conflict')
diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts
index 0976550b..2481cbcb 100644
--- a/src/hooks/useCommandRegistry.test.ts
+++ b/src/hooks/useCommandRegistry.test.ts
@@ -95,6 +95,46 @@ describe('useCommandRegistry', () => {
expect(cmd!.enabled).toBe(false)
})
+ it('includes reindex-vault command in Settings group', () => {
+ const config = makeConfig({ onReindexVault: vi.fn() })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'reindex-vault')
+ expect(cmd).toBeDefined()
+ expect(cmd!.group).toBe('Settings')
+ expect(cmd!.label).toBe('Reindex Vault')
+ })
+
+ it('reindex-vault is enabled when onReindexVault is provided', () => {
+ const config = makeConfig({ onReindexVault: vi.fn() })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'reindex-vault')
+ expect(cmd!.enabled).toBe(true)
+ })
+
+ it('reindex-vault is disabled when onReindexVault is not provided', () => {
+ const config = makeConfig()
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'reindex-vault')
+ expect(cmd!.enabled).toBe(false)
+ })
+
+ it('reindex-vault executes onReindexVault callback', () => {
+ const onReindexVault = vi.fn()
+ const config = makeConfig({ onReindexVault })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'reindex-vault')
+ cmd!.execute()
+ expect(onReindexVault).toHaveBeenCalled()
+ })
+
+ it('reindex-vault has searchable keywords', () => {
+ const config = makeConfig({ onReindexVault: vi.fn() })
+ const { result } = renderHook(() => useCommandRegistry(config))
+ const cmd = findCommand(result.current, 'reindex-vault')
+ expect(cmd!.keywords).toContain('reindex')
+ expect(cmd!.keywords).toContain('search')
+ })
+
it('resolve-conflicts stays enabled across rerenders', () => {
const config = makeConfig()
const { result, rerender } = renderHook(
diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts
index 055de6c4..54ae7dc1 100644
--- a/src/hooks/useCommandRegistry.ts
+++ b/src/hooks/useCommandRegistry.ts
@@ -20,6 +20,7 @@ interface CommandRegistryConfig {
modifiedCount: number
mcpStatus?: string
onInstallMcp?: () => void
+ onReindexVault?: () => void
onQuickOpen: () => void
onCreateNote: () => void
@@ -196,6 +197,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
+ onReindexVault,
} = config
const hasActiveNote = activeTabPath !== null
@@ -257,6 +259,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
{ id: 'install-mcp', label: 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install'], enabled: mcpStatus === 'not_installed' && !!onInstallMcp, execute: () => onInstallMcp?.() },
+ { id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -275,5 +278,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
+ onReindexVault,
])
}
diff --git a/src/hooks/useIndexing.test.ts b/src/hooks/useIndexing.test.ts
index 7136f7df..d4bb88e6 100644
--- a/src/hooks/useIndexing.test.ts
+++ b/src/hooks/useIndexing.test.ts
@@ -84,4 +84,62 @@ describe('useIndexing', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(typeof result.current.retryIndexing).toBe('function')
})
+
+ it('exposes triggerFullReindex function', () => {
+ const { result } = renderHook(() => useIndexing('/test/vault'))
+ expect(typeof result.current.triggerFullReindex).toBe('function')
+ })
+
+ it('retryIndexing is the same reference as triggerFullReindex', () => {
+ const { result } = renderHook(() => useIndexing('/test/vault'))
+ expect(result.current.retryIndexing).toBe(result.current.triggerFullReindex)
+ })
+
+ it('triggerFullReindex sets scanning phase then completes', async () => {
+ const { result } = renderHook(() => useIndexing('/test/vault'))
+
+ await act(async () => { await result.current.triggerFullReindex() })
+ // In non-Tauri mode, it goes to 'complete' then auto-dismisses
+ expect(result.current.progress.phase).toBe('complete')
+ })
+
+ it('triggerFullReindex sets lastIndexedTime on success', async () => {
+ const { result } = renderHook(() => useIndexing('/test/vault'))
+
+ expect(result.current.lastIndexedTime).toBeNull()
+ await act(async () => { await result.current.triggerFullReindex() })
+ expect(result.current.lastIndexedTime).toBeGreaterThan(0)
+ })
+
+ it('triggerFullReindex sets error phase on failure', async () => {
+ const { result } = renderHook(() => useIndexing('/test/vault'))
+
+ mockInvoke.mockRejectedValueOnce(new Error('indexing failed'))
+ await act(async () => { await result.current.triggerFullReindex() })
+ expect(result.current.progress.phase).toBe('error')
+ })
+
+ it('populates lastIndexedTime from backend metadata on mount', async () => {
+ mockInvoke.mockResolvedValue({
+ available: true,
+ qmd_installed: true,
+ collection_exists: true,
+ indexed_count: 100,
+ embedded_count: 80,
+ pending_embed: 0,
+ last_indexed_commit: 'abc123',
+ last_indexed_at: 1709800000,
+ })
+
+ const { result } = renderHook(() => useIndexing('/test/vault'))
+
+ // Wait for the effect to run
+ await act(async () => { await vi.advanceTimersByTimeAsync(10) })
+ expect(result.current.lastIndexedTime).toBe(1709800000000) // seconds → ms
+ })
+
+ it('starts with lastIndexedTime as null', () => {
+ const { result } = renderHook(() => useIndexing('/test/vault'))
+ expect(result.current.lastIndexedTime).toBeNull()
+ })
})
diff --git a/src/hooks/useIndexing.ts b/src/hooks/useIndexing.ts
index e2690b13..a3eb5e7e 100644
--- a/src/hooks/useIndexing.ts
+++ b/src/hooks/useIndexing.ts
@@ -17,6 +17,8 @@ interface IndexStatus {
indexed_count: number
embedded_count: number
pending_embed: number
+ last_indexed_commit: string | null
+ last_indexed_at: number | null
}
const IDLE: IndexingProgress = { phase: 'idle', current: 0, total: 0, done: false, error: null }
@@ -27,6 +29,7 @@ function invokeCmd(cmd: string, args?: Record): Promise {
export function useIndexing(vaultPath: string) {
const [progress, setProgress] = useState(IDLE)
+ const [lastIndexedTime, setLastIndexedTime] = useState(null)
const indexingRef = useRef(false)
const vaultPathRef = useRef(vaultPath)
@@ -43,6 +46,9 @@ export function useIndexing(vaultPath: string) {
setProgress(event.payload)
if (event.payload.done) {
indexingRef.current = false
+ if (event.payload.phase === 'complete') {
+ setLastIndexedTime(Date.now())
+ }
}
})
cleanup = () => { unlisten.then(fn => fn()) }
@@ -61,6 +67,11 @@ export function useIndexing(vaultPath: string) {
const status = await invokeCmd('get_index_status', { vaultPath })
if (cancelled) return
+ // Populate last indexed time from backend metadata
+ if (status.last_indexed_at) {
+ setLastIndexedTime(status.last_indexed_at * 1000) // seconds → ms
+ }
+
// If qmd not installed or no collection or pending embeds, trigger indexing
const needsIndexing = !status.qmd_installed || !status.collection_exists || status.pending_embed > 0
if (needsIndexing && !indexingRef.current) {
@@ -116,17 +127,23 @@ export function useIndexing(vaultPath: string) {
if (indexingRef.current) return
try {
await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current })
+ setLastIndexedTime(Date.now())
} catch {
// Incremental update failure is non-fatal
}
}, [])
- const retryIndexing = useCallback(async () => {
+ const triggerFullReindex = 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 })
+ // In non-Tauri mode, mark complete immediately
+ if (!isTauri()) {
+ setProgress({ phase: 'complete', current: 0, total: 0, done: true, error: null })
+ setLastIndexedTime(Date.now())
+ }
} catch (err) {
const msg = String(err)
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
@@ -136,5 +153,7 @@ export function useIndexing(vaultPath: string) {
}
}, [])
- return { progress, triggerIncrementalIndex, retryIndexing }
+ const retryIndexing = triggerFullReindex
+
+ return { progress, lastIndexedTime, triggerIncrementalIndex, triggerFullReindex, retryIndexing }
}
diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts
index f7ebc91a..1e377226 100644
--- a/src/hooks/useMenuEvents.test.ts
+++ b/src/hooks/useMenuEvents.test.ts
@@ -34,6 +34,7 @@ function makeHandlers(): MenuEventHandlers {
onResolveConflicts: vi.fn(),
onViewChanges: vi.fn(),
onInstallMcp: vi.fn(),
+ onReindexVault: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
activeTabPath: '/vault/test.md',
@@ -299,6 +300,12 @@ describe('dispatchMenuEvent', () => {
expect(h.onInstallMcp).toHaveBeenCalled()
})
+ it('vault-reindex triggers reindex vault', () => {
+ const h = makeHandlers()
+ dispatchMenuEvent('vault-reindex', h)
+ expect(h.onReindexVault).toHaveBeenCalled()
+ })
+
// Edge cases
it('unknown event ID does nothing', () => {
const h = makeHandlers()
diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts
index b8880cd5..0ac7ec43 100644
--- a/src/hooks/useMenuEvents.ts
+++ b/src/hooks/useMenuEvents.ts
@@ -35,6 +35,7 @@ export interface MenuEventHandlers {
onResolveConflicts?: () => void
onViewChanges?: () => void
onInstallMcp?: () => void
+ onReindexVault?: () => void
activeTabPathRef: React.MutableRefObject
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
@@ -77,7 +78,7 @@ type OptionalHandler =
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCreateTheme' | 'onRestoreDefaultThemes'
- | 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp'
+ | 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault'
const OPTIONAL_EVENT_MAP: Record = {
'view-go-back': 'onGoBack',
@@ -96,6 +97,7 @@ const OPTIONAL_EVENT_MAP: Record = {
'vault-resolve-conflicts': 'onResolveConflicts',
'vault-view-changes': 'onViewChanges',
'vault-install-mcp': 'onInstallMcp',
+ 'vault-reindex': 'onReindexVault',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts
index bdd21ab0..74ee487b 100644
--- a/src/mock-tauri/mock-handlers.ts
+++ b/src/mock-tauri/mock-handlers.ts
@@ -277,7 +277,7 @@ export const mockHandlers: Record any> = {
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
- get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0 }),
+ get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0, last_indexed_commit: 'abc123', last_indexed_at: Math.floor(Date.now() / 1000) - 3600 }),
start_indexing: () => null,
trigger_incremental_index: () => null,
list_themes: (): ThemeFile[] => [...mockThemes],
diff --git a/src/utils/indexingHelpers.ts b/src/utils/indexingHelpers.ts
new file mode 100644
index 00000000..279ecd58
--- /dev/null
+++ b/src/utils/indexingHelpers.ts
@@ -0,0 +1,10 @@
+export function formatIndexedElapsed(lastIndexedTime: number | null): string {
+ if (!lastIndexedTime) return ''
+ const secs = Math.round((Date.now() - lastIndexedTime) / 1000)
+ if (secs < 60) return 'Indexed just now'
+ const mins = Math.floor(secs / 60)
+ if (mins < 60) return `Indexed ${mins}m ago`
+ const hrs = Math.floor(mins / 60)
+ if (hrs < 24) return `Indexed ${hrs}h ago`
+ return `Indexed ${Math.floor(hrs / 24)}d ago`
+}
diff --git a/tests/smoke/indexing-reindex-status.spec.ts b/tests/smoke/indexing-reindex-status.spec.ts
new file mode 100644
index 00000000..d2ae43b4
--- /dev/null
+++ b/tests/smoke/indexing-reindex-status.spec.ts
@@ -0,0 +1,28 @@
+import { test, expect } from '@playwright/test'
+import {
+ openCommandPalette,
+ findCommand,
+ executeCommand,
+} from './helpers'
+
+test.describe('Reindex Vault smoke tests', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ })
+
+ test('Reindex Vault command appears in command palette', async ({ page }) => {
+ await openCommandPalette(page)
+ const found = await findCommand(page, 'Reindex Vault')
+ expect(found).toBe(true)
+ })
+
+ test('Reindex Vault command triggers indexing progress', async ({ page }) => {
+ await openCommandPalette(page)
+ await executeCommand(page, 'Reindex Vault')
+
+ // After triggering reindex, the indexing badge should appear in the status bar
+ const indexingBadge = page.locator('[data-testid="status-indexing"], [data-testid="status-indexed-time"]')
+ await expect(indexingBadge.first()).toBeVisible({ timeout: 5_000 })
+ })
+})