feat: status property — Notion-style dropdown with color chips (#97)

* fix: resolve search panel freeze by making search_vault async

The search_vault Tauri command was synchronous (fn, not async fn),
blocking the main thread for 30+ seconds during hybrid/semantic
search on large vaults (9200+ files). This caused the macOS beachball.

Changes:
- Make search_vault async with tokio::spawn_blocking (runs qmd off main thread)
- Cache collection name per vault path (avoid repeated qmd collection list calls)
- Cancel inflight searches and debounce timers when panel closes
- Add regression test for stale result cancellation on panel close

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: add status property dropdown wireframes

Three frames: closed pill state, open dropdown with suggested/vault
statuses, and custom status creation flow.

Also bump flaky NoteList 9000-entry test timeout from 15s to 30s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: replace status text edit with Notion-style dropdown

- Extract STATUS_STYLES to shared statusStyles.ts utility
- New StatusDropdown component: filterable popover with colored pills
- StatusPill reusable component for consistent status chip rendering
- Vault-wide status aggregation from entries prop
- Dropdown shows "From vault" and "Suggested" sections
- Custom status creation via type-and-Enter
- Escape/backdrop click cancels without saving
- Keyboard navigation (ArrowUp/Down + Enter)
- 22 new tests covering dropdown behavior + integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: cargo fmt

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-26 13:40:27 +01:00
committed by GitHub
parent 9c81caca46
commit d6b7343dac
11 changed files with 648 additions and 77 deletions

View File

@@ -368,4 +368,42 @@ describe('SearchPanel', () => {
// Keyword results remain
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
})
it('cancels inflight searches when panel closes', async () => {
const resolvers: ((v: unknown) => void)[] = []
mockInvokeFn.mockImplementation(
() => new Promise(resolve => { resolvers.push(resolve) }),
)
const { rerender } = 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: 'slow query' } })
// Wait for keyword search to start
await waitFor(() => {
expect(resolvers).toHaveLength(1)
})
// Close the panel while search is inflight
rerender(
<SearchPanel open={false} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
)
// Resolve the inflight keyword search — should be discarded (stale generation)
resolvers[0]({
results: [{ title: 'Stale Result', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
elapsed_ms: 30,
})
// Reopen panel
rerender(
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
)
// Should NOT show the stale result — panel was reset
expect(screen.queryByText('Stale Result')).not.toBeInTheDocument()
expect(screen.getByText('Search across all note contents')).toBeInTheDocument()
})
})