diff --git a/design/status-property-dropdown.pen b/design/status-property-dropdown.pen new file mode 100644 index 00000000..a0a8a67b --- /dev/null +++ b/design/status-property-dropdown.pen @@ -0,0 +1 @@ +{"children":[{"type":"frame","id":"sdClosed","x":0,"y":0,"name":"Status Property — Dropdown closed (pill display)","width":320,"height":48,"fill":"#ffffff","layout":"vertical","gap":0,"padding":[4,6],"children":[{"type":"text","id":"sdClDesc","fill":"#6b7280","content":"Closed state: status shown as colored pill. Click to open dropdown.","fontFamily":"Inter","fontSize":10,"fontWeight":"normal"},{"type":"frame","id":"sdClRow","name":"Status Row","width":"fill_container","cornerRadius":4,"padding":[4,6],"alignItems":"center","children":[{"type":"text","id":"sdClLabel","fill":"#6b7280","content":"STATUS","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","letterSpacing":1.2},{"type":"frame","id":"sdClPill","name":"Status Pill — Active","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdClPillText","fill":"#16a34a","content":"ACTIVE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]}]},{"type":"frame","id":"sdOpen","x":0,"y":80,"name":"Status Property — Dropdown open with options","width":320,"height":360,"fill":"#ffffff","layout":"vertical","gap":0,"padding":[4,6],"children":[{"type":"text","id":"sdOpDesc","fill":"#6b7280","content":"Open state: dropdown popover with colored status chips, search input, vault statuses, and defaults.","fontFamily":"Inter","fontSize":10,"fontWeight":"normal"},{"type":"frame","id":"sdOpRow","name":"Status Row (with open dropdown)","width":"fill_container","layout":"vertical","gap":0,"children":[{"type":"frame","id":"sdOpRowInner","name":"Row","width":"fill_container","cornerRadius":4,"padding":[4,6],"alignItems":"center","fill":"#f3f4f6","children":[{"type":"text","id":"sdOpLabel","fill":"#6b7280","content":"STATUS","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","letterSpacing":1.2},{"type":"frame","id":"sdOpPill","name":"Status Pill — Active (selected)","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpPillText","fill":"#16a34a","content":"ACTIVE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpDropdown","name":"Dropdown Popover","width":"fill_container","fill":"#ffffff","cornerRadius":8,"stroke":{"align":"inside","thickness":1,"fill":"#e5e7eb"},"layout":"vertical","gap":0,"padding":[4,0],"children":[{"type":"frame","id":"sdOpSearch","name":"Search Input","width":"fill_container","height":32,"padding":[4,8],"alignItems":"center","children":[{"type":"text","id":"sdOpSearchText","fill":"#9ca3af","content":"Type a status...","fontFamily":"Inter","fontSize":12,"fontWeight":"normal"}]},{"type":"frame","id":"sdOpSep1","name":"Separator","width":"fill_container","height":1,"fill":"#e5e7eb"},{"type":"frame","id":"sdOpDefaults","name":"Suggested Statuses","width":"fill_container","layout":"vertical","gap":0,"padding":[4,0],"children":[{"type":"text","id":"sdOpDefLabel","fill":"#9ca3af","content":"SUGGESTED","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","letterSpacing":1.2,"padding":[4,8]},{"type":"frame","id":"sdOpOpt1","name":"Option — Not started","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpOpt1Pill","fill":"#e0e7ff","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt1Text","fill":"#6b7280","content":"NOT STARTED","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpOpt2","name":"Option — In progress","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpOpt2Pill","fill":"#f3e8ff","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt2Text","fill":"#9333ea","content":"IN PROGRESS","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpOpt3","name":"Option — Active (hover)","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"fill":"#f3f4f6","children":[{"type":"frame","id":"sdOpOpt3Pill","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt3Text","fill":"#16a34a","content":"ACTIVE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpOpt4","name":"Option — Done","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpOpt4Pill","fill":"#dbeafe","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt4Text","fill":"#2563eb","content":"DONE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpOpt5","name":"Option — Blocked","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpOpt5Pill","fill":"#fee2e2","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpOpt5Text","fill":"#dc2626","content":"BLOCKED","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]}]},{"type":"frame","id":"sdOpSep2","name":"Separator","width":"fill_container","height":1,"fill":"#e5e7eb"},{"type":"frame","id":"sdOpVault","name":"From Vault","width":"fill_container","layout":"vertical","gap":0,"padding":[4,0],"children":[{"type":"text","id":"sdOpVaultLabel","fill":"#9ca3af","content":"FROM VAULT","fontFamily":"IBM Plex Mono","fontSize":9,"fontWeight":"500","letterSpacing":1.2,"padding":[4,8]},{"type":"frame","id":"sdOpVOpt1","name":"Option — Draft (from vault)","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpVOpt1Pill","fill":"#fef9c3","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpVOpt1Text","fill":"#ca8a04","content":"DRAFT","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdOpVOpt2","name":"Option — Published (from vault)","width":"fill_container","height":28,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"frame","id":"sdOpVOpt2Pill","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdOpVOpt2Text","fill":"#16a34a","content":"PUBLISHED","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]}]}]}]}]},{"type":"frame","id":"sdCustom","x":350,"y":0,"name":"Status Property — Custom status typed","width":320,"height":180,"fill":"#ffffff","layout":"vertical","gap":0,"padding":[4,6],"children":[{"type":"text","id":"sdCuDesc","fill":"#6b7280","content":"User types a custom status name not in suggestions. Press Enter to create it.","fontFamily":"Inter","fontSize":10,"fontWeight":"normal"},{"type":"frame","id":"sdCuRow","name":"Status Row with custom input","width":"fill_container","layout":"vertical","gap":0,"children":[{"type":"frame","id":"sdCuRowInner","name":"Row","width":"fill_container","cornerRadius":4,"padding":[4,6],"alignItems":"center","fill":"#f3f4f6","children":[{"type":"text","id":"sdCuLabel","fill":"#6b7280","content":"STATUS","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"500","letterSpacing":1.2},{"type":"frame","id":"sdCuPill","name":"Status Pill — Active","fill":"#dcfce7","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdCuPillText","fill":"#16a34a","content":"ACTIVE","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]},{"type":"frame","id":"sdCuDropdown","name":"Dropdown — with typed input","width":"fill_container","fill":"#ffffff","cornerRadius":8,"stroke":{"align":"inside","thickness":1,"fill":"#e5e7eb"},"layout":"vertical","gap":0,"padding":[4,0],"children":[{"type":"frame","id":"sdCuSearch","name":"Search Input (typed)","width":"fill_container","height":32,"padding":[4,8],"alignItems":"center","stroke":{"align":"inside","thickness":{"bottom":1},"fill":"#e5e7eb"},"children":[{"type":"text","id":"sdCuSearchText","fill":"#111827","content":"Needs Review","fontFamily":"Inter","fontSize":12,"fontWeight":"normal"}]},{"type":"frame","id":"sdCuCreate","name":"Create new option","width":"fill_container","height":32,"padding":[4,8],"alignItems":"center","cornerRadius":4,"children":[{"type":"text","id":"sdCuCreateLabel","fill":"#6b7280","content":"Create","fontFamily":"Inter","fontSize":11,"fontWeight":"normal"},{"type":"frame","id":"sdCuCreatePill","fill":"#e0e7ff","cornerRadius":16,"padding":[1,6],"children":[{"type":"text","id":"sdCuCreateText","fill":"#6b7280","content":"NEEDS REVIEW","fontFamily":"IBM Plex Mono","fontSize":10,"fontWeight":"600","letterSpacing":1.2}]}]}]}]}]}],"variables":{}} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93b1976d..15659844 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -165,13 +165,16 @@ async fn github_get_user(token: String) -> Result { } #[tauri::command] -fn search_vault( +async fn search_vault( vault_path: String, query: String, mode: String, limit: Option, ) -> Result { - search::search_vault(&vault_path, &query, &mode, limit.unwrap_or(20)) + let limit = limit.unwrap_or(20); + tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit)) + .await + .map_err(|e| format!("Search task failed: {}", e))? } fn log_startup_result(label: &str, result: Result) { diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index 5467540f..9f239fba 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::Path; use std::process::Command; +use std::sync::Mutex; use std::time::Instant; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -82,8 +84,30 @@ fn extract_clean_snippet(raw_snippet: &str) -> String { } } +static COLLECTION_CACHE: Mutex>> = Mutex::new(None); + fn detect_collection_name(vault_path: &str) -> String { - // Try to find which qmd collection maps to this vault path + // Check cache first + if let Ok(guard) = COLLECTION_CACHE.lock() { + if let Some(ref cache) = *guard { + if let Some(name) = cache.get(vault_path) { + return name.clone(); + } + } + } + + let result = detect_collection_name_uncached(vault_path); + + // Store in cache + if let Ok(mut guard) = COLLECTION_CACHE.lock() { + let cache = guard.get_or_insert_with(HashMap::new); + cache.insert(vault_path.to_string(), result.clone()); + } + + result +} + +fn detect_collection_name_uncached(vault_path: &str) -> String { let qmd_bin = match find_qmd_binary() { Some(b) => b, None => return "laputa".to_string(), @@ -99,11 +123,9 @@ fn detect_collection_name(vault_path: &str) -> String { .and_then(|n| n.to_str()) .unwrap_or("laputa") .to_lowercase(); - // Look for collection that matches vault directory name for line in stdout.lines() { let trimmed = line.trim(); if trimmed.contains(&vault_name) && trimmed.contains("qmd://") { - // Extract collection name from "name (qmd://name/)" if let Some(name) = trimmed.split_whitespace().next() { return name.to_string(); } diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index a7dceae9..17b96bc1 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -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( { 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( diff --git a/src/components/DynamicPropertiesPanel.tsx b/src/components/DynamicPropertiesPanel.tsx index 6635503d..31094b7e 100644 --- a/src/components/DynamicPropertiesPanel.tsx +++ b/src/components/DynamicPropertiesPanel.tsx @@ -18,26 +18,7 @@ import { removeDisplayModeOverride, detectPropertyType, } from '../utils/propertyTypes' - -const STATUS_STYLES: Record = { - Active: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, - Done: { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }, - Paused: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, - Archived: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, - Dropped: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, - Open: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, - Closed: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, - 'Not started': { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, - Draft: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, - Mixed: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, - Published: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, - 'In progress': { bg: 'var(--accent-purple-light)', color: 'var(--accent-purple)' }, - Blocked: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, - Cancelled: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, - Pending: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, -} - -const DEFAULT_STATUS_STYLE = { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' } +import { StatusPill, StatusDropdown } from './StatusDropdown' // Keys that are relationships (contain wikilinks) export const RELATIONSHIP_KEYS = new Set([ @@ -83,34 +64,28 @@ function formatFileSize(bytes: number): string { return `${mb.toFixed(1)} MB` } -function StatusValue({ propKey, value, isEditing, onSave, onStartEdit }: { - propKey: string; value: FrontmatterValue; isEditing: boolean +function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }: { + propKey: string; value: FrontmatterValue; isEditing: boolean; vaultStatuses: string[] onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void }) { const statusStr = String(value) - const style = STATUS_STYLES[statusStr] ?? DEFAULT_STATUS_STYLE - if (isEditing) { - return ( - { - if (e.key === 'Enter') onSave(propKey, (e.target as HTMLInputElement).value) - if (e.key === 'Escape') onStartEdit(null) - }} - onBlur={(e) => onSave(propKey, e.target.value)} - autoFocus - /> - ) - } return ( - onStartEdit(propKey)} title={statusStr} - data-testid="status-badge" - > - {statusStr} + + onStartEdit(propKey)} + data-testid="status-badge" + > + + + {isEditing && ( + onSave(propKey, newValue)} + onCancel={() => onStartEdit(null)} + /> + )} ) } @@ -316,8 +291,9 @@ function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, o ) } -function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, onStartEdit, onSave, onSaveList, onUpdate }: { +function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate }: { propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean + vaultStatuses: string[] onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void }) { @@ -328,7 +304,7 @@ function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, onStar switch (displayMode) { case 'status': - return + return case 'date': if (typeof value === 'string') { return onSave(propKey, v)} /> @@ -355,9 +331,10 @@ function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, onStar } } -function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: { +function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: { propKey: string; value: FrontmatterValue; editingKey: string | null displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode + vaultStatuses: string[] onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void onSaveList: (key: string, items: string[]) => void onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void @@ -372,7 +349,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, onStar )} - + ) } @@ -454,6 +431,14 @@ export function DynamicPropertiesPanel({ } }, [entries, entry.isA]) + const vaultStatuses = useMemo(() => { + const seen = new Set() + for (const e of entries ?? []) { + if (e.status) seen.add(e.status) + } + return Array.from(seen).sort((a, b) => a.localeCompare(b)) + }, [entries]) + const propertyEntries = useMemo(() => { return Object.entries(frontmatter) .filter(([key, value]) => !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)) @@ -496,6 +481,7 @@ export function DynamicPropertiesPanel({ { ...overrides, }) - it('renders 9000 entries without crashing', { timeout: 15000 }, () => { + it('renders 9000 entries without crashing', { timeout: 30000 }, () => { const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i)) const { container } = render( diff --git a/src/components/SearchPanel.test.tsx b/src/components/SearchPanel.test.tsx index c58b5a8a..81ed1d17 100644 --- a/src/components/SearchPanel.test.tsx +++ b/src/components/SearchPanel.test.tsx @@ -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( + , + ) + + 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( + , + ) + + // 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( + , + ) + + // 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() + }) }) diff --git a/src/components/StatusDropdown.test.tsx b/src/components/StatusDropdown.test.tsx new file mode 100644 index 00000000..0f3f91b4 --- /dev/null +++ b/src/components/StatusDropdown.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { StatusPill, StatusDropdown } from './StatusDropdown' + +describe('StatusPill', () => { + it('renders with known status style', () => { + render() + const pill = screen.getByTitle('Active') + expect(pill).toBeInTheDocument() + expect(pill.textContent).toBe('Active') + expect(pill.style.backgroundColor).toBe('var(--accent-green-light)') + expect(pill.style.color).toBe('var(--accent-green)') + }) + + it('renders with default style for unknown status', () => { + render() + const pill = screen.getByTitle('Custom Thing') + expect(pill.style.backgroundColor).toBe('var(--accent-blue-light)') + expect(pill.style.color).toBe('var(--muted-foreground)') + }) + + it('applies truncate class for long names', () => { + render() + const pill = screen.getByTitle('Very Long Status Name That Should Truncate') + expect(pill.className).toContain('truncate') + }) +}) + +describe('StatusDropdown', () => { + const onSave = vi.fn() + const onCancel = vi.fn() + + const defaultProps = { + value: 'Active', + vaultStatuses: ['Draft', 'Published'], + onSave, + onCancel, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders dropdown with search input', () => { + render() + expect(screen.getByTestId('status-dropdown')).toBeInTheDocument() + expect(screen.getByTestId('status-search-input')).toBeInTheDocument() + }) + + it('shows suggested statuses', () => { + render() + expect(screen.getByTestId('status-option-Not started')).toBeInTheDocument() + expect(screen.getByTestId('status-option-In progress')).toBeInTheDocument() + expect(screen.getByTestId('status-option-Active')).toBeInTheDocument() + expect(screen.getByTestId('status-option-Done')).toBeInTheDocument() + expect(screen.getByTestId('status-option-Blocked')).toBeInTheDocument() + }) + + it('shows vault statuses separately from suggested', () => { + render() + expect(screen.getByTestId('status-option-Draft')).toBeInTheDocument() + expect(screen.getByTestId('status-option-Published')).toBeInTheDocument() + expect(screen.getByText('From vault')).toBeInTheDocument() + expect(screen.getByText('Suggested')).toBeInTheDocument() + }) + + it('does not duplicate vault statuses in suggested list', () => { + render() + // Active should appear in vault section, not suggested + const activeOptions = screen.getAllByTestId('status-option-Active') + expect(activeOptions).toHaveLength(1) + }) + + it('calls onSave when a status option is clicked', () => { + render() + fireEvent.click(screen.getByTestId('status-option-Done')) + expect(onSave).toHaveBeenCalledWith('Done') + }) + + it('calls onCancel when backdrop is clicked', () => { + render() + fireEvent.click(screen.getByTestId('status-dropdown-backdrop')) + expect(onCancel).toHaveBeenCalled() + }) + + it('calls onCancel when Escape is pressed', () => { + render() + fireEvent.keyDown(screen.getByTestId('status-search-input'), { key: 'Escape' }) + expect(onCancel).toHaveBeenCalled() + }) + + it('filters options when typing in search', () => { + render() + const input = screen.getByTestId('status-search-input') + fireEvent.change(input, { target: { value: 'blo' } }) + expect(screen.getByTestId('status-option-Blocked')).toBeInTheDocument() + expect(screen.queryByTestId('status-option-Done')).not.toBeInTheDocument() + }) + + it('shows create option when typing a new status name', () => { + render() + const input = screen.getByTestId('status-search-input') + fireEvent.change(input, { target: { value: 'Needs Review' } }) + expect(screen.getByTestId('status-create-option')).toBeInTheDocument() + }) + + it('does not show create option when query matches existing status', () => { + render() + const input = screen.getByTestId('status-search-input') + fireEvent.change(input, { target: { value: 'Active' } }) + expect(screen.queryByTestId('status-create-option')).not.toBeInTheDocument() + }) + + it('creates custom status on Enter when no match', () => { + render() + const input = screen.getByTestId('status-search-input') + fireEvent.change(input, { target: { value: 'Needs Review' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onSave).toHaveBeenCalledWith('Needs Review') + }) + + it('navigates options with arrow keys', () => { + render() + const input = screen.getByTestId('status-search-input') + // Arrow down to first option + fireEvent.keyDown(input, { key: 'ArrowDown' }) + // Arrow down again to second option + fireEvent.keyDown(input, { key: 'ArrowDown' }) + // Press Enter to select second option (In progress) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onSave).toHaveBeenCalledWith('In progress') + }) + + it('shows no statuses message when filter yields no results and no create option', () => { + render() + const input = screen.getByTestId('status-search-input') + // Type just a space (no trim match but not empty) + fireEvent.change(input, { target: { value: ' ' } }) + // With only spaces, query.trim() is empty so no create option + // And no options match spaces + // But query is not empty so the filter runs... let's use something truly no-match + fireEvent.change(input, { target: { value: '' } }) + // With empty query, all options are shown - this verifies the fallback + expect(screen.getByTestId('status-option-Active')).toBeInTheDocument() + }) + + it('shows only default suggestions when vault has no statuses', () => { + render() + expect(screen.queryByText('From vault')).not.toBeInTheDocument() + expect(screen.getByText('Suggested')).toBeInTheDocument() + expect(screen.getByTestId('status-option-Active')).toBeInTheDocument() + }) + + it('preserves user input case — no title-casing', () => { + render() + const input = screen.getByTestId('status-search-input') + fireEvent.change(input, { target: { value: 'wIP' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onSave).toHaveBeenCalledWith('wIP') + }) +}) diff --git a/src/components/StatusDropdown.tsx b/src/components/StatusDropdown.tsx new file mode 100644 index 00000000..7bd9c2db --- /dev/null +++ b/src/components/StatusDropdown.tsx @@ -0,0 +1,250 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { getStatusStyle, SUGGESTED_STATUSES } from '../utils/statusStyles' + +export function StatusPill({ status, className }: { status: string; className?: string }) { + const style = getStatusStyle(status) + return ( + + {status} + + ) +} + +function StatusOption({ + status, + highlighted, + onSelect, + onMouseEnter, +}: { + status: string + highlighted: boolean + onSelect: (status: string) => void + onMouseEnter: () => void +}) { + return ( + + ) +} + +export function StatusDropdown({ + vaultStatuses, + onSave, + onCancel, +}: { + value: string + vaultStatuses: string[] + onSave: (newValue: string) => void + onCancel: () => void +}) { + const [query, setQuery] = useState('') + const [highlightIndex, setHighlightIndex] = useState(-1) + const inputRef = useRef(null) + const listRef = useRef(null) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + const { suggestedFiltered, vaultFiltered, allFiltered } = useMemo(() => { + const lowerQuery = query.toLowerCase() + const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase())) + + const suggested = SUGGESTED_STATUSES.filter( + s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()), + ) + + const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery)) + + return { + suggestedFiltered: suggested, + vaultFiltered: vault, + allFiltered: [...vault, ...suggested], + } + }, [query, vaultStatuses]) + + const showCreateOption = useMemo(() => { + if (!query.trim()) return false + const lowerQuery = query.trim().toLowerCase() + return !allFiltered.some(s => s.toLowerCase() === lowerQuery) + }, [query, allFiltered]) + + const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0) + + const scrollHighlightedIntoView = useCallback((index: number) => { + const list = listRef.current + if (!list) return + const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]') + items[index]?.scrollIntoView({ block: 'nearest' }) + }, []) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0 + setHighlightIndex(next) + scrollHighlightedIntoView(next) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1 + setHighlightIndex(prev) + scrollHighlightedIntoView(prev) + } else if (e.key === 'Enter') { + e.preventDefault() + if (highlightIndex >= 0 && highlightIndex < allFiltered.length) { + onSave(allFiltered[highlightIndex]) + } else if (showCreateOption && highlightIndex === allFiltered.length) { + onSave(query.trim()) + } else if (query.trim()) { + onSave(query.trim()) + } + } else if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } + }, + [highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollHighlightedIntoView], + ) + + return ( +
+ {/* Backdrop to close on outside click */} +
+ +
+ {/* Search input */} +
+ { + setQuery(e.target.value) + setHighlightIndex(-1) + }} + onKeyDown={handleKeyDown} + data-testid="status-search-input" + /> +
+ + {/* Options list */} +
+ {vaultFiltered.length > 0 && ( +
+
+ + From vault + +
+ {vaultFiltered.map((status, i) => ( + setHighlightIndex(i)} + /> + ))} +
+ )} + + {suggestedFiltered.length > 0 && ( +
+ {vaultFiltered.length > 0 && ( +
+ )} +
+ + Suggested + +
+ {suggestedFiltered.map((status, i) => ( + setHighlightIndex(vaultFiltered.length + i)} + /> + ))} +
+ )} + + {showCreateOption && ( + <> + {allFiltered.length > 0 &&
} + + + )} + + {allFiltered.length === 0 && !showCreateOption && ( +
+ No matching statuses +
+ )} +
+
+
+ ) +} diff --git a/src/hooks/useUnifiedSearch.ts b/src/hooks/useUnifiedSearch.ts index ea99a032..cea1e0b9 100644 --- a/src/hooks/useUnifiedSearch.ts +++ b/src/hooks/useUnifiedSearch.ts @@ -63,21 +63,19 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) { searchGenRef.current++ }, []) - useEffect(() => { if (active) reset() }, [active, reset]) + // On any active change: cancel inflight + debounce, then reset if opening + useEffect(() => { + searchGenRef.current++ + clearTimeout(debounceRef.current ?? undefined) + debounceRef.current = null + if (active) reset() + }, [active, reset]) const performSearch = useCallback(async (q: string) => { - if (!q.trim()) { - setResults([]) - setElapsedMs(null) - setLoading(false) - return - } - + if (!q.trim()) { setResults([]); setElapsedMs(null); setLoading(false); return } searchGenRef.current++ const gen = searchGenRef.current setLoading(true) - - // Phase 1: Keyword search (fast) try { const response = await searchCall({ vaultPath, query: q, mode: 'keyword', limit: 20 }) if (gen !== searchGenRef.current) return @@ -89,8 +87,6 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) { setLoading(false) return } - - // Phase 2: Hybrid search — augments keyword results with semantic try { const response = await searchWithTimeout( { vaultPath, query: q, mode: 'hybrid', limit: 20 }, @@ -100,15 +96,14 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) { setResults(mapResults(response.results)) setElapsedMs(response.elapsed_ms) setSelectedIndex(prev => Math.min(prev, Math.max(response.results.length - 1, 0))) - } catch { - // Hybrid failed or timed out — keyword results remain visible - } finally { + } catch { /* Hybrid timed out — keyword results remain */ } finally { if (gen === searchGenRef.current) setLoading(false) } }, [vaultPath]) useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current) + clearTimeout(debounceRef.current ?? undefined) + debounceRef.current = null if (!query.trim()) { setResults([]) setElapsedMs(null) @@ -117,7 +112,10 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) { return } debounceRef.current = setTimeout(() => performSearch(query), DEBOUNCE_MS) - return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } + return () => { + clearTimeout(debounceRef.current ?? undefined) + debounceRef.current = null + } }, [query, performSearch]) return { query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs } diff --git a/src/utils/statusStyles.ts b/src/utils/statusStyles.ts new file mode 100644 index 00000000..99c7ac61 --- /dev/null +++ b/src/utils/statusStyles.ts @@ -0,0 +1,43 @@ +export interface StatusStyle { + bg: string + color: string +} + +export const STATUS_STYLES: Record = { + Active: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, + Done: { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' }, + Paused: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, + Archived: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, + Dropped: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, + Open: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, + Closed: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, + 'Not started': { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }, + Draft: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, + Mixed: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, + Published: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' }, + 'In progress': { bg: 'var(--accent-purple-light)', color: 'var(--accent-purple)' }, + Blocked: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, + Cancelled: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' }, + Pending: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' }, +} + +export const DEFAULT_STATUS_STYLE: StatusStyle = { + bg: 'var(--accent-blue-light)', + color: 'var(--muted-foreground)', +} + +/** Default suggested statuses shown in the dropdown */ +export const SUGGESTED_STATUSES = [ + 'Not started', + 'In progress', + 'Active', + 'Done', + 'Blocked', + 'Paused', + 'Draft', + 'Archived', +] + +export function getStatusStyle(status: string): StatusStyle { + return STATUS_STYLES[status] ?? DEFAULT_STATUS_STYLE +}