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

@@ -350,7 +350,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('Modified')).toBeInTheDocument()
})
it('handles editing status', () => {
it('opens status dropdown on click and selects a status', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -359,12 +359,13 @@ describe('DynamicPropertiesPanel', () => {
onUpdateProperty={onUpdateProperty}
/>
)
// Click status pill to start editing (rendered with CSS uppercase, DOM text is "Active")
// Click status pill to open dropdown
fireEvent.click(screen.getByTitle('Active'))
// Should show edit input
const input = screen.getByDisplayValue('Active')
fireEvent.change(input, { target: { value: 'Done' } })
fireEvent.keyDown(input, { key: 'Enter' })
// Should show dropdown with search input
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
expect(screen.getByTestId('status-search-input')).toBeInTheDocument()
// Click on "Done" option in the suggested list
fireEvent.click(screen.getByTestId('status-option-Done'))
expect(onUpdateProperty).toHaveBeenCalledWith('Status', 'Done')
})
@@ -742,6 +743,74 @@ describe('DynamicPropertiesPanel', () => {
})
})
describe('status dropdown interaction', () => {
it('closes dropdown on Escape without saving', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Status: 'Active' }}
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
fireEvent.keyDown(screen.getByTestId('status-search-input'), { key: 'Escape' })
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
expect(onUpdateProperty).not.toHaveBeenCalled()
})
it('closes dropdown on backdrop click without saving', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Status: 'Active' }}
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
fireEvent.click(screen.getByTestId('status-dropdown-backdrop'))
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
expect(onUpdateProperty).not.toHaveBeenCalled()
})
it('creates custom status by typing and pressing Enter', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Status: 'Active' }}
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
const input = screen.getByTestId('status-search-input')
fireEvent.change(input, { target: { value: 'Needs Review' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onUpdateProperty).toHaveBeenCalledWith('Status', 'Needs Review')
})
it('shows vault statuses from entries', () => {
const entriesWithStatuses = [
makeEntry({ path: '/vault/a.md', status: 'Reviewing' }),
makeEntry({ path: '/vault/b.md', status: 'Shipped' }),
]
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Status: 'Active' }}
entries={entriesWithStatuses}
onUpdateProperty={onUpdateProperty}
/>
)
fireEvent.click(screen.getByTitle('Active'))
expect(screen.getByTestId('status-option-Reviewing')).toBeInTheDocument()
expect(screen.getByTestId('status-option-Shipped')).toBeInTheDocument()
})
})
describe('smart property display — boolean', () => {
it('renders boolean toggle for true values', () => {
render(