diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index edf1b111..81c778df 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -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}"); diff --git a/src/App.tsx b/src/App.tsx index 6d872b4a..53e2c364 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { - 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} /> + 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} /> setToastMessage(null)} /> diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 0452b23d..a089abd6 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -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( + + ) + 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( + + ) + fireEvent.click(screen.getByTestId('status-indexed-time')) + expect(onReindexVault).toHaveBeenCalledOnce() + }) + + it('hides indexed time badge when no lastIndexedTime', () => { + render( + + ) + 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') + }) }) diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 7dd1cb59..e31f08fc 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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 = { 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 }) + }) +})