From 73060f3d00857a3446c2f7fb544ec9012b408d2b Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 9 Mar 2026 12:42:56 +0100 Subject: [PATCH] fix: exclude trashed notes from search results and autocomplete Trashed notes were appearing in search (Cmd+F), Quick Open (Ctrl+P), wikilink autocomplete ([[), and person mention autocomplete (@). Rust: add is_file_trashed() to check frontmatter, filter search_vault results. Frontend: filter trashed entries from useNoteSearch, baseItems in both editor views, and mock search_vault handler. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/search.rs | 10 +- src-tauri/src/vault/mod.rs | 2 +- src-tauri/src/vault/trash.rs | 90 +++++++++++++++++ src/components/RawEditorView.tsx | 2 +- src/components/SingleEditorView.tsx | 2 +- src/hooks/useNoteSearch.test.ts | 29 ++++++ src/hooks/useNoteSearch.ts | 2 +- src/mock-tauri/mock-handlers.ts | 1 + .../smoke/exclude-trashed-from-search.spec.ts | 98 +++++++++++++++++++ 9 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 tests/smoke/exclude-trashed-from-search.spec.ts diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index a40a1d21..ba60fc8e 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -1,4 +1,5 @@ use crate::indexing; +use crate::vault; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; @@ -160,16 +161,19 @@ pub fn search_vault( let results: Vec = qmd_results .into_iter() - .map(|r| { + .filter_map(|r| { let path = qmd_uri_to_vault_path(&r.file, vault_path); + if vault::is_file_trashed(Path::new(&path)) { + return None; + } let snippet = extract_clean_snippet(&r.snippet); - SearchResult { + Some(SearchResult { title: r.title, path, snippet, score: r.score, note_type: None, - } + }) }) .collect(); diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index f78ae7ad..a09bfd63 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -13,7 +13,7 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul pub use image::{copy_image_to_vault, save_image}; pub use migration::migrate_is_a_to_type; pub use rename::{move_note_to_type_folder, rename_note, MoveResult, RenameResult}; -pub use trash::{delete_note, purge_trash}; +pub use trash::{delete_note, is_file_trashed, purge_trash}; use parsing::{ contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title, diff --git a/src-tauri/src/vault/trash.rs b/src-tauri/src/vault/trash.rs index 5842a9e0..6d5e4e73 100644 --- a/src-tauri/src/vault/trash.rs +++ b/src-tauri/src/vault/trash.rs @@ -57,6 +57,37 @@ pub fn delete_note(path: &str) -> Result { Ok(path.to_string()) } +/// Check whether a file's frontmatter marks it as trashed. +/// Returns `true` if `Trashed: true` or `Trashed at` is present. +pub fn is_file_trashed(path: &Path) -> bool { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return false, + }; + let matter = Matter::::new(); + let parsed = matter.parse(&content); + + // Check for "Trashed at" field — its presence implies trashed + if extract_trashed_at_string(&parsed.data).is_some() { + return true; + } + + // Check for "Trashed: true" + if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data { + if let Some(pod) = map.get("Trashed").or_else(|| map.get("trashed")) { + return match pod { + gray_matter::Pod::Boolean(b) => *b, + gray_matter::Pod::String(s) => { + matches!(s.to_ascii_lowercase().as_str(), "yes" | "true") + } + _ => false, + }; + } + } + + false +} + /// Scan all markdown files in the vault and delete those where /// `Trashed at` frontmatter is more than 30 days ago. /// Returns the list of deleted file paths. @@ -248,4 +279,63 @@ mod tests { assert_eq!(deleted.len(), 1); assert!(deleted[0].contains("old.md")); } + + #[test] + fn test_is_file_trashed_with_trashed_true() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "trashed.md", "---\nTrashed: true\n---\n# Gone\n"); + assert!(is_file_trashed(&dir.path().join("trashed.md"))); + } + + #[test] + fn test_is_file_trashed_with_trashed_at() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "trashed.md", + "---\nTrashed at: \"2026-01-01\"\n---\n# Gone\n", + ); + assert!(is_file_trashed(&dir.path().join("trashed.md"))); + } + + #[test] + fn test_is_file_trashed_with_trashed_yes() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "trashed.md", "---\nTrashed: Yes\n---\n# Gone\n"); + assert!(is_file_trashed(&dir.path().join("trashed.md"))); + } + + #[test] + fn test_is_file_trashed_normal_note() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n"); + assert!(!is_file_trashed(&dir.path().join("normal.md"))); + } + + #[test] + fn test_is_file_trashed_archived_not_trashed() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "archived.md", + "---\nArchived: true\n---\n# Archived\n", + ); + assert!(!is_file_trashed(&dir.path().join("archived.md"))); + } + + #[test] + fn test_is_file_trashed_nonexistent_file() { + assert!(!is_file_trashed(Path::new("/nonexistent/path.md"))); + } + + #[test] + fn test_is_file_trashed_with_trashed_false() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "active.md", + "---\nTrashed: false\n---\n# Active\n", + ); + assert!(!is_file_trashed(&dir.path().join("active.md"))); + } } diff --git a/src/components/RawEditorView.tsx b/src/components/RawEditorView.tsx index 029ad3dd..4af978fe 100644 --- a/src/components/RawEditorView.tsx +++ b/src/components/RawEditorView.tsx @@ -52,7 +52,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const baseItems = useMemo( - () => deduplicateByPath(entries.map(entry => ({ + () => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({ title: entry.title, aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])], group: entry.isA || 'Note', diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 1c18fa0f..c0ca86e7 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -62,7 +62,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const baseItems = useMemo( - () => deduplicateByPath(entries.map(entry => ({ + () => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({ title: entry.title, aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])], group: entry.isA || 'Note', diff --git a/src/hooks/useNoteSearch.test.ts b/src/hooks/useNoteSearch.test.ts index 4057f975..03ddab0c 100644 --- a/src/hooks/useNoteSearch.test.ts +++ b/src/hooks/useNoteSearch.test.ts @@ -220,6 +220,35 @@ describe('useNoteSearch', () => { expect(result.current.results[0].title).toBe('Refactoring Notes') }) + it('excludes trashed notes from results', () => { + const withTrashed: VaultEntry[] = [ + makeEntry({ path: '/vault/a.md', title: 'Active Note', modifiedAt: 1700000003 }), + makeEntry({ path: '/vault/t.md', title: 'Trashed Note', trashed: true, trashedAt: 1700000000, modifiedAt: 1700000002 }), + makeEntry({ path: '/vault/b.md', title: 'Another Active', modifiedAt: 1700000001 }), + ] + const { result } = renderHook(() => useNoteSearch(withTrashed, '')) + expect(result.current.results.map(r => r.title)).toEqual(['Active Note', 'Another Active']) + }) + + it('excludes trashed notes from search results by query', () => { + const withTrashed: VaultEntry[] = [ + makeEntry({ path: '/vault/a.md', title: 'Meeting Notes', modifiedAt: 1700000002 }), + makeEntry({ path: '/vault/t.md', title: 'Meeting Draft', trashed: true, modifiedAt: 1700000001 }), + ] + const { result } = renderHook(() => useNoteSearch(withTrashed, 'Meeting')) + expect(result.current.results).toHaveLength(1) + expect(result.current.results[0].title).toBe('Meeting Notes') + }) + + it('does not exclude archived notes from results', () => { + const withArchived: VaultEntry[] = [ + makeEntry({ path: '/vault/a.md', title: 'Active Note', modifiedAt: 1700000002 }), + makeEntry({ path: '/vault/ar.md', title: 'Archived Note', archived: true, modifiedAt: 1700000001 }), + ] + const { result } = renderHook(() => useNoteSearch(withArchived, '')) + expect(result.current.results).toHaveLength(2) + }) + it('resolves custom type color from Type entries', () => { const withTypes: VaultEntry[] = [ makeEntry({ path: '/vault/t/recipe.md', title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' }), diff --git a/src/hooks/useNoteSearch.ts b/src/hooks/useNoteSearch.ts index b0559159..03a5644c 100644 --- a/src/hooks/useNoteSearch.ts +++ b/src/hooks/useNoteSearch.ts @@ -32,7 +32,7 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults = const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const searchableEntries = useMemo( - () => entries.filter((e) => !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')), + () => entries.filter((e) => !e.trashed && !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')), [entries], ) diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 28cbb207..b805c6f0 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -269,6 +269,7 @@ export const mockHandlers: Record any> = { if (!q) return { results: [], elapsed_ms: 0, query: q, mode: args.mode } const matches = MOCK_ENTRIES .filter(e => { + if (e.trashed) return false const content = MOCK_CONTENT[e.path] ?? '' return e.title.toLowerCase().includes(q) || content.toLowerCase().includes(q) }) diff --git a/tests/smoke/exclude-trashed-from-search.spec.ts b/tests/smoke/exclude-trashed-from-search.spec.ts new file mode 100644 index 00000000..0a119f6d --- /dev/null +++ b/tests/smoke/exclude-trashed-from-search.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test' +import { sendShortcut } from './helpers' + +const QUICK_OPEN_INPUT = 'input[placeholder="Search notes..."]' +const SEARCH_INPUT = 'input[placeholder="Search in all notes..."]' + +/** Known trashed note titles from mock data */ +const TRASHED_TITLES = ['Old Draft Notes', 'Deprecated API Notes', 'Failed SEO Experiment'] +/** Query specific enough to find a known active note */ +const ACTIVE_QUERY = 'Laputa App' + +async function openQuickOpen(page: import('@playwright/test').Page) { + await page.locator('body').click() + await sendShortcut(page, 'p', ['Control']) + await expect(page.locator(QUICK_OPEN_INPUT)).toBeVisible() +} + +function getAllResultTitles(page: import('@playwright/test').Page) { + return page.locator('span.truncate').allTextContents() +} + +test.describe('Exclude trashed notes from search and autocomplete', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('trashed notes do not appear in Quick Open search', async ({ page }) => { + await openQuickOpen(page) + for (const title of TRASHED_TITLES) { + // Use a unique enough substring to trigger results + const query = title.split(' ')[0] + await page.locator(QUICK_OPEN_INPUT).fill(query) + await page.waitForTimeout(400) + const titles = await getAllResultTitles(page) + expect(titles, `"${title}" should not appear for query "${query}"`).not.toContain(title) + } + }) + + test('active notes still appear in Quick Open search', async ({ page }) => { + await openQuickOpen(page) + await page.locator(QUICK_OPEN_INPUT).fill(ACTIVE_QUERY) + await page.waitForTimeout(400) + const titles = await getAllResultTitles(page) + expect(titles.some(t => t.includes('Laputa App'))).toBe(true) + }) + + test('trashed notes do not appear in full-text search', async ({ page }) => { + // Full-text search panel opened via Ctrl/Cmd+Shift+F + const searchVisible = async () => { + await page.locator('body').click() + await sendShortcut(page, 'f', ['Control', 'Shift']) + try { + await expect(page.locator(SEARCH_INPUT)).toBeVisible({ timeout: 2000 }) + return true + } catch { return false } + } + if (!await searchVisible()) { + // Retry with Meta (macOS Cmd key) in case Playwright routes it differently + await sendShortcut(page, 'f', ['Meta', 'Shift']) + try { + await expect(page.locator(SEARCH_INPUT)).toBeVisible({ timeout: 2000 }) + } catch { + test.skip() + return + } + } + await page.locator(SEARCH_INPUT).fill('Old Draft') + await page.waitForTimeout(600) + const titles = await getAllResultTitles(page) + expect(titles).not.toContain('Old Draft Notes') + }) + + test('wikilink autocomplete does not suggest trashed notes', async ({ page }) => { + // Open any note first (click the first note in the sidebar) + const firstNote = page.locator('[data-testid="note-item"]').first() + if (await firstNote.isVisible()) { + await firstNote.click() + await page.waitForTimeout(300) + } + + // Focus the editor and type [[ to trigger autocomplete + const editor = page.locator('.bn-editor').first() + if (await editor.isVisible()) { + await editor.click() + await page.keyboard.type('[[Old Draft') + await page.waitForTimeout(500) + + // Check autocomplete suggestions - should not contain the trashed note + const suggestions = page.locator('.bn-suggestion-menu [class*="item"], .bn-suggestion-menu button, [data-testid="wikilink-suggestion"]') + const count = await suggestions.count() + for (let i = 0; i < count; i++) { + const text = await suggestions.nth(i).textContent() + expect(text).not.toContain('Old Draft Notes') + } + } + }) +})