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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
98
tests/smoke/exclude-trashed-from-search.spec.ts
Normal file
98
tests/smoke/exclude-trashed-from-search.spec.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user