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

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