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

@@ -165,13 +165,16 @@ async fn github_get_user(token: String) -> Result<GitHubUser, String> {
}
#[tauri::command]
fn search_vault(
async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
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<usize, String>) {

View File

@@ -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<Option<HashMap<String, String>>> = 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();
}