Skip to main content

kimun_notes/components/note_browser/
search_provider.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use kimun_core::NoteVault;
5use kimun_core::nfs::{NoteEntryData, VaultPath};
6use kimun_core::note::NoteContentData;
7
8use super::format_journal_date;
9use crate::components::file_list::FileListEntry;
10use crate::components::query_vars::resolve_query;
11use crate::components::search_list::{Emit, RowSource};
12
13pub struct SearchNotesProvider {
14    vault: Arc<NoteVault>,
15    last_paths: Vec<VaultPath>,
16    /// The note open when the browser was launched, used to resolve query
17    /// variables like `{note}` before the query reaches core. `None` when no
18    /// note is open (e.g. launched from the root browse view).
19    current_note: Option<VaultPath>,
20}
21
22impl SearchNotesProvider {
23    pub fn new(
24        vault: Arc<NoteVault>,
25        last_paths: Vec<VaultPath>,
26        current_note: Option<VaultPath>,
27    ) -> Self {
28        Self {
29            vault,
30            last_paths,
31            current_note,
32        }
33    }
34
35    fn to_entry(&self, entry: NoteEntryData, content: NoteContentData) -> FileListEntry {
36        let filename = entry.path.get_parent_path().1;
37        let title = if content.title.trim().is_empty() {
38            "<no title>".to_string()
39        } else {
40            content.title
41        };
42        let journal_date = self
43            .vault
44            .journal_date(&entry.path)
45            .map(format_journal_date);
46        FileListEntry::Note {
47            path: entry.path,
48            title,
49            filename,
50            journal_date,
51        }
52    }
53}
54
55#[async_trait]
56impl RowSource<FileListEntry> for SearchNotesProvider {
57    async fn load(&self, query: &str, emit: Emit<FileListEntry>) {
58        let entries: Vec<FileListEntry> = if query.is_empty() {
59            // Build a lookup map from all indexed notes so we can resolve each
60            // last_path to its full metadata in O(1).
61            let all_notes = self.vault.get_all_notes().await.unwrap_or_default();
62            let mut by_path: std::collections::HashMap<_, _> = all_notes
63                .into_iter()
64                .map(|(entry, content)| (entry.path.clone(), (entry, content)))
65                .collect();
66
67            // last_paths is most-recent-first; iterate as-is.
68            self.last_paths
69                .iter()
70                .filter_map(|path| by_path.remove(path))
71                .map(|(entry, content)| self.to_entry(entry, content))
72                .collect()
73        } else {
74            // Resolve query variables ({note}, …) against the open note before
75            // handing a plain query to core — the same presentation-layer step
76            // the Query panel does. Without this, `{note}` reaches core
77            // literally and matches nothing.
78            let resolved = resolve_query(query, self.current_note.as_ref());
79            self.vault
80                .search_notes(&resolved)
81                .await
82                .unwrap_or_default()
83                .into_iter()
84                .map(|(entry, content)| self.to_entry(entry, content))
85                .collect()
86        };
87        emit.replace(entries);
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::components::events::redraw_callback;
95    use crate::components::search_list::SearchList;
96    use crate::test_support::temp_vault;
97    use tokio::sync::mpsc::unbounded_channel;
98
99    fn has_note_named(rows: &[&FileListEntry], name: &str) -> bool {
100        rows.iter().any(|r| match r {
101            FileListEntry::Note { path, .. } => path.get_clean_name() == name,
102            _ => false,
103        })
104    }
105
106    /// `{note}` must be resolved against the open note before the query reaches
107    /// core — the same presentation-layer step the Query panel performs.
108    #[tokio::test]
109    async fn resolves_note_variable_before_search() {
110        let vault = temp_vault("search_provider_note_var").await;
111        // Build the DB schema/index (temp_vault only opens the vault).
112        vault.validate_and_init().await.unwrap();
113        vault
114            .create_note(&VaultPath::note_path_from("spec"), "hello")
115            .await
116            .unwrap();
117
118        // With the open note = "spec", "={note}" resolves to "=spec" and the
119        // name filter matches the note.
120        let (tx, _rx) = unbounded_channel();
121        let provider = SearchNotesProvider::new(
122            vault.clone(),
123            vec![],
124            Some(VaultPath::note_path_from("spec")),
125        );
126        let mut list = SearchList::builder(provider, redraw_callback(tx))
127            .initial_query("={note}")
128            .build();
129        list.poll_until_idle().await;
130        assert!(
131            has_note_named(&list.visible_rows(), "spec"),
132            "expected the 'spec' note via resolved {{note}}"
133        );
134
135        // Without an open note, "={note}" resolves to bare "=" and must NOT
136        // match "spec" — proving the literal `{note}` was substituted away.
137        let (tx2, _rx2) = unbounded_channel();
138        let provider_none = SearchNotesProvider::new(vault.clone(), vec![], None);
139        let mut list_none = SearchList::builder(provider_none, redraw_callback(tx2))
140            .initial_query("={note}")
141            .build();
142        list_none.poll_until_idle().await;
143        assert!(
144            !has_note_named(&list_none.visible_rows(), "spec"),
145            "without an open note, {{note}} resolves to empty and must not match 'spec'"
146        );
147    }
148}