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, SavedSearchFlow, 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 fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
103 Some(crate::components::search_list::YankTarget::new(
104 self.query.clone(),
105 "query",
106 ))
107 }
108}
109
110pub const VIRTUAL_BACKLINKS_NAME: &str = "Backlinks (current note)";
111pub const VIRTUAL_BACKLINKS_QUERY: &str = "<{note}";
112
113pub struct SavedSearchesModel;
114
115impl SavedSearchesModel {
116 pub fn user_items(user: Vec<SavedSearch>) -> Vec<SearchItem> {
121 user.into_iter()
122 .enumerate()
123 .map(|(i, s)| SearchItem {
124 index: if i < 9 { Some((i + 1) as u8) } else { None },
125 name: s.name,
126 query: s.query,
127 is_virtual: false,
128 })
129 .collect()
130 }
131}
132
133pub fn rank_to_indices(rows: &[SearchItem], filter: &str) -> Vec<usize> {
140 let f = filter.trim();
141 if f.is_empty() {
142 return (0..rows.len()).collect();
143 }
144 let as_index: Option<u8> = f.parse().ok();
145 let needle = f.to_lowercase();
146 let mut ranked: Vec<(usize, u8)> = Vec::new(); for (i, it) in rows.iter().enumerate() {
148 let exact_index = as_index.is_some() && it.index == as_index;
149 let name_match = it.name.to_lowercase().contains(&needle);
150 if exact_index {
151 ranked.push((i, 0));
152 } else if name_match {
153 ranked.push((i, 1));
154 }
155 }
156 ranked.sort_by_key(|(_, r)| *r);
158 ranked.into_iter().map(|(i, _)| i).collect()
159}
160
161struct SavedSearchSource {
174 vault: Arc<NoteVault>,
175 pending_delete: Arc<std::sync::Mutex<Option<String>>>,
176}
177
178#[async_trait]
179impl RowSource<SearchItem> for SavedSearchSource {
180 async fn load(&self, _query: &str, emit: Emit<SearchItem>) {
181 let to_delete = self.pending_delete.lock().unwrap().take();
184 if let Some(name) = to_delete {
185 self.vault.delete_saved_search(&name).await.ok();
186 }
187 let user = self.vault.list_saved_searches().await.unwrap_or_default();
188 emit.replace(SavedSearchesModel::user_items(user));
189 }
190
191 fn leading_row(&self, _query: &str) -> Option<SearchItem> {
192 Some(SearchItem {
193 index: None,
194 name: VIRTUAL_BACKLINKS_NAME.to_string(),
195 query: VIRTUAL_BACKLINKS_QUERY.to_string(),
196 is_virtual: true,
197 })
198 }
199
200 fn reload_on_query(&self) -> bool {
201 false
202 }
203}
204
205pub struct SavedSearchesModal {
210 list: SearchList<SearchItem>,
211 pending_delete: Arc<std::sync::Mutex<Option<String>>>,
214 delete_combo: KeyCombo,
215}
216
217impl SavedSearchesModal {
218 pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings, icons: Icons, tx: AppTx) -> Self {
219 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
220 let delete_combo = key_event_to_combo(&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
221 .expect("Delete maps to a key combo");
222 let pending_delete = Arc::new(std::sync::Mutex::new(None));
223 let list = SearchList::builder(
224 SavedSearchSource {
225 vault,
226 pending_delete: pending_delete.clone(),
227 },
228 redraw_callback(tx),
229 )
230 .filter(Filter::Rank(Arc::new(rank_to_indices)))
231 .yank_combos_from(&key_bindings)
232 .icons(icons)
233 .intercept(vec![delete_combo])
234 .build();
235 Self {
236 list,
237 pending_delete,
238 delete_combo,
239 }
240 }
241}
242
243impl Overlay for SavedSearchesModal {
248 fn kind(&self) -> OverlayKind {
249 OverlayKind::SavedSearches
250 }
251
252 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
253 match event {
254 InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
255 SearchMouse::Activated(_) => {
256 if let Some(item) = self.list.selected_row() {
257 tx.send(AppEvent::SavedSearch(SavedSearchFlow::Selected {
258 query: item.query.clone(),
259 name: item.name.clone(),
260 }))
261 .ok();
262 }
263 EventState::Consumed
264 }
265 SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
266 EventState::Consumed
267 }
268 SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
271 EventState::Consumed
272 }
273 SearchMouse::None => EventState::NotConsumed,
274 },
275 InputEvent::Key(key) => match self.list.handle_key(key) {
276 KeyReaction::Intercepted(c) if c == self.delete_combo => {
277 if let Some(item) = self.list.selected_row().filter(|i| !i.is_virtual) {
278 *self.pending_delete.lock().unwrap() = Some(item.name.clone());
282 self.list.reload();
283 }
284 EventState::Consumed
285 }
286 KeyReaction::Submit => {
287 if let Some(item) = self.list.selected_row() {
288 tx.send(AppEvent::SavedSearch(SavedSearchFlow::Selected {
289 query: item.query.clone(),
290 name: item.name.clone(),
291 }))
292 .ok();
293 }
294 EventState::Consumed
295 }
296 KeyReaction::Cancel => {
297 tx.send(AppEvent::CloseOverlay).ok();
298 EventState::Consumed
299 }
300 KeyReaction::Consumed => EventState::Consumed,
301 KeyReaction::Yank(target) => {
302 crate::components::yank_row(target, tx);
303 EventState::Consumed
304 }
305 KeyReaction::Intercepted(_) | KeyReaction::ListVerb(_) | KeyReaction::Unhandled => {
306 EventState::NotConsumed
307 }
308 },
309 _ => EventState::NotConsumed,
310 }
311 }
312
313 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
314 let popup_rect = crate::components::centered_rect(60, 60, area);
315
316 let inner = modal_chrome(
317 f,
318 popup_rect,
319 theme,
320 ModalSpec {
321 title: Some(" Saved Searches "),
322 ..Default::default()
323 },
324 );
325
326 let rows = Layout::default()
327 .direction(Direction::Vertical)
328 .constraints([
329 Constraint::Length(3),
330 Constraint::Min(0),
331 Constraint::Length(1),
332 ])
333 .split(inner);
334
335 let filter_block = Block::default()
337 .title(" Filter ")
338 .borders(Borders::ALL)
339 .border_style(theme.border_style(true))
340 .style(theme.panel_style());
341 let filter_inner = filter_block.inner(rows[0]);
342 f.render_widget(filter_block, rows[0]);
343 self.list.render_query(f, filter_inner, theme, true);
344
345 let list_block = Block::default()
347 .borders(Borders::ALL)
348 .border_style(theme.border_style(false))
349 .style(theme.panel_style());
350 let list_inner = list_block.inner(rows[1]);
351 f.render_widget(list_block, rows[1]);
352 self.list.render(f, list_inner, theme, false);
353 self.list.set_list_rect(list_inner);
356 self.list.set_panel_rect(popup_rect);
358
359 f.render_widget(
361 Paragraph::new("↑↓ navigate | Enter open | Del delete | Esc close")
362 .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
363 rows[2],
364 );
365 }
366
367 fn hint_shortcuts(&self) -> Vec<(String, String)> {
368 vec![
369 ("↑↓".to_string(), "navigate".to_string()),
370 ("Enter".to_string(), "open".to_string()),
371 ("Del".to_string(), "delete".to_string()),
372 ("Esc".to_string(), "close".to_string()),
373 ]
374 }
375}
376
377#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::settings::AppSettings;
385 use crate::test_support::temp_vault;
386 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
387 use tokio::sync::mpsc::unbounded_channel;
388
389 async fn poll_engine_idle(modal: &mut SavedSearchesModal) {
393 for _ in 0..600 {
398 modal.list.poll();
399 if !modal.list.is_loading() {
400 break;
401 }
402 tokio::task::yield_now().await;
403 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
404 }
405 modal.list.poll();
406 }
407
408 #[test]
409 fn user_items_skip_virtual_and_number_first_nine() {
410 let user: Vec<SavedSearch> = (0..11)
411 .map(|i| SavedSearch {
412 name: format!("s{i}"),
413 query: format!("#{i}"),
414 })
415 .collect();
416 let items = SavedSearchesModel::user_items(user);
417 assert!(items.iter().all(|i| !i.is_virtual));
419 assert_eq!(items[0].index, Some(1));
420 assert_eq!(items[8].index, Some(9));
421 assert_eq!(items[9].index, None); }
423
424 #[test]
425 fn rank_exact_index_first() {
426 let items = vec![
427 SearchItem::saved(1, "todo", "#todo"),
428 SearchItem::saved(2, "backlinks-ish", "<{note}"),
429 SearchItem::saved(3, "two-things", "#a"),
430 ];
431 let idx = rank_to_indices(&items, "2");
432 assert_eq!(items[idx[0]].name, "backlinks-ish"); let idx = rank_to_indices(&items, "tod");
434 assert_eq!(items[idx[0]].name, "todo");
435 }
436
437 #[test]
438 fn rank_empty_filter_returns_all_in_order() {
439 let items = vec![
440 SearchItem::saved(1, "a", "#a"),
441 SearchItem::saved(2, "b", "#b"),
442 ];
443 let idx = rank_to_indices(&items, "");
444 assert_eq!(idx, vec![0, 1]);
445 }
446
447 #[test]
448 fn rank_name_substring_only_matches() {
449 let items = vec![
450 SearchItem::saved(1, "todo", "#todo"),
451 SearchItem::saved(2, "ideas", "#ideas"),
452 ];
453 let idx = rank_to_indices(&items, "ide");
454 assert_eq!(idx.len(), 1);
455 assert_eq!(items[idx[0]].name, "ideas");
456 }
457
458 #[tokio::test(flavor = "multi_thread")]
459 async fn delete_removes_row_via_ordered_reload() {
460 let vault = temp_vault("saved_searches_delete").await;
461 vault
462 .save_search("todo", "#todo")
463 .await
464 .expect("save search");
465 vault
466 .save_search("ideas", "#ideas")
467 .await
468 .expect("save search");
469 let settings = AppSettings::default();
470 let (tx, _rx) = unbounded_channel();
471 let mut modal = SavedSearchesModal::new(
472 vault.clone(),
473 settings.key_bindings.clone(),
474 settings.icons(),
475 tx.clone(),
476 );
477 poll_engine_idle(&mut modal).await;
478
479 Overlay::handle_input(
481 &mut modal,
482 &InputEvent::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
483 &tx,
484 );
485 let target = modal
486 .list
487 .selected_row()
488 .filter(|i| !i.is_virtual)
489 .expect("a non-virtual row is selected")
490 .name
491 .clone();
492
493 Overlay::handle_input(
495 &mut modal,
496 &InputEvent::Key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)),
497 &tx,
498 );
499 poll_engine_idle(&mut modal).await;
500
501 let remaining = vault.list_saved_searches().await.expect("list");
503 assert_eq!(remaining.len(), 1, "one saved search should remain");
504 assert!(
505 !remaining.iter().any(|s| s.name == target),
506 "deleted name {target} should be gone from the vault"
507 );
508
509 let visible: Vec<String> = modal
511 .list
512 .visible_rows()
513 .iter()
514 .map(|r| r.name.clone())
515 .collect();
516 assert!(
517 !visible.contains(&target),
518 "deleted name {target} should be gone from the visible rows, got {visible:?}"
519 );
520 }
521
522 #[tokio::test]
526 async fn enter_emits_selected_not_close() {
527 let vault = temp_vault("saved_searches_modal").await;
528 vault
529 .save_search("todo", "#todo")
530 .await
531 .expect("save search");
532 vault
533 .save_search("ideas", "#ideas")
534 .await
535 .expect("save search");
536 let settings = AppSettings::default();
537 let (tx, mut rx) = unbounded_channel();
538 let mut modal = SavedSearchesModal::new(
539 vault,
540 settings.key_bindings.clone(),
541 settings.icons(),
542 tx.clone(),
543 );
544 modal.list.poll_until_idle().await;
545
546 Overlay::handle_input(
547 &mut modal,
548 &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
549 &tx,
550 );
551
552 let mut events = Vec::new();
553 while let Ok(ev) = rx.try_recv() {
554 events.push(ev);
555 }
556 assert!(
557 events
558 .iter()
559 .any(|e| matches!(e, AppEvent::SavedSearch(SavedSearchFlow::Selected { .. }))),
560 "expected SavedSearchSelected, got {events:?}"
561 );
562 assert!(
563 !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
564 "select must not emit CloseOverlay; editor's SavedSearchSelected handler closes the overlay, got {events:?}"
565 );
566 }
567}