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:
Test
2026-03-24 16:18:05 +01:00
parent 50ad7e0e8c
commit ecbb94ae83
42 changed files with 148 additions and 16520 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

@@ -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',
}

View File

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