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