1use std::sync::Arc;
2use std::sync::mpsc::Receiver;
3
4use async_trait::async_trait;
5use chrono::NaiveDate;
6use kimun_core::NoteVault;
7use kimun_core::nfs::VaultPath;
8use ratatui::Frame;
9use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
10use ratatui::style::Style;
11use ratatui::widgets::{Block, Borders, Clear, Paragraph};
12
13use crate::components::Component;
14use crate::components::autocomplete::{
15 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
16 TriggerOptions,
17};
18use crate::components::event_state::EventState;
19use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
20use crate::components::file_list::{FileListComponent, FileListEntry};
21use crate::components::single_line_input::{InputOutcome, SingleLineInput};
22use crate::keys::KeyBindings;
23use crate::settings::icons::Icons;
24use crate::settings::themes::Theme;
25
26pub mod file_finder_provider;
27pub mod link_results_provider;
28pub mod search_provider;
29
30#[async_trait]
35pub trait NoteBrowserProvider: Send + Sync {
36 async fn load(&self, query: &str) -> Vec<FileListEntry>;
38
39 fn allows_create(&self) -> bool {
42 false
43 }
44}
45
46pub struct NoteBrowserModal {
51 title: String,
52 search_query: SingleLineInput,
53 provider: Arc<dyn NoteBrowserProvider>,
54 file_list: FileListComponent,
55 list_rect: Rect,
56 preview_text: String,
57 vault: Arc<NoteVault>,
58 tx: AppTx,
59 load_task: Option<tokio::task::JoinHandle<()>>,
61 load_rx: Option<Receiver<Vec<FileListEntry>>>,
62 preview_task: Option<tokio::task::JoinHandle<()>>,
64 preview_rx: Option<Receiver<String>>,
65 autocomplete: AutocompleteController,
67}
68
69struct SearchBoxHostSnapshot {
75 lines: Vec<String>,
76 cursor: (usize, usize),
80 caret_pos: Option<(u16, u16)>,
81}
82
83impl AutocompleteHost for SearchBoxHostSnapshot {
84 fn buffer_snapshot(&self) -> crate::components::text_editor::snapshot::EditorSnapshot<'_> {
85 use std::num::NonZeroU64;
86 let dummy = NonZeroU64::new(1).unwrap();
89 crate::components::text_editor::snapshot::EditorSnapshot::borrowed(
90 &self.lines,
91 self.cursor,
92 dummy,
93 )
94 }
95 fn cache_key(&self) -> Option<std::num::NonZeroU64> {
96 None
101 }
102 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
103 self.caret_pos
106 }
107}
108
109impl NoteBrowserModal {
110 pub fn new(
111 title: impl Into<String>,
112 provider: impl NoteBrowserProvider + 'static,
113 vault: Arc<NoteVault>,
114 key_bindings: KeyBindings,
115 icons: Icons,
116 tx: AppTx,
117 ) -> Self {
118 Self::new_with_query(
119 title,
120 provider,
121 vault,
122 key_bindings,
123 icons,
124 tx,
125 String::new(),
126 )
127 }
128
129 fn new_with_query(
130 title: impl Into<String>,
131 provider: impl NoteBrowserProvider + 'static,
132 vault: Arc<NoteVault>,
133 key_bindings: KeyBindings,
134 icons: Icons,
135 tx: AppTx,
136 initial_query: String,
137 ) -> Self {
138 let file_list = FileListComponent::new(key_bindings, icons);
139 let mut autocomplete =
144 AutocompleteController::new(vault.clone(), AutocompleteMode::HashtagOnly)
145 .with_trigger_opts(TriggerOptions {
146 disambiguate_header: false,
147 apply_exclusion_zone: false,
148 });
149 autocomplete.set_redraw_callback(redraw_callback(tx.clone()));
150 let mut modal = Self {
151 title: title.into(),
152 search_query: SingleLineInput::new(),
153 provider: Arc::new(provider),
154 file_list,
155 list_rect: Rect::default(),
156 preview_text: String::new(),
157 vault,
158 tx: tx.clone(),
159 load_task: None,
160 load_rx: None,
161 preview_task: None,
162 preview_rx: None,
163 autocomplete,
164 };
165 if !initial_query.is_empty() {
166 modal.search_query.set_value(initial_query);
167 }
168 modal.schedule_load(tx);
169 modal
170 }
171
172 fn schedule_load(&mut self, tx: AppTx) {
175 if let Some(handle) = self.load_task.take() {
176 handle.abort();
177 }
178 let query = self.search_query.value().to_string();
179 let provider = Arc::clone(&self.provider);
180 let (result_tx, result_rx) = std::sync::mpsc::channel();
181 self.load_rx = Some(result_rx);
182
183 let handle = tokio::spawn(async move {
184 let entries = provider.load(&query).await;
185 result_tx.send(entries).ok();
186 tx.send(AppEvent::Redraw).ok();
187 });
188 self.load_task = Some(handle);
189 }
190
191 fn poll_load(&mut self) {
192 let Some(rx) = &self.load_rx else { return };
193 match rx.try_recv() {
194 Ok(entries) => {
195 self.file_list.clear();
196 let mut create_entry: Option<FileListEntry> = None;
197 for entry in entries {
198 if matches!(entry, FileListEntry::CreateNote { .. }) {
199 create_entry = Some(entry);
200 } else {
201 self.file_list.push_entry(entry);
202 }
203 }
204 if let Some(entry) = create_entry {
205 self.file_list.prepend_create_entry(entry);
206 }
207 self.load_rx = None;
208 self.load_task = None;
209 self.refresh_preview();
210 }
211 Err(std::sync::mpsc::TryRecvError::Empty) => {}
212 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
213 self.load_rx = None;
214 }
215 }
216 }
217
218 fn schedule_preview(&mut self, path: VaultPath) {
221 if let Some(handle) = self.preview_task.take() {
222 handle.abort();
223 }
224 let vault = Arc::clone(&self.vault);
225 let tx = self.tx.clone();
226 let (result_tx, result_rx) = std::sync::mpsc::channel();
227 self.preview_rx = Some(result_rx);
228
229 let handle = tokio::spawn(async move {
230 let text = vault.get_note_text(&path).await.unwrap_or_default();
231 result_tx.send(text).ok();
232 tx.send(AppEvent::Redraw).ok();
233 });
234 self.preview_task = Some(handle);
235 }
236
237 fn poll_preview(&mut self) {
238 let Some(rx) = &self.preview_rx else { return };
239 match rx.try_recv() {
240 Ok(text) => {
241 self.preview_text = text;
242 self.preview_rx = None;
243 self.preview_task = None;
244 }
245 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
246 self.preview_rx = None;
247 }
248 Err(std::sync::mpsc::TryRecvError::Empty) => {}
249 }
250 }
251
252 fn open_selected_entry(&self, tx: &AppTx) {
253 let Some(entry) = self.file_list.selected_entry() else {
254 return;
255 };
256 if let FileListEntry::CreateNote { path, .. } = entry {
257 let path = path.clone();
258 let vault = Arc::clone(&self.vault);
259 let tx = tx.clone();
260 tokio::spawn(async move {
261 vault.load_or_create_note(&path, None).await.ok();
262 tx.send(AppEvent::OpenPath(path)).ok();
263 tx.send(AppEvent::CloseNoteBrowser).ok();
264 });
265 return;
266 }
267 let path = entry.path().clone();
268 tx.send(AppEvent::OpenPath(path)).ok();
269 tx.send(AppEvent::CloseNoteBrowser).ok();
270 }
271
272 pub fn with_initial_query<S: Into<String>>(
280 title: impl Into<String>,
281 provider: impl NoteBrowserProvider + 'static,
282 vault: Arc<NoteVault>,
283 key_bindings: KeyBindings,
284 icons: Icons,
285 tx: AppTx,
286 query: S,
287 ) -> Self {
288 Self::new_with_query(
289 title,
290 provider,
291 vault,
292 key_bindings,
293 icons,
294 tx,
295 query.into(),
296 )
297 }
298
299 #[cfg(test)]
303 pub(super) fn query_text(&self) -> &str {
304 self.search_query.value()
305 }
306
307 #[cfg(test)]
309 pub(super) fn cursor_char_count(&self) -> usize {
310 self.search_query.cursor_char_offset()
311 }
312
313 fn refresh_preview(&mut self) {
316 let maybe_path = self.file_list.selected_entry().and_then(|e| match e {
317 FileListEntry::Note { path, .. } => Some(path.clone()),
318 _ => None,
319 });
320 if let Some(path) = maybe_path {
321 self.schedule_preview(path);
322 } else {
323 self.preview_text.clear();
324 if let Some(h) = self.preview_task.take() {
325 h.abort();
326 }
327 }
328 }
329
330 fn autocomplete_snapshot(&self) -> SearchBoxHostSnapshot {
333 let value = self.search_query.value().to_string();
336 let cursor_byte = self.search_query.cursor_byte();
337 let col = value[..cursor_byte.min(value.len())].chars().count();
338 SearchBoxHostSnapshot {
339 lines: vec![value],
340 cursor: (0, col),
341 caret_pos: self.search_query.last_caret_pos(),
342 }
343 }
344}
345
346impl Component for NoteBrowserModal {
351 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
352 use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
353
354 if let InputEvent::Mouse(mouse) = event {
355 self.autocomplete.close();
361 let r = self.list_rect;
362 if !r.contains(Position {
363 x: mouse.column,
364 y: mouse.row,
365 }) {
366 return EventState::NotConsumed;
367 }
368 match mouse.kind {
369 MouseEventKind::Down(MouseButton::Left) => {
370 if mouse.row > r.y {
371 let rel_row = mouse.row - r.y - 1;
372 let prev = self.file_list.selected_display_idx();
373 if let Some(idx) = self.file_list.select_at_visual_row(rel_row) {
374 if prev == Some(idx) {
375 self.open_selected_entry(tx);
376 } else {
377 self.refresh_preview();
378 }
379 }
380 }
381 EventState::Consumed
382 }
383 MouseEventKind::ScrollUp => {
384 self.file_list.scroll_up();
385 EventState::Consumed
386 }
387 MouseEventKind::ScrollDown => {
388 self.file_list.scroll_down();
389 EventState::Consumed
390 }
391 _ => EventState::Consumed,
392 }
393 } else {
394 let InputEvent::Key(key) = event else {
395 return EventState::NotConsumed;
396 };
397
398 if self.autocomplete.is_open() {
403 let snapshot = self.autocomplete_snapshot();
404 match self.autocomplete.handle_key(*key, &snapshot) {
405 HandleKeyOutcome::Accepted(action) => {
406 self.search_query.replace_range_bytes(
407 action.range.clone(),
408 &action.new_text,
409 action.new_cursor_byte,
410 );
411 self.schedule_load(tx.clone());
415 return EventState::Consumed;
416 }
417 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
418 return EventState::Consumed;
419 }
420 HandleKeyOutcome::NotHandled => {}
421 }
422 }
423
424 match key.code {
426 KeyCode::Up => {
427 self.file_list.select_prev();
428 self.refresh_preview();
429 return EventState::Consumed;
430 }
431 KeyCode::Down => {
432 self.file_list.select_next();
433 self.refresh_preview();
434 return EventState::Consumed;
435 }
436 _ => {}
437 }
438 if let KeyCode::Char(_) = key.code {
440 let non_shift = key.modifiers - KeyModifiers::SHIFT;
441 if !non_shift.is_empty() {
442 return EventState::Consumed;
443 }
444 }
445 let outcome = self.search_query.handle_key(key);
446 let snapshot = self.autocomplete_snapshot();
454 match outcome {
455 InputOutcome::Changed => self.autocomplete.sync(&snapshot),
456 InputOutcome::Consumed => self.autocomplete.refresh_if_open(&snapshot),
457 InputOutcome::Cancel | InputOutcome::Submit => {
458 self.autocomplete.close();
459 }
460 InputOutcome::NotConsumed => {}
461 }
462 match outcome {
463 InputOutcome::Cancel => {
464 tx.send(AppEvent::CloseNoteBrowser).ok();
465 EventState::Consumed
466 }
467 InputOutcome::Submit => {
468 self.open_selected_entry(tx);
469 EventState::Consumed
470 }
471 InputOutcome::Changed => {
472 self.schedule_load(tx.clone());
473 EventState::Consumed
474 }
475 InputOutcome::Consumed => EventState::Consumed,
476 InputOutcome::NotConsumed => EventState::NotConsumed,
477 }
478 }
479 }
480
481 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, _focused: bool) {
482 self.poll_load();
483 self.poll_preview();
484
485 let popup_rect = centered_rect(80, 75, area);
486
487 f.render_widget(Clear, popup_rect);
489
490 let outer_block = Block::default()
491 .title(format!(" {} ", self.title))
492 .borders(Borders::ALL)
493 .border_style(theme.border_style(true))
494 .style(theme.panel_style());
495 let inner = outer_block.inner(popup_rect);
496 f.render_widget(outer_block, popup_rect);
497
498 let rows = Layout::default()
499 .direction(Direction::Vertical)
500 .constraints([
501 Constraint::Length(3),
502 Constraint::Min(0),
503 Constraint::Length(1),
504 ])
505 .split(inner);
506
507 let search_block = Block::default()
509 .title(" Search ")
510 .borders(Borders::ALL)
511 .border_style(theme.border_style(true))
512 .style(theme.panel_style());
513 let search_inner = search_block.inner(rows[0]);
514 f.render_widget(search_block, rows[0]);
515 self.search_query.render(
516 f,
517 search_inner,
518 Style::default()
519 .fg(theme.fg.to_ratatui())
520 .bg(theme.bg_panel.to_ratatui()),
521 0,
522 true,
523 );
524
525 let columns = Layout::default()
527 .direction(Direction::Horizontal)
528 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
529 .split(rows[1]);
530
531 self.list_rect = columns[0];
532 self.file_list.render(f, columns[0], theme, false);
533
534 let preview_block = Block::default()
535 .title(" Preview ")
536 .borders(Borders::ALL)
537 .border_style(theme.border_style(false))
538 .style(theme.panel_style());
539 let preview_inner = preview_block.inner(columns[1]);
540 f.render_widget(preview_block, columns[1]);
541 f.render_widget(
542 Paragraph::new(self.preview_text.as_str()).style(
543 Style::default()
544 .fg(theme.fg.to_ratatui())
545 .bg(theme.bg.to_ratatui()),
546 ),
547 preview_inner,
548 );
549
550 f.render_widget(
552 Paragraph::new("↑↓: navigate | Enter: open | Esc: close")
553 .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
554 rows[2],
555 );
556
557 self.autocomplete.poll_results();
563 let live_anchor = self.search_query.last_caret_pos();
564 if let (Some(state), Some(anchor)) = (self.autocomplete.state_mut(), live_anchor) {
565 state.anchor = anchor;
566 }
567 if let Some(state) = self.autocomplete.state() {
568 autocomplete::render(f, state, popup_rect, theme);
569 }
570 }
571
572 fn hint_shortcuts(&self) -> Vec<(String, String)> {
573 vec![
574 ("↑↓".to_string(), "navigate".to_string()),
575 ("Enter".to_string(), "open".to_string()),
576 ("Esc".to_string(), "close".to_string()),
577 ]
578 }
579}
580
581fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
586 let popup_height = area.height * percent_y / 100;
587 let popup_width = area.width * percent_x / 100;
588 Rect {
589 x: area.x + (area.width.saturating_sub(popup_width)) / 2,
590 y: area.y + (area.height.saturating_sub(popup_height)) / 2,
591 width: popup_width,
592 height: popup_height,
593 }
594}
595
596pub(super) fn format_journal_date(date: NaiveDate) -> String {
601 date.format("%A, %B %-d, %Y").to_string()
602}
603
604#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::settings::AppSettings;
612 use crate::test_support::{mouse_down_at, temp_vault};
613 use tokio::sync::mpsc::unbounded_channel;
614
615 struct EmptyProvider;
616
617 #[async_trait]
618 impl NoteBrowserProvider for EmptyProvider {
619 async fn load(&self, _query: &str) -> Vec<FileListEntry> {
620 Vec::new()
621 }
622 }
623
624 async fn make_modal() -> NoteBrowserModal {
625 let vault = temp_vault("modal").await;
626 let settings = AppSettings::default();
627 let (tx, _rx) = unbounded_channel();
628 NoteBrowserModal::new(
629 "test",
630 EmptyProvider,
631 vault,
632 settings.key_bindings.clone(),
633 settings.icons(),
634 tx,
635 )
636 }
637
638 #[tokio::test]
642 async fn modal_mouse_down_outside_list_rect_is_not_consumed() {
643 let mut modal = make_modal().await;
644 modal.list_rect = Rect {
645 x: 10,
646 y: 10,
647 width: 20,
648 height: 10,
649 };
650 let (tx, _rx) = unbounded_channel();
651
652 let result = modal.handle_input(&mouse_down_at(0, 0), &tx);
654 assert_eq!(result, EventState::NotConsumed);
655 }
656
657 #[tokio::test]
661 async fn modal_mouse_down_on_list_border_does_not_panic() {
662 let mut modal = make_modal().await;
663 modal.list_rect = Rect {
664 x: 10,
665 y: 10,
666 width: 20,
667 height: 10,
668 };
669 let (tx, _rx) = unbounded_channel();
670 let result = modal.handle_input(&mouse_down_at(15, 10), &tx);
672 assert_eq!(result, EventState::Consumed);
673 assert!(modal.file_list.selected_display_idx().is_none());
674 }
675
676 #[test]
677 fn centered_rect_is_centered() {
678 let area = Rect {
679 x: 0,
680 y: 0,
681 width: 100,
682 height: 40,
683 };
684 let r = centered_rect(80, 75, area);
685 assert_eq!(r.width, 80);
686 assert_eq!(r.height, 30);
687 assert_eq!(r.x, 10); assert_eq!(r.y, 5); }
690
691 #[test]
692 fn centered_rect_does_not_underflow() {
693 let area = Rect {
695 x: 0,
696 y: 0,
697 width: 5,
698 height: 5,
699 };
700 let _ = centered_rect(80, 75, area);
701 }
702
703 #[tokio::test]
706 async fn modal_constructed_with_initial_query_prefills_input() {
707 let vault = temp_vault("modal_iq").await;
708 let settings = AppSettings::default();
709 let (tx, _rx) = unbounded_channel();
710 let modal = NoteBrowserModal::with_initial_query(
711 "test",
712 EmptyProvider,
713 vault,
714 settings.key_bindings.clone(),
715 settings.icons(),
716 tx,
717 "#important",
718 );
719 assert_eq!(modal.query_text(), "#important");
720 assert_eq!(modal.cursor_char_count(), "#important".chars().count());
721 }
722
723 #[tokio::test]
724 async fn modal_new_has_empty_query() {
725 let modal = make_modal().await;
726 assert_eq!(modal.query_text(), "");
727 assert_eq!(modal.cursor_char_count(), 0);
728 }
729
730 #[tokio::test]
735 async fn search_box_autocomplete_accept_inserts_tag() {
736 use kimun_core::nfs::VaultPath;
737 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
738
739 let vault = temp_vault("search_autocomplete").await;
740 vault.validate_and_init().await.unwrap();
741 vault
742 .create_note(&VaultPath::note_path_from("/a.md"), "body #projects")
743 .await
744 .unwrap();
745 let settings = AppSettings::default();
746 let (tx, _rx) = unbounded_channel();
747 let mut modal = NoteBrowserModal::new(
748 "test",
749 EmptyProvider,
750 vault,
751 settings.key_bindings.clone(),
752 settings.icons(),
753 tx,
754 );
755
756 let (tx2, _rx2) = unbounded_channel();
758 for ch in ['#', 'p', 'r', 'o'] {
759 modal.handle_input(
760 &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
761 &tx2,
762 );
763 }
764 modal
767 .search_query
768 .set_last_caret_pos_for_tests(Some((0, 0)));
769 let snapshot = modal.autocomplete_snapshot();
770 modal.autocomplete.sync(&snapshot);
771 tokio::task::yield_now().await;
773 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
774 modal.autocomplete.poll_results();
775
776 assert!(modal.autocomplete.is_open(), "popup should be open");
777
778 modal.handle_input(
780 &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
781 &tx2,
782 );
783 assert_eq!(modal.search_query.value(), "#projects");
784 }
785
786 #[tokio::test]
791 async fn with_initial_query_does_not_double_schedule() {
792 let vault = temp_vault("modal_iq_once").await;
793 let settings = AppSettings::default();
794 let (tx, _rx) = unbounded_channel();
795 let modal = NoteBrowserModal::with_initial_query(
796 "test",
797 EmptyProvider,
798 vault,
799 settings.key_bindings.clone(),
800 settings.icons(),
801 tx,
802 "#important",
803 );
804 assert_eq!(modal.query_text(), "#important");
805 assert_eq!(modal.cursor_char_count(), "#important".chars().count());
806 assert!(modal.load_task.is_some());
811 }
812}