refactor: remove QMD semantic indexing — keep keyword search only
Remove the entire QMD search engine (semantic indexing, hybrid search, vector embeddings) and replace with a simple walkdir-based keyword search. QMD never worked reliably in production, added bundle bloat, and showed "Index failed" in the status bar. What changed: - Rust: deleted indexing.rs, rewrote search.rs as pure keyword search - Frontend: removed useIndexing hook, IndexingBadge, hybrid search - Menu: removed "Reindex Vault" command from palette and menu bar - Config: removed qmd from bundle resources and build script - Deleted tools/qmd/ (QMD CLI tool, MCP server, tests) - Updated docs (ARCHITECTURE, ABSTRACTIONS, GETTING-STARTED) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -30,7 +30,6 @@ import { useGitHistory } from './hooks/useGitHistory'
|
||||
import { useUpdater, restartApp } from './hooks/useUpdater'
|
||||
import { useAutoSync } from './hooks/useAutoSync'
|
||||
import { useConflictResolver } from './hooks/useConflictResolver'
|
||||
import { useIndexing } from './hooks/useIndexing'
|
||||
import { useZoom } from './hooks/useZoom'
|
||||
import { useVaultConfig } from './hooks/useVaultConfig'
|
||||
import { useBuildNumber } from './hooks/useBuildNumber'
|
||||
@@ -98,13 +97,10 @@ function App() {
|
||||
const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault)
|
||||
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`)
|
||||
@@ -294,11 +290,9 @@ function App() {
|
||||
openNoteInNewWindow(entry.path, resolvedPath, entry.title)
|
||||
}, [resolvedPath])
|
||||
|
||||
const { triggerIncrementalIndex } = indexing
|
||||
const onAfterSave = useCallback(() => {
|
||||
vault.loadModifiedFiles()
|
||||
triggerIncrementalIndex()
|
||||
}, [vault, triggerIncrementalIndex])
|
||||
}, [vault])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature required by useEditorSave
|
||||
const onNotePersisted = useCallback((path: string, _content: string) => {
|
||||
@@ -507,7 +501,6 @@ function App() {
|
||||
onEmptyTrash: deleteActions.handleEmptyTrash,
|
||||
trashedCount: deleteActions.trashedCount,
|
||||
onReopenClosedTab: notes.handleReopenClosedTab,
|
||||
onReindexVault: indexing.triggerFullReindex,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
onSetNoteIcon: handleSetNoteIconCommand,
|
||||
@@ -640,7 +633,7 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
<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={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} 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} />
|
||||
<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={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} 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} />
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('SearchPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('performs unified search with keyword then hybrid', async () => {
|
||||
it('performs keyword search', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: '...designing APIs for AI...', score: 0.87, note_type: 'Essay' },
|
||||
@@ -140,7 +140,6 @@ describe('SearchPanel', () => {
|
||||
const input = screen.getByPlaceholderText('Search in all notes...')
|
||||
fireEvent.change(input, { target: { value: 'api design' } })
|
||||
|
||||
// Should call keyword search first
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
|
||||
vaultPath: '/vault',
|
||||
@@ -150,20 +149,9 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Results should appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should also call hybrid search
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
|
||||
vaultPath: '/vault',
|
||||
query: 'api design',
|
||||
mode: 'hybrid',
|
||||
limit: 20,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no results message when search returns empty', async () => {
|
||||
@@ -331,7 +319,7 @@ describe('SearchPanel', () => {
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
|
||||
|
||||
// Spinner appears when keyword search starts (after debounce)
|
||||
// Spinner appears when search starts (after debounce)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('search-spinner')).toBeInTheDocument()
|
||||
})
|
||||
@@ -342,21 +330,9 @@ describe('SearchPanel', () => {
|
||||
elapsed_ms: 30,
|
||||
})
|
||||
|
||||
// Keyword results appear, spinner still visible (hybrid in progress)
|
||||
// Spinner disappears after search completes
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('search-spinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Wait for hybrid call then resolve it
|
||||
await waitFor(() => { expect(resolvers).toHaveLength(2) })
|
||||
resolvers[1]({
|
||||
results: [{ title: 'Result', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
|
||||
elapsed_ms: 150,
|
||||
})
|
||||
|
||||
// Spinner disappears after hybrid completes
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -388,44 +364,12 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps keyword results when hybrid search fails', async () => {
|
||||
mockInvokeFn.mockImplementation(async (_cmd: string, args?: Record<string, unknown>) => {
|
||||
const mode = (args as Record<string, string>)?.mode
|
||||
if (mode === 'keyword') {
|
||||
return {
|
||||
results: [{ title: 'Keyword Only', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
|
||||
elapsed_ms: 30,
|
||||
}
|
||||
}
|
||||
throw new Error('qmd unavailable')
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
|
||||
|
||||
// Keyword results appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Spinner disappears after hybrid fails
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Keyword results remain
|
||||
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deduplicates results when backend returns same note twice', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'keyword hit', score: 0.7, note_type: 'Essay' },
|
||||
{ title: 'Refactoring Retreat', path: '/vault/event/retreat.md', snippet: 'unique', score: 0.6, note_type: 'Event' },
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'semantic hit', score: 0.9, note_type: 'Essay' },
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'duplicate hit', score: 0.9, note_type: 'Essay' },
|
||||
],
|
||||
elapsed_ms: 48,
|
||||
})
|
||||
|
||||
@@ -2,8 +2,6 @@ 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')
|
||||
return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) }
|
||||
@@ -226,121 +224,6 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows indexing badge when indexing is in progress', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'scanning', current: 342, total: 1057, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('status-indexing')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Indexing… 342\/1,057/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows embedding phase in indexing badge', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'embedding', current: 50, total: 200, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Embedding… 50\/200/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows index ready when indexing is complete', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'complete', current: 1057, total: 1057, done: true, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Index ready')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error state in indexing badge with retry label', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Index failed — retry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides indexing badge when phase is unavailable', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'unavailable', current: 0, total: 0, done: true, error: 'qmd not available' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRetryIndexing when clicking error badge', () => {
|
||||
const onRetryIndexing = vi.fn()
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
|
||||
onRetryIndexing={onRetryIndexing}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-indexing'))
|
||||
expect(onRetryIndexing).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides indexing badge when 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 }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides indexing badge when no progress prop provided', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows installing phase in indexing badge', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'installing', current: 0, total: 0, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Installing search…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows MCP warning badge when status is not_installed', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
|
||||
@@ -396,38 +279,6 @@ describe('StatusBar', () => {
|
||||
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('shows Pull required label when syncStatus is pull_required', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" />
|
||||
@@ -462,38 +313,4 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByText(/1 behind/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
|
||||
import type { GitRemoteStatus, 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
|
||||
@@ -35,10 +33,6 @@ interface StatusBarProps {
|
||||
onZoomReset?: () => void
|
||||
buildNumber?: string
|
||||
onCheckForUpdates?: () => void
|
||||
indexingProgress?: IndexingProgress
|
||||
lastIndexedTime?: number | null
|
||||
onRetryIndexing?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
@@ -334,70 +328,6 @@ function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void
|
||||
)
|
||||
}
|
||||
|
||||
const INDEXING_LABELS: Record<string, string> = {
|
||||
installing: 'Installing search…',
|
||||
scanning: 'Indexing…',
|
||||
embedding: 'Embedding…',
|
||||
complete: 'Index ready',
|
||||
error: 'Index failed — retry',
|
||||
unavailable: 'Search unavailable',
|
||||
}
|
||||
|
||||
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'
|
||||
const showCount = progress.total > 0 && isActive
|
||||
const displayText = showCount
|
||||
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
|
||||
: label
|
||||
const color = isError ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={isError && onRetry ? 'button' : undefined}
|
||||
onClick={isError && onRetry ? onRetry : undefined}
|
||||
style={{ ...ICON_STYLE, color, cursor: isError && onRetry ? 'pointer' : 'default' }}
|
||||
title={isError ? 'Click to retry indexing' : undefined}
|
||||
data-testid="status-indexing"
|
||||
>
|
||||
{isActive
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <Search size={13} />
|
||||
}
|
||||
{displayText}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
@@ -451,7 +381,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, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, 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, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
@@ -477,7 +407,6 @@ 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} lastIndexedTime={lastIndexedTime} onRetry={onRetryIndexing} onReindex={onReindexVault} />}
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
|
||||
@@ -64,7 +64,6 @@ interface AppCommandsConfig {
|
||||
onEmptyTrash?: () => void
|
||||
trashedCount?: number
|
||||
onReopenClosedTab?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
@@ -156,7 +155,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onResolveConflicts: config.onResolveConflicts,
|
||||
onViewChanges: viewChanges,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
@@ -212,7 +210,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
trashedCount: config.trashedCount,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
onSetNoteIcon: config.onSetNoteIcon,
|
||||
|
||||
@@ -95,46 +95,6 @@ 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(
|
||||
|
||||
@@ -25,7 +25,6 @@ interface CommandRegistryConfig {
|
||||
onInstallMcp?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
trashedCount?: number
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
@@ -169,7 +168,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
onReindexVault,
|
||||
onReloadVault,
|
||||
onRepairVault,
|
||||
onSetNoteIcon,
|
||||
@@ -256,7 +254,6 @@ 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: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
|
||||
{ id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
|
||||
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
|
||||
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
|
||||
|
||||
@@ -282,7 +279,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
vaultTypes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
|
||||
onReindexVault, onReloadVault, onRepairVault,
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
isSectionGroup, noteListFilter, onSetNoteListFilter,
|
||||
onOpenInNewWindow,
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
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().mockResolvedValue({
|
||||
available: true,
|
||||
qmd_installed: true,
|
||||
collection_exists: true,
|
||||
indexed_count: 100,
|
||||
embedded_count: 80,
|
||||
pending_embed: 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts with idle phase', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(result.current.progress.phase).toBe('idle')
|
||||
})
|
||||
|
||||
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('sets unavailable phase for "not installed" 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('sets unavailable phase for "not available" errors', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
mockInvoke.mockRejectedValueOnce(new Error('qmd not available: bun not found'))
|
||||
await act(async () => { await result.current.retryIndexing() })
|
||||
expect(result.current.progress.phase).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('auto-dismisses unavailable phase after 8 seconds', 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')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(8000) })
|
||||
expect(result.current.progress.phase).toBe('idle')
|
||||
})
|
||||
|
||||
it('exposes retryIndexing function', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
|
||||
export interface IndexingProgress {
|
||||
phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error' | 'unavailable'
|
||||
current: number
|
||||
total: number
|
||||
done: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface IndexStatus {
|
||||
available: boolean
|
||||
qmd_installed: boolean
|
||||
collection_exists: boolean
|
||||
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 }
|
||||
|
||||
function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
|
||||
// Listen for progress events from Rust
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
import('@tauri-apps/api/event').then(({ listen }) => {
|
||||
const unlisten = listen<IndexingProgress>('indexing-progress', (event) => {
|
||||
setProgress(event.payload)
|
||||
if (event.payload.done) {
|
||||
indexingRef.current = false
|
||||
if (event.payload.phase === 'complete') {
|
||||
setLastIndexedTime(Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
cleanup = () => { unlisten.then(fn => fn()) }
|
||||
})
|
||||
|
||||
return () => { cleanup?.() }
|
||||
}, [])
|
||||
|
||||
// Check index status and auto-trigger indexing on vault open
|
||||
useEffect(() => {
|
||||
if (!vaultPath) return
|
||||
let cancelled = false
|
||||
|
||||
async function checkAndIndex() {
|
||||
try {
|
||||
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) {
|
||||
indexingRef.current = true
|
||||
setProgress({
|
||||
phase: status.qmd_installed ? 'scanning' : 'installing',
|
||||
current: 0,
|
||||
total: status.indexed_count,
|
||||
done: false,
|
||||
error: null,
|
||||
})
|
||||
// Fire and forget — progress updates come via events
|
||||
invokeCmd('start_indexing', { vaultPath }).catch((err) => {
|
||||
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 {
|
||||
// get_index_status failed — likely qmd not available, non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
checkAndIndex()
|
||||
return () => { cancelled = true }
|
||||
}, [vaultPath])
|
||||
|
||||
// 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 () => {
|
||||
if (indexingRef.current) return
|
||||
try {
|
||||
await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current })
|
||||
setLastIndexedTime(Date.now())
|
||||
} catch {
|
||||
// Incremental update failure is non-fatal
|
||||
}
|
||||
}, [])
|
||||
|
||||
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')
|
||||
setProgress({ phase: isUnavailable ? 'unavailable' : 'error', current: 0, total: 0, done: true, error: msg })
|
||||
} finally {
|
||||
indexingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const retryIndexing = triggerFullReindex
|
||||
|
||||
return { progress, lastIndexedTime, triggerIncrementalIndex, triggerFullReindex, retryIndexing }
|
||||
}
|
||||
@@ -33,7 +33,6 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onResolveConflicts: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReindexVault: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onReopenClosedTab: vi.fn(),
|
||||
onOpenInNewWindow: vi.fn(),
|
||||
@@ -296,12 +295,6 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onInstallMcp).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-reindex triggers reindex vault', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-reindex', h)
|
||||
expect(h.onReindexVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-reload triggers reload vault', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-reload', h)
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface MenuEventHandlers {
|
||||
onInstallMcp?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
@@ -82,7 +81,7 @@ type OptionalHandler =
|
||||
| 'onGoBack' | 'onGoForward' | 'onCheckForUpdates'
|
||||
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
|
||||
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
|
||||
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onEmptyTrash'
|
||||
| 'onReopenClosedTab'
|
||||
| 'onOpenInNewWindow'
|
||||
@@ -103,7 +102,6 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-resolve-conflicts': 'onResolveConflicts',
|
||||
'vault-view-changes': 'onViewChanges',
|
||||
'vault-install-mcp': 'onInstallMcp',
|
||||
'vault-reindex': 'onReindexVault',
|
||||
'vault-reload': 'onReloadVault',
|
||||
'vault-repair': 'onRepairVault',
|
||||
'note-empty-trash': 'onEmptyTrash',
|
||||
|
||||
@@ -17,7 +17,6 @@ interface SearchResponseData {
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 300
|
||||
const HYBRID_TIMEOUT_MS = 5000
|
||||
|
||||
function searchCall(args: Record<string, unknown>): Promise<SearchResponseData> {
|
||||
return isTauri()
|
||||
@@ -25,16 +24,6 @@ function searchCall(args: Record<string, unknown>): Promise<SearchResponseData>
|
||||
: mockInvoke<SearchResponseData>('search_vault', args)
|
||||
}
|
||||
|
||||
function searchWithTimeout(args: Record<string, unknown>, ms: number): Promise<SearchResponseData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Search timeout')), ms)
|
||||
searchCall(args).then(
|
||||
result => { clearTimeout(timer); resolve(result) },
|
||||
err => { clearTimeout(timer); reject(err) },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function mapResults(raw: SearchResultData[]): SearchResult[] {
|
||||
const seen = new Set<string>()
|
||||
return raw
|
||||
@@ -92,19 +81,7 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) {
|
||||
setSelectedIndex(0)
|
||||
} catch {
|
||||
if (gen !== searchGenRef.current) return
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await searchWithTimeout(
|
||||
{ vaultPath, query: q, mode: 'hybrid', limit: 20 },
|
||||
HYBRID_TIMEOUT_MS,
|
||||
)
|
||||
if (gen !== searchGenRef.current) return
|
||||
setResults(mapResults(response.results))
|
||||
setElapsedMs(response.elapsed_ms)
|
||||
setSelectedIndex(prev => Math.min(prev, Math.max(response.results.length - 1, 0)))
|
||||
} catch { /* Hybrid timed out — keyword results remain */ } finally {
|
||||
} finally {
|
||||
if (gen === searchGenRef.current) setLoading(false)
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
@@ -265,9 +265,6 @@ 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, last_indexed_commit: 'abc123', last_indexed_at: Math.floor(Date.now() / 1000) - 3600 }),
|
||||
start_indexing: () => null,
|
||||
trigger_incremental_index: () => null,
|
||||
repair_vault: (): string => 'Vault repaired',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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`
|
||||
}
|
||||
Reference in New Issue
Block a user