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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-09 12:42:56 +01:00
parent eaeb6e5d40
commit fc4ba24c4e
9 changed files with 229 additions and 7 deletions

View File

@@ -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<SearchResult> = 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();

View File

@@ -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,

View File

@@ -57,6 +57,37 @@ pub fn delete_note(path: &str) -> Result<String, String> {
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::<YAML>::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")));
}
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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' }),

View File

@@ -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],
)

View File

@@ -269,6 +269,7 @@ export const mockHandlers: Record<string, (args: any) => 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)
})

View File

@@ -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')
}
}
})
})