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::{query_is_unresolvable, 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        // A purely note-dependent query ({note} or bare-operator sugar) with
59        // no note to resolve against would reach core as a bare prefix and be
60        // dropped — a dead-end empty list. Treat it like an empty query
61        // instead, so the browser keeps showing the recent notes. Mixed
62        // queries keep their concrete terms and still search.
63        let unresolvable = query_is_unresolvable(query, self.current_note.as_ref());
64        let entries: Vec<FileListEntry> = if query.is_empty() || unresolvable {
65            // Build a lookup map from all indexed notes so we can resolve each
66            // last_path to its full metadata in O(1).
67            let all_notes = self.vault.get_all_notes().await.unwrap_or_default();
68            let mut by_path: std::collections::HashMap<_, _> = all_notes
69                .into_iter()
70                .map(|(entry, content)| (entry.path.clone(), (entry, content)))
71                .collect();
72
73            // last_paths is most-recent-first; iterate as-is.
74            self.last_paths
75                .iter()
76                .filter_map(|path| by_path.remove(path))
77                .map(|(entry, content)| self.to_entry(entry, content))
78                .collect()
79        } else {
80            // Resolve query variables ({note}, …) against the open note before
81            // handing a plain query to core — the same presentation-layer step
82            // the Query panel does. Without this, `{note}` reaches core
83            // literally and matches nothing.
84            let resolved = resolve_query(query, self.current_note.as_ref());
85            self.vault
86                .search_notes(&resolved)
87                .await
88                .unwrap_or_default()
89                .into_iter()
90                .map(|(entry, content)| self.to_entry(entry, content))
91                .collect()
92        };
93        emit.replace(entries);
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::components::events::redraw_callback;
101    use crate::components::search_list::SearchList;
102    use crate::test_support::temp_vault;
103    use tokio::sync::mpsc::unbounded_channel;
104
105    fn has_note_named(rows: &[&FileListEntry], name: &str) -> bool {
106        rows.iter().any(|r| match r {
107            FileListEntry::Note { path, .. } => path.get_clean_name() == name,
108            _ => false,
109        })
110    }
111
112    /// `{note}` must be resolved against the open note before the query reaches
113    /// core — the same presentation-layer step the Query panel performs.
114    #[tokio::test]
115    async fn resolves_note_variable_before_search() {
116        let vault = temp_vault("search_provider_note_var").await;
117        // Build the DB schema/index (temp_vault only opens the vault).
118        vault.validate_and_init().await.unwrap();
119        vault
120            .create_note(&VaultPath::note_path_from("spec"), "hello")
121            .await
122            .unwrap();
123
124        // With the open note = "spec", "={note}" resolves to "=spec" and the
125        // name filter matches the note.
126        let (tx, _rx) = unbounded_channel();
127        let provider = SearchNotesProvider::new(
128            vault.clone(),
129            vec![],
130            Some(VaultPath::note_path_from("spec")),
131        );
132        let mut list = SearchList::builder(provider, redraw_callback(tx))
133            .initial_query("={note}")
134            .build();
135        list.poll_until_idle().await;
136        assert!(
137            has_note_named(&list.visible_rows(), "spec"),
138            "expected the 'spec' note via resolved {{note}}"
139        );
140
141        // Without an open note, "={note}" resolves to bare "=" and must NOT
142        // match "spec" — proving the literal `{note}` was substituted away.
143        let (tx2, _rx2) = unbounded_channel();
144        let provider_none = SearchNotesProvider::new(vault.clone(), vec![], None);
145        let mut list_none = SearchList::builder(provider_none, redraw_callback(tx2))
146            .initial_query("={note}")
147            .build();
148        list_none.poll_until_idle().await;
149        assert!(
150            !has_note_named(&list_none.visible_rows(), "spec"),
151            "without an open note, {{note}} resolves to empty and must not match 'spec'"
152        );
153    }
154
155    /// A note-dependent query with no note to resolve against must fall back
156    /// to the recent-notes view (like an empty query) instead of running a
157    /// search that core drops — a dead-end empty list.
158    #[tokio::test]
159    async fn unresolvable_note_query_falls_back_to_recent_notes() {
160        let vault = temp_vault("search_provider_unresolvable").await;
161        vault.validate_and_init().await.unwrap();
162        vault
163            .create_note(&VaultPath::note_path_from("spec"), "hello")
164            .await
165            .unwrap();
166
167        // No note open, bare `<` typed: the sugar can't resolve, so the
168        // browser shows the recent notes (here: "spec") rather than nothing.
169        let (tx, _rx) = unbounded_channel();
170        let provider =
171            SearchNotesProvider::new(vault.clone(), vec![VaultPath::note_path_from("spec")], None);
172        let mut list = SearchList::builder(provider, redraw_callback(tx))
173            .initial_query("<")
174            .build();
175        list.poll_until_idle().await;
176        assert!(
177            has_note_named(&list.visible_rows(), "spec"),
178            "bare `<` with no open note must fall back to recent notes"
179        );
180    }
181
182    /// A mixed query — concrete terms plus unresolvable note sugar — must
183    /// still run the search (core drops the bare prefix), not silently
184    /// discard the user's terms for the recent-notes fallback.
185    #[tokio::test]
186    async fn mixed_query_with_unresolvable_sugar_still_searches() {
187        let vault = temp_vault("search_provider_mixed").await;
188        vault.validate_and_init().await.unwrap();
189        vault
190            .create_note(&VaultPath::note_path_from("gadget"), "widget stuff")
191            .await
192            .unwrap();
193        vault
194            .create_note(&VaultPath::note_path_from("other"), "nothing here")
195            .await
196            .unwrap();
197
198        // No note open, query `widget <`: the `widget` term must still
199        // filter; "other" is the most recent note and must NOT appear (that
200        // would mean the recent-notes fallback swallowed the query).
201        let (tx, _rx) = unbounded_channel();
202        let provider = SearchNotesProvider::new(
203            vault.clone(),
204            vec![VaultPath::note_path_from("other")],
205            None,
206        );
207        let mut list = SearchList::builder(provider, redraw_callback(tx))
208            .initial_query("widget <")
209            .build();
210        list.poll_until_idle().await;
211        let rows = list.visible_rows();
212        assert!(
213            has_note_named(&rows, "gadget"),
214            "concrete term `widget` must still match"
215        );
216        assert!(
217            !has_note_named(&rows, "other"),
218            "mixed query must not fall back to recent notes"
219        );
220    }
221}