kimun_notes/components/note_browser/
search_provider.rs1use 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 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 let unresolvable = query_is_unresolvable(query, self.current_note.as_ref());
65 let entries: Vec<FileListEntry> = if query.is_empty() || unresolvable {
66 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 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 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 #[tokio::test]
116 async fn resolves_note_variable_before_search() {
117 let vault = temp_vault("search_provider_note_var").await;
118 vault.validate_and_init().await.unwrap();
120 vault
121 .create_note(&VaultPath::note_path_from("spec"), "hello")
122 .await
123 .unwrap();
124
125 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 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 #[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 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 #[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 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}