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

@@ -18,26 +18,7 @@ import {
removeDisplayModeOverride,
detectPropertyType,
} from '../utils/propertyTypes'
const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
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 (
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
type="text" defaultValue={statusStr}
onKeyDown={(e) => {
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 (
<span
className="inline-block min-w-0 cursor-pointer truncate transition-opacity hover:opacity-80"
style={{ backgroundColor: style.bg, color: style.color, borderRadius: 16, padding: '1px 6px', fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase' as const }}
onClick={() => onStartEdit(propKey)} title={statusStr}
data-testid="status-badge"
>
{statusStr}
<span className="relative inline-flex min-w-0 items-center">
<span
className="cursor-pointer transition-opacity hover:opacity-80"
onClick={() => onStartEdit(propKey)}
data-testid="status-badge"
>
<StatusPill status={statusStr} />
</span>
{isEditing && (
<StatusDropdown
value={statusStr}
vaultStatuses={vaultStatuses}
onSave={(newValue) => onSave(propKey, newValue)}
onCancel={() => onStartEdit(null)}
/>
)}
</span>
)
}
@@ -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 <StatusValue propKey={propKey} value={value} isEditing={isEditing} onSave={onSave} onStartEdit={onStartEdit} />
return <StatusValue propKey={propKey} value={value} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
case 'date':
if (typeof value === 'string') {
return <DateValue value={value} onSave={(v) => 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
)}
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
</span>
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
)
}
@@ -454,6 +431,14 @@ export function DynamicPropertiesPanel({
}
}, [entries, entry.isA])
const vaultStatuses = useMemo(() => {
const seen = new Set<string>()
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({
<PropertyRow
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={effectiveMode} autoMode={autoMode}
vaultStatuses={vaultStatuses}
onStartEdit={setEditingKey} onSave={handleSaveValue}
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}