feat: reindex vault command, indexing status bar, sync-triggered reindex

- Add "Reindex Vault" command to command palette and menu bar
- Show "Indexed Xm ago" in status bar when idle, clickable to reindex
- After git pull with updates, auto-trigger incremental reindex
- Add lastIndexedTime state to useIndexing, populated from backend metadata
- Add triggerFullReindex to useIndexing (retryIndexing is now an alias)
- Add onSyncUpdated callback to useAutoSync
- Extract formatIndexedElapsed to utils/indexingHelpers.ts
- Tests: 20 new unit tests across 5 files, 2 Playwright smoke tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-07 01:14:12 +01:00
parent 5d8f514bea
commit de00ab6794
16 changed files with 332 additions and 13 deletions

View File

@@ -48,6 +48,7 @@ const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
const VAULT_REINDEX: &str = "vault-reindex";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
@@ -88,6 +89,7 @@ const CUSTOM_IDS: &[&str] = &[
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
];
/// IDs of menu items that should be disabled when no note tab is active.
@@ -332,6 +334,9 @@ fn build_vault_menu(app: &App) -> MenuResult {
let install_mcp = MenuItemBuilder::new("Install MCP Server")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reindex = MenuItemBuilder::new("Reindex Vault")
.id(VAULT_REINDEX)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Vault")
.item(&open_vault)
@@ -345,6 +350,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
.item(&resolve_conflicts)
.item(&view_changes)
.separator()
.item(&reindex)
.item(&install_mcp)
.build()?)
}
@@ -462,6 +468,7 @@ mod tests {
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");

View File

@@ -113,10 +113,13 @@ function App() {
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const indexing = useIndexing(resolvedPath)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: vault.reloadVault,
onSyncUpdated: indexing.triggerIncrementalIndex,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
@@ -127,8 +130,6 @@ function App() {
// Ref bridges for conflict resolution callbacks (notes declared below)
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
const indexing = useIndexing(resolvedPath)
const conflictResolver = useConflictResolver({
vaultPath: resolvedPath,
onResolved: () => {
@@ -466,6 +467,7 @@ function App() {
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
onReindexVault: indexing.triggerFullReindex,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
@@ -587,7 +589,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} onRetryIndexing={indexing.retryIndexing} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<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} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<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

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { StatusBar } from './StatusBar'
import type { VaultOption } from './StatusBar'
import { formatIndexedElapsed } from '../utils/indexingHelpers'
vi.mock('../utils/url', async () => {
const actual = await vi.importActual('../utils/url')
@@ -394,4 +395,71 @@ describe('StatusBar', () => {
fireEvent.click(screen.getByTestId('status-mcp'))
expect(onInstallMcp).not.toHaveBeenCalled()
})
it('shows "Indexed just now" when lastIndexedTime is recent and phase is idle', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
lastIndexedTime={Date.now() - 5000}
/>
)
expect(screen.getByText(/Indexed just now/)).toBeInTheDocument()
expect(screen.getByTestId('status-indexed-time')).toBeInTheDocument()
})
it('calls onReindexVault when clicking the indexed time badge', () => {
const onReindexVault = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
lastIndexedTime={Date.now() - 5000}
onReindexVault={onReindexVault}
/>
)
fireEvent.click(screen.getByTestId('status-indexed-time'))
expect(onReindexVault).toHaveBeenCalledOnce()
})
it('hides indexed time badge when no lastIndexedTime', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
/>
)
expect(screen.queryByTestId('status-indexed-time')).not.toBeInTheDocument()
})
})
describe('formatIndexedElapsed', () => {
it('returns empty string for null', () => {
expect(formatIndexedElapsed(null)).toBe('')
})
it('returns "Indexed just now" for < 60s', () => {
expect(formatIndexedElapsed(Date.now() - 30_000)).toBe('Indexed just now')
})
it('returns minutes for < 60min', () => {
expect(formatIndexedElapsed(Date.now() - 5 * 60_000)).toBe('Indexed 5m ago')
})
it('returns hours for < 24h', () => {
expect(formatIndexedElapsed(Date.now() - 3 * 3600_000)).toBe('Indexed 3h ago')
})
it('returns days for >= 24h', () => {
expect(formatIndexedElapsed(Date.now() - 48 * 3600_000)).toBe('Indexed 2d ago')
})
})

View File

@@ -4,6 +4,7 @@ import type { LastCommitInfo, SyncStatus } from '../types'
import type { IndexingProgress } from '../hooks/useIndexing'
import type { McpStatus } from '../hooks/useMcpStatus'
import { openExternalUrl } from '../utils/url'
import { formatIndexedElapsed } from '../utils/indexingHelpers'
export interface VaultOption {
label: string
@@ -33,7 +34,9 @@ interface StatusBarProps {
buildNumber?: string
onCheckForUpdates?: () => void
indexingProgress?: IndexingProgress
lastIndexedTime?: number | null
onRetryIndexing?: () => void
onReindexVault?: () => void
onRemoveVault?: (path: string) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
@@ -245,8 +248,32 @@ const INDEXING_LABELS: Record<string, string> = {
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 (
<>
<span style={SEP_STYLE}>|</span>
<span
role={onReindex ? 'button' : undefined}
onClick={onReindex}
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', cursor: onReindex ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title={onReindex ? 'Click to reindex vault' : undefined}
data-testid="status-indexed-time"
onMouseEnter={onReindex ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onReindex ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
>
<Search size={13} />{elapsed}
</span>
</>
)
}
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 && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} onRetry={onRetryIndexing} />}
{indexingProgress && <IndexingBadge progress={indexingProgress} lastIndexedTime={lastIndexedTime} onRetry={onRetryIndexing} onReindex={onReindexVault} />}
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>

View File

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

View File

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

View File

@@ -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<LastCommitInfo | null>(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<boolean> => {
@@ -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')

View File

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

View File

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

View File

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

View File

@@ -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<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
export function useIndexing(vaultPath: string) {
const [progress, setProgress] = useState<IndexingProgress>(IDLE)
const [lastIndexedTime, setLastIndexedTime] = useState<number | null>(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<IndexStatus>('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 }
}

View File

@@ -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<string | null>,
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()

View File

@@ -35,6 +35,7 @@ export interface MenuEventHandlers {
onResolveConflicts?: () => void
onViewChanges?: () => void
onInstallMcp?: () => void
onReindexVault?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
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<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
@@ -96,6 +97,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'vault-resolve-conflicts': 'onResolveConflicts',
'vault-view-changes': 'onViewChanges',
'vault-install-mcp': 'onInstallMcp',
'vault-reindex': 'onReindexVault',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {

View File

@@ -277,7 +277,7 @@ export const mockHandlers: Record<string, (args: any) => 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],

View File

@@ -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`
}

View File

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