1use std::sync::Arc;
16
17use async_trait::async_trait;
18use kimun_core::{NoteVault, SavedSearch};
19use ratatui::Frame;
20use ratatui::layout::{Constraint, Direction, Layout, Rect};
21use ratatui::style::{Modifier, Style};
22use ratatui::widgets::{Block, Borders, ListItem, Paragraph};
23
24use crate::components::event_state::EventState;
25use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
26use crate::components::overlay::{Overlay, OverlayKind};
27use crate::components::panel::{ModalSpec, modal_chrome};
28use crate::components::search_list::{
29 Emit, Filter, KeyReaction, RowSource, SearchList, SearchMouse, SearchRow,
30};
31use crate::keys::key_combo::KeyCombo;
32use crate::keys::{KeyBindings, key_event_to_combo};
33use crate::settings::icons::Icons;
34use crate::settings::themes::Theme;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SearchItem {
46 pub index: Option<u8>,
47 pub name: String,
48 pub query: String,
49 pub is_virtual: bool,
50}
51
52impl SearchItem {
53 pub fn saved(index: u8, name: &str, query: &str) -> Self {
55 Self {
56 index: Some(index),
57 name: name.to_string(),
58 query: query.to_string(),
59 is_virtual: false,
60 }
61 }
62}
63
64impl SearchRow for SearchItem {
65 fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
66 let prefix = match self.index {
67 Some(n) => format!("{n} "),
68 None => " ".to_string(),
69 };
70 let label = if self.is_virtual {
71 format!("{prefix}* {}", self.name)
72 } else {
73 format!("{prefix}{}", self.name)
74 };
75 let style = if self.is_virtual {
76 Style::default()
77 .fg(theme.accent.to_ratatui())
78 .add_modifier(Modifier::ITALIC)
79 } else {
80 Style::default().fg(theme.fg.to_ratatui())
81 };
82 ListItem::new(label).style(style)
83 }
84
85 fn visual_height(&self) -> u16 {
86 1
87 }
88
89 fn match_text(&self) -> Option<&str> {
93 if self.is_virtual {
94 None
95 } else {
96 Some(&self.name)
97 }
98 }
99}
100
101pub const VIRTUAL_BACKLINKS_NAME: &str = "Backlinks (current note)";
102pub const VIRTUAL_BACKLINKS_QUERY: &str = "<{note}";
103
104pub struct SavedSearchesModel;
105
106impl SavedSearchesModel {
107 pub fn user_items(user: Vec<SavedSearch>) -> Vec<SearchItem> {
112 user.into_iter()
113 .enumerate()
114 .map(|(i, s)| SearchItem {
115 index: if i < 9 { Some((i + 1) as u8) } else { None },
116 name: s.name,
117 query: s.query,
118 is_virtual: false,
119 })
120 .collect()
121 }
122}
123
124pub fn rank_to_indices(rows: &[SearchItem], filter: &str) -> Vec<usize> {
131 let f = filter.trim();
132 if f.is_empty() {
133 return (0..rows.len()).collect();
134 }
135 let as_index: Option<u8> = f.parse().ok();
136 let needle = f.to_lowercase();
137 let mut ranked: Vec<(usize, u8)> = Vec::new(); for (i, it) in rows.iter().enumerate() {
139 let exact_index = as_index.is_some() && it.index == as_index;
140 let name_match = it.name.to_lowercase().contains(&needle);
141 if exact_index {
142 ranked.push((i, 0));
143 } else if name_match {
144 ranked.push((i, 1));
145 }
146 }
147 ranked.sort_by_key(|(_, r)| *r);
149 ranked.into_iter().map(|(i, _)| i).collect()
150}
151
152struct SavedSearchSource {
165 vault: Arc<NoteVault>,
166 pending_delete: Arc<std::sync::Mutex<Option<String>>>,
167}
168
169#[async_trait]
170impl RowSource<SearchItem> for SavedSearchSource {
171 async fn load(&self, _query: &str, emit: Emit<SearchItem>) {
172 let to_delete = self.pending_delete.lock().unwrap().take();
175 if let Some(name) = to_delete {
176 self.vault.delete_saved_search(&name).await.ok();
177 }
178 let user = self.vault.list_saved_searches().await.unwrap_or_default();
179 emit.replace(SavedSearchesModel::user_items(user));
180 }
181
182 fn leading_row(&self, _query: &str) -> Option<SearchItem> {
183 Some(SearchItem {
184 index: None,
185 name: VIRTUAL_BACKLINKS_NAME.to_string(),
186 query: VIRTUAL_BACKLINKS_QUERY.to_string(),
187 is_virtual: true,
188 })
189 }
190
191 fn reload_on_query(&self) -> bool {
192 false
193 }
194}
195
196pub struct SavedSearchesModal {
201 list: SearchList<SearchItem>,
202 pending_delete: Arc<std::sync::Mutex<Option<String>>>,
205 delete_combo: KeyCombo,
206}
207
208impl SavedSearchesModal {
209 pub fn new(vault: Arc<NoteVault>, _key_bindings: KeyBindings, icons: Icons, tx: AppTx) -> Self {
210 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
211 let delete_combo = key_event_to_combo(&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
212 .expect("Delete maps to a key combo");
213 let pending_delete = Arc::new(std::sync::Mutex::new(None));
214 let list = SearchList::builder(
215 SavedSearchSource {
216 vault,
217 pending_delete: pending_delete.clone(),
218 },
219 redraw_callback(tx),
220 )
221 .filter(Filter::Rank(Arc::new(rank_to_indices)))
222 .icons(icons)
223 .intercept(vec![delete_combo])
224 .build();
225 Self {
226 list,
227 pending_delete,
228 delete_combo,
229 }
230 }
231}
232
233impl Overlay for SavedSearchesModal {
238 fn kind(&self) -> OverlayKind {
239 OverlayKind::SavedSearches
240 }
241
242 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
243 match event {
244 InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
245 SearchMouse::Activated(_) => {
246 if let Some(item) = self.list.selected_row() {
247 tx.send(AppEvent::SavedSearchSelected {
248 query: item.query.clone(),
249 name: item.name.clone(),
250 })
251 .ok();
252 }
253 EventState::Consumed
254 }
255 SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
256 EventState::Consumed
257 }
258 SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
261 EventState::Consumed
262 }
263 SearchMouse::None => EventState::NotConsumed,
264 },
265 InputEvent::Key(key) => match self.list.handle_key(key) {
266 KeyReaction::Intercepted(c) if c == self.delete_combo => {
267 if let Some(item) = self.list.selected_row().filter(|i| !i.is_virtual) {
268 *self.pending_delete.lock().unwrap() = Some(item.name.clone());
272 self.list.reload();
273 }
274 EventState::Consumed
275 }
276 KeyReaction::Submit => {
277 if let Some(item) = self.list.selected_row() {
278 tx.send(AppEvent::SavedSearchSelected {
279 query: item.query.clone(),
280 name: item.name.clone(),
281 })
282 .ok();
283 }
284 EventState::Consumed
285 }
286 KeyReaction::Cancel => {
287 tx.send(AppEvent::CloseOverlay).ok();
288 EventState::Consumed
289 }
290 KeyReaction::Consumed => EventState::Consumed,
291 KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
292 },
293 _ => EventState::NotConsumed,
294 }
295 }
296
297 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
298 let popup_rect = crate::components::centered_rect(60, 60, area);
299
300 let inner = modal_chrome(
301 f,
302 popup_rect,
303 theme,
304 ModalSpec {
305 title: Some(" Saved Searches "),
306 ..Default::default()
307 },
308 );
309
310 let rows = Layout::default()
311 .direction(Direction::Vertical)
312 .constraints([
313 Constraint::Length(3),
314 Constraint::Min(0),
315 Constraint::Length(1),
316 ])
317 .split(inner);
318
319 let filter_block = Block::default()
321 .title(" Filter ")
322 .borders(Borders::ALL)
323 .border_style(theme.border_style(true))
324 .style(theme.panel_style());
325 let filter_inner = filter_block.inner(rows[0]);
326 f.render_widget(filter_block, rows[0]);
327 self.list.render_query(f, filter_inner, theme, true);
328
329 let list_block = Block::default()
331 .borders(Borders::ALL)
332 .border_style(theme.border_style(false))
333 .style(theme.panel_style());
334 let list_inner = list_block.inner(rows[1]);
335 f.render_widget(list_block, rows[1]);
336 self.list.render(f, list_inner, theme, false);
337 self.list.set_list_rect(list_inner);
340 self.list.set_panel_rect(popup_rect);
342
343 f.render_widget(
345 Paragraph::new("↑↓ navigate | Enter open | Del delete | Esc close")
346 .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
347 rows[2],
348 );
349 }
350
351 fn hint_shortcuts(&self) -> Vec<(String, String)> {
352 vec![
353 ("↑↓".to_string(), "navigate".to_string()),
354 ("Enter".to_string(), "open".to_string()),
355 ("Del".to_string(), "delete".to_string()),
356 ("Esc".to_string(), "close".to_string()),
357 ]
358 }
359}
360
361#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::settings::AppSettings;
369 use crate::test_support::temp_vault;
370 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
371 use tokio::sync::mpsc::unbounded_channel;
372
373 async fn poll_engine_idle(modal: &mut SavedSearchesModal) {
377 for _ in 0..600 {
382 modal.list.poll();
383 if !modal.list.is_loading() {
384 break;
385 }
386 tokio::task::yield_now().await;
387 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
388 }
389 modal.list.poll();
390 }
391
392 #[test]
393 fn user_items_skip_virtual_and_number_first_nine() {
394 let user: Vec<SavedSearch> = (0..11)
395 .map(|i| SavedSearch {
396 name: format!("s{i}"),
397 query: format!("#{i}"),
398 })
399 .collect();
400 let items = SavedSearchesModel::user_items(user);
401 assert!(items.iter().all(|i| !i.is_virtual));
403 assert_eq!(items[0].index, Some(1));
404 assert_eq!(items[8].index, Some(9));
405 assert_eq!(items[9].index, None); }
407
408 #[test]
409 fn rank_exact_index_first() {
410 let items = vec![
411 SearchItem::saved(1, "todo", "#todo"),
412 SearchItem::saved(2, "backlinks-ish", "<{note}"),
413 SearchItem::saved(3, "two-things", "#a"),
414 ];
415 let idx = rank_to_indices(&items, "2");
416 assert_eq!(items[idx[0]].name, "backlinks-ish"); let idx = rank_to_indices(&items, "tod");
418 assert_eq!(items[idx[0]].name, "todo");
419 }
420
421 #[test]
422 fn rank_empty_filter_returns_all_in_order() {
423 let items = vec![
424 SearchItem::saved(1, "a", "#a"),
425 SearchItem::saved(2, "b", "#b"),
426 ];
427 let idx = rank_to_indices(&items, "");
428 assert_eq!(idx, vec![0, 1]);
429 }
430
431 #[test]
432 fn rank_name_substring_only_matches() {
433 let items = vec![
434 SearchItem::saved(1, "todo", "#todo"),
435 SearchItem::saved(2, "ideas", "#ideas"),
436 ];
437 let idx = rank_to_indices(&items, "ide");
438 assert_eq!(idx.len(), 1);
439 assert_eq!(items[idx[0]].name, "ideas");
440 }
441
442 #[tokio::test(flavor = "multi_thread")]
443 async fn delete_removes_row_via_ordered_reload() {
444 let vault = temp_vault("saved_searches_delete").await;
445 vault
446 .save_search("todo", "#todo")
447 .await
448 .expect("save search");
449 vault
450 .save_search("ideas", "#ideas")
451 .await
452 .expect("save search");
453 let settings = AppSettings::default();
454 let (tx, _rx) = unbounded_channel();
455 let mut modal = SavedSearchesModal::new(
456 vault.clone(),
457 settings.key_bindings.clone(),
458 settings.icons(),
459 tx.clone(),
460 );
461 poll_engine_idle(&mut modal).await;
462
463 Overlay::handle_input(
465 &mut modal,
466 &InputEvent::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
467 &tx,
468 );
469 let target = modal
470 .list
471 .selected_row()
472 .filter(|i| !i.is_virtual)
473 .expect("a non-virtual row is selected")
474 .name
475 .clone();
476
477 Overlay::handle_input(
479 &mut modal,
480 &InputEvent::Key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)),
481 &tx,
482 );
483 poll_engine_idle(&mut modal).await;
484
485 let remaining = vault.list_saved_searches().await.expect("list");
487 assert_eq!(remaining.len(), 1, "one saved search should remain");
488 assert!(
489 !remaining.iter().any(|s| s.name == target),
490 "deleted name {target} should be gone from the vault"
491 );
492
493 let visible: Vec<String> = modal
495 .list
496 .visible_rows()
497 .iter()
498 .map(|r| r.name.clone())
499 .collect();
500 assert!(
501 !visible.contains(&target),
502 "deleted name {target} should be gone from the visible rows, got {visible:?}"
503 );
504 }
505
506 #[tokio::test]
510 async fn enter_emits_selected_not_close() {
511 let vault = temp_vault("saved_searches_modal").await;
512 vault
513 .save_search("todo", "#todo")
514 .await
515 .expect("save search");
516 vault
517 .save_search("ideas", "#ideas")
518 .await
519 .expect("save search");
520 let settings = AppSettings::default();
521 let (tx, mut rx) = unbounded_channel();
522 let mut modal = SavedSearchesModal::new(
523 vault,
524 settings.key_bindings.clone(),
525 settings.icons(),
526 tx.clone(),
527 );
528 modal.list.poll_until_idle().await;
529
530 Overlay::handle_input(
531 &mut modal,
532 &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
533 &tx,
534 );
535
536 let mut events = Vec::new();
537 while let Ok(ev) = rx.try_recv() {
538 events.push(ev);
539 }
540 assert!(
541 events
542 .iter()
543 .any(|e| matches!(e, AppEvent::SavedSearchSelected { .. })),
544 "expected SavedSearchSelected, got {events:?}"
545 );
546 assert!(
547 !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
548 "select must not emit CloseOverlay; editor's SavedSearchSelected handler closes the overlay, got {events:?}"
549 );
550 }
551}