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 }
52 }
53}
54
55#[async_trait]
56impl RowSource<FileListEntry> for SearchNotesProvider {
57 async fn load(&self, query: &str, emit: Emit<FileListEntry>) {
58 let unresolvable = query_is_unresolvable(query, self.current_note.as_ref());
64 let entries: Vec<FileListEntry> = if query.is_empty() || unresolvable {
65 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 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 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 #[tokio::test]
115 async fn resolves_note_variable_before_search() {
116 let vault = temp_vault("search_provider_note_var").await;
117 vault.validate_and_init().await.unwrap();
119 vault
120 .create_note(&VaultPath::note_path_from("spec"), "hello")
121 .await
122 .unwrap();
123
124 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 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 #[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 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 #[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 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}