1use std::sync::{Arc, Mutex};
2
3use crate::settings::themes::Theme;
4use async_trait::async_trait;
5use chrono::NaiveDate;
6use kimun_core::nfs::VaultPath;
7use kimun_core::{NoteVault, NotesValidation, ResultType, VaultBrowseOptionsBuilder};
8use ratatui::Frame;
9use ratatui::crossterm::event::{MouseButton, MouseEventKind};
10use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
11use ratatui::style::Style;
12use ratatui::widgets::{Block, Borders, Paragraph};
13
14use crate::components::Component;
15use crate::components::event_state::EventState;
16use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
17use crate::components::file_list::{FileListEntry, SortField, SortOrder};
18use crate::components::search_list::{
19 Emit, Filter, KeyReaction, RowSource, SearchList, SearchMouse,
20};
21use crate::keys::KeyBindings;
22use crate::settings::AppSettings;
23use crate::settings::icons::Icons;
24
25struct DirListingSource {
30 vault: Arc<NoteVault>,
31 dir: VaultPath,
32 sort: Arc<Mutex<(SortField, SortOrder)>>,
36 group_dirs: Arc<Mutex<bool>>,
38}
39
40#[async_trait]
41impl RowSource<FileListEntry> for DirListingSource {
42 async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
43 if !self.dir.is_root_or_empty() {
45 emit.push(FileListEntry::Up {
46 parent: self.dir.get_parent_path().0,
47 });
48 }
49
50 let (options, rx) = VaultBrowseOptionsBuilder::new(&self.dir)
51 .recursive(false)
52 .validation(NotesValidation::Full)
53 .build();
54
55 let vault = self.vault.clone();
56 let browse = tokio::spawn(async move { vault.browse_vault(options).await });
58
59 let vault = self.vault.clone();
62 let dir = self.dir.clone();
63 let (field, order) = *self.sort.lock().unwrap();
66 let group_dirs = *self.group_dirs.lock().unwrap();
67 let drain = tokio::task::spawn_blocking(move || {
68 let mut entries: Vec<FileListEntry> = Vec::new();
69 while let Ok(result) = rx.recv() {
70 if matches!(result.rtype, ResultType::Directory) && result.path == dir {
71 continue;
72 }
73 let journal_date = vault.journal_date(&result.path).map(format_journal_date);
74 entries.push(FileListEntry::from_result(result, journal_date));
75 }
76 let cmp = |a: &FileListEntry, b: &FileListEntry| {
77 let ka = a.sort_key(field);
78 let kb = b.sort_key(field);
79 match order {
80 SortOrder::Ascending => ka.cmp(&kb),
81 SortOrder::Descending => kb.cmp(&ka),
82 }
83 };
84 if group_dirs {
85 let (mut dirs, mut rest): (Vec<_>, Vec<_>) = entries
86 .into_iter()
87 .partition(|e| matches!(e, FileListEntry::Directory { .. }));
88 dirs.sort_by(&cmp);
89 rest.sort_by(&cmp);
90 dirs.extend(rest);
91 dirs
92 } else {
93 entries.sort_by(&cmp);
94 entries
95 }
96 });
97
98 match drain.await {
99 Ok(entries) => {
100 for entry in entries {
101 emit.push(entry);
102 }
103 }
104 Err(e) => tracing::warn!("sidebar directory listing drain failed: {e}"),
105 }
106 if let Err(e) = browse.await {
107 tracing::warn!("sidebar browse_vault task failed: {e}");
108 }
109 emit.done();
110 }
111
112 fn leading_row(&self, query: &str) -> Option<FileListEntry> {
113 if query.is_empty() {
114 None
115 } else {
116 let path = self.dir.append(&VaultPath::note_path_from(query)).flatten();
117 Some(FileListEntry::CreateNote {
118 filename: path.to_string(),
119 path,
120 })
121 }
122 }
123
124 fn reload_on_query(&self) -> bool {
125 false
128 }
129}
130
131pub struct SidebarComponent {
132 current_dir: VaultPath,
133 list: Option<SearchList<FileListEntry>>,
134 vault: Arc<NoteVault>,
135 icons: Icons,
136 default_sort_field: SortField,
137 default_sort_order: SortOrder,
138 journal_sort_field: SortField,
139 journal_sort_order: SortOrder,
140 sort: Arc<Mutex<(SortField, SortOrder)>>,
144 group_dirs: Arc<Mutex<bool>>,
147 rendered_rect: Rect,
148 key_bindings: KeyBindings,
149}
150
151impl SidebarComponent {
152 pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
156 Self::new(
157 settings.key_bindings.clone(),
158 vault,
159 settings.icons(),
160 settings,
161 )
162 }
163
164 pub fn new(
165 key_bindings: KeyBindings,
166 vault: Arc<NoteVault>,
167 icons: Icons,
168 settings: &AppSettings,
169 ) -> Self {
170 let default_sort_field = SortField::from(settings.default_sort_field);
171 let default_sort_order = SortOrder::from(settings.default_sort_order);
172 Self {
173 current_dir: VaultPath::root(),
174 list: None,
175 vault,
176 icons,
177 default_sort_field,
178 default_sort_order,
179 journal_sort_field: SortField::from(settings.journal_sort_field),
180 journal_sort_order: SortOrder::from(settings.journal_sort_order),
181 sort: Arc::new(Mutex::new((default_sort_field, default_sort_order))),
182 group_dirs: Arc::new(Mutex::new(settings.group_directories)),
183 rendered_rect: Rect::default(),
184 key_bindings,
185 }
186 }
187
188 pub fn current_dir(&self) -> &VaultPath {
189 &self.current_dir
190 }
191
192 pub fn is_empty(&self) -> bool {
195 self.list.is_none()
196 }
197
198 fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
200 if dir == self.vault.journal_path() {
201 (self.journal_sort_field, self.journal_sort_order)
202 } else {
203 (self.default_sort_field, self.default_sort_order)
204 }
205 }
206
207 pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
211 self.current_dir = dir.clone();
212 let (sort_field, sort_order) = self.sort_for(&dir);
213 self.sort = Arc::new(Mutex::new((sort_field, sort_order)));
214 let source = DirListingSource {
215 vault: self.vault.clone(),
216 dir,
217 sort: self.sort.clone(),
218 group_dirs: self.group_dirs.clone(),
219 };
220 self.list = Some(
221 SearchList::builder(source, redraw_callback(tx.clone()))
222 .filter(Filter::Fuzzy)
223 .icons(self.icons.clone())
224 .build(),
225 );
226 }
227
228 pub fn current_sort(&self) -> (SortField, SortOrder) {
230 *self.sort.lock().unwrap()
231 }
232
233 pub fn group_dirs(&self) -> bool {
235 *self.group_dirs.lock().unwrap()
236 }
237
238 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
241 *self.sort.lock().unwrap() = (field, order);
242 *self.group_dirs.lock().unwrap() = group_dirs;
243 if let Some(list) = &mut self.list {
244 list.reload();
245 }
246 }
247
248 pub fn is_current_journal(&self) -> bool {
251 &self.current_dir == self.vault.journal_path()
252 }
253
254 pub fn save_default(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
260 if self.is_current_journal() {
261 self.journal_sort_field = field;
262 self.journal_sort_order = order;
263 } else {
264 self.default_sort_field = field;
265 self.default_sort_order = order;
266 }
267 self.apply_sort(field, order, group_dirs);
268 }
269
270 fn note_count(&self) -> usize {
272 match &self.list {
273 None => 0,
274 Some(list) => list
275 .visible_rows()
276 .iter()
277 .filter(|e| matches!(e, FileListEntry::Note { .. }))
278 .count(),
279 }
280 }
281
282 fn activate_selected_entry(&self, tx: &AppTx) {
286 let Some(list) = &self.list else { return };
287 let Some(entry) = list.selected_row() else {
288 return;
289 };
290 match entry {
291 FileListEntry::CreateNote { path, .. } => {
292 let path = path.clone();
293 let vault = Arc::clone(&self.vault);
294 let tx2 = tx.clone();
295 tokio::spawn(async move {
296 if let Err(e) = vault.load_or_create_note(&path, None).await {
297 tracing::warn!("create note failed for {path}: {e}");
298 return;
299 }
300 tx2.send(AppEvent::OpenPath(path)).ok();
301 });
302 }
303 other => {
304 tx.send(AppEvent::OpenPath(other.path().clone())).ok();
305 }
306 }
307 }
308}
309
310fn format_journal_date(date: NaiveDate) -> String {
313 date.format("%A, %B %-d, %Y").to_string()
314}
315
316impl Component for SidebarComponent {
317 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
318 if let InputEvent::Mouse(mouse) = event {
319 let pos = Position {
320 x: mouse.column,
321 y: mouse.row,
322 };
323 if !self.rendered_rect.contains(pos) {
324 return EventState::NotConsumed;
325 }
326 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
328 tx.send(AppEvent::FocusSidebar).ok();
329 }
330 if let Some(list) = &mut self.list {
331 match mouse.kind {
332 MouseEventKind::ScrollUp => list.select_prev(),
337 MouseEventKind::ScrollDown => list.select_next(),
338 _ => match list.handle_mouse(mouse) {
339 SearchMouse::Activated(_) => self.activate_selected_entry(tx),
340 SearchMouse::Selected(_) | SearchMouse::Scrolled | SearchMouse::None => {}
341 },
342 }
343 }
344 return EventState::Consumed;
345 }
346
347 if let InputEvent::Key(key) = event {
348 if self.list.is_none() {
349 return EventState::NotConsumed;
350 }
351 let reaction = self.list.as_mut().unwrap().handle_key(key);
352 match reaction {
353 KeyReaction::Submit => {
354 self.activate_selected_entry(tx);
355 EventState::Consumed
356 }
357 KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
358 KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
359 }
360 } else {
361 EventState::NotConsumed
362 }
363 }
364
365 fn hint_shortcuts(&self) -> Vec<(String, String)> {
366 use crate::keys::action_shortcuts::ActionShortcuts;
367
368 [
369 (ActionShortcuts::FocusEditor, "editor \u{2192}"),
370 (ActionShortcuts::OpenSortDialog, "sort"),
371 ]
372 .iter()
373 .filter_map(|(action, label)| {
374 self.key_bindings
375 .first_combo_for(action)
376 .map(|k| (k, label.to_string()))
377 })
378 .collect()
379 }
380
381 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
382 self.rendered_rect = rect;
383
384 let rows = Layout::default()
385 .direction(Direction::Vertical)
386 .constraints([
387 Constraint::Length(3),
388 Constraint::Length(3),
389 Constraint::Min(0),
390 ])
391 .split(rect);
392
393 let border_style = theme.border_style(focused);
394
395 let header = Block::default()
396 .title(self.current_dir.to_string())
397 .borders(Borders::ALL)
398 .border_style(border_style)
399 .style(theme.panel_style());
400 let header_inner = header.inner(rows[0]);
401 f.render_widget(header, rows[0]);
402 f.render_widget(
403 Paragraph::new(format!("{} notes", self.note_count())).style(
404 Style::default()
405 .fg(theme.fg_muted.to_ratatui())
406 .bg(theme.bg_panel.to_ratatui()),
407 ),
408 header_inner,
409 );
410
411 let search_block = Block::default()
412 .title(" Search")
413 .borders(Borders::ALL)
414 .border_style(border_style)
415 .style(theme.panel_style());
416 let search_inner = search_block.inner(rows[1]);
417 f.render_widget(search_block, rows[1]);
418
419 let list_block = Block::default()
420 .borders(Borders::ALL)
421 .border_style(border_style)
422 .style(theme.panel_style());
423 let list_inner = list_block.inner(rows[2]);
424 f.render_widget(list_block, rows[2]);
425
426 if let Some(list) = &mut self.list {
427 list.render_query(f, search_inner, theme, focused);
428 list.render(f, list_inner, theme, focused);
429 list.set_list_rect(list_inner);
433 }
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::settings::AppSettings;
441 use crate::test_support::{mouse_down_at, temp_vault};
442 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
443 use tokio::sync::mpsc::unbounded_channel;
444
445 async fn make_sidebar() -> SidebarComponent {
446 let vault = temp_vault("sidebar").await;
447 vault.validate_and_init().await.unwrap();
448 let settings = AppSettings::default();
449 SidebarComponent::new(
450 settings.key_bindings.clone(),
451 vault,
452 settings.icons(),
453 &settings,
454 )
455 }
456
457 async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
459 let vault = temp_vault(prefix).await;
460 vault.validate_and_init().await.unwrap();
461 for name in names {
462 vault
463 .create_note(&VaultPath::note_path_from(name), "body")
464 .await
465 .unwrap();
466 }
467 let settings = AppSettings::default();
468 SidebarComponent::new(
469 settings.key_bindings.clone(),
470 vault,
471 settings.icons(),
472 &settings,
473 )
474 }
475
476 #[tokio::test]
479 async fn mouse_down_on_header_focuses_sidebar() {
480 let mut sidebar = make_sidebar().await;
481 sidebar.rendered_rect = Rect {
482 x: 0,
483 y: 3,
484 width: 30,
485 height: 20,
486 };
487 let (tx, mut rx) = unbounded_channel();
488
489 let result = sidebar.handle_input(&mouse_down_at(5, 4), &tx);
491 assert_eq!(result, EventState::Consumed);
492 let evt = rx.try_recv().expect("should send a focus event");
493 assert!(matches!(evt, AppEvent::FocusSidebar));
494 }
495
496 #[tokio::test]
497 async fn mouse_down_on_search_box_focuses_sidebar() {
498 let mut sidebar = make_sidebar().await;
499 sidebar.rendered_rect = Rect {
500 x: 0,
501 y: 3,
502 width: 30,
503 height: 20,
504 };
505 let (tx, mut rx) = unbounded_channel();
506
507 let result = sidebar.handle_input(&mouse_down_at(5, 7), &tx);
509 assert_eq!(result, EventState::Consumed);
510 let evt = rx.try_recv().expect("should send a focus event");
511 assert!(matches!(evt, AppEvent::FocusSidebar));
512 }
513
514 fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
515 InputEvent::Mouse(MouseEvent {
516 kind,
517 column: col,
518 row,
519 modifiers: KeyModifiers::NONE,
520 })
521 }
522
523 async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
526 sidebar.navigate(VaultPath::root(), tx);
527 for _ in 0..50 {
530 if let Some(list) = &mut sidebar.list {
531 list.poll();
532 if !list.is_loading() {
533 break;
534 }
535 }
536 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
537 }
538 if let Some(list) = &mut sidebar.list {
539 list.poll();
540 }
541 }
542
543 #[tokio::test(flavor = "multi_thread")]
546 async fn mouse_double_click_on_list_row_sends_open_path() {
547 let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
548 let (tx, mut rx) = unbounded_channel();
549 navigate_to_root(&mut sidebar, &tx).await;
550
551 sidebar.rendered_rect = Rect {
552 x: 0,
553 y: 3,
554 width: 30,
555 height: 20,
556 };
557 if let Some(list) = &mut sidebar.list {
560 list.set_list_rect(Rect {
561 x: 0,
562 y: 9,
563 width: 30,
564 height: 14,
565 });
566 }
567
568 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
570 let _ = rx.try_recv();
572
573 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
575 let mut events = Vec::new();
576 while let Ok(evt) = rx.try_recv() {
577 events.push(evt);
578 }
579 assert!(
580 events
581 .iter()
582 .any(|e| matches!(e, AppEvent::OpenPath(p) if p.to_string().contains("alpha"))),
583 "expected OpenPath for the activated note, got {events:?}"
584 );
585 }
586
587 #[tokio::test(flavor = "multi_thread")]
590 async fn scroll_down_in_sidebar_bounds_scrolls_list() {
591 let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
592 let (tx, _rx) = unbounded_channel();
593 navigate_to_root(&mut sidebar, &tx).await;
594
595 sidebar.rendered_rect = Rect {
596 x: 0,
597 y: 3,
598 width: 30,
599 height: 20,
600 };
601 if let Some(list) = &mut sidebar.list {
602 list.set_list_rect(Rect {
603 x: 0,
604 y: 9,
605 width: 30,
606 height: 14,
607 });
608 }
609
610 let first = sidebar
611 .list
612 .as_ref()
613 .unwrap()
614 .selected_row()
615 .map(|e| e.path().to_string());
616
617 let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
619 assert_eq!(result, EventState::Consumed);
620 let after = sidebar
621 .list
622 .as_ref()
623 .unwrap()
624 .selected_row()
625 .map(|e| e.path().to_string());
626 assert_ne!(first, after, "scroll-from-header should move the selection");
627 }
628
629 #[tokio::test]
630 async fn mouse_down_outside_sidebar_is_not_consumed() {
631 let mut sidebar = make_sidebar().await;
632 sidebar.rendered_rect = Rect {
633 x: 0,
634 y: 3,
635 width: 30,
636 height: 20,
637 };
638 let (tx, mut rx) = unbounded_channel();
639
640 let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
642 assert_eq!(result, EventState::NotConsumed);
643 assert!(rx.try_recv().is_err());
644 }
645
646 #[tokio::test(flavor = "multi_thread")]
648 async fn navigate_loads_directory_notes() {
649 let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
650 assert!(sidebar.is_empty());
651 let (tx, _rx) = unbounded_channel();
652 navigate_to_root(&mut sidebar, &tx).await;
653 assert!(!sidebar.is_empty());
654 assert_eq!(sidebar.note_count(), 1);
655 }
656
657 async fn poll_to_idle(sidebar: &mut SidebarComponent) {
660 for _ in 0..50 {
661 if let Some(list) = &mut sidebar.list {
662 list.poll();
663 if !list.is_loading() {
664 break;
665 }
666 }
667 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
668 }
669 if let Some(list) = &mut sidebar.list {
670 list.poll();
671 }
672 }
673
674 fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
676 sidebar
677 .list
678 .as_ref()
679 .unwrap()
680 .visible_rows()
681 .iter()
682 .filter_map(|e| match e {
683 FileListEntry::Note { filename, .. } => Some(filename.clone()),
684 _ => None,
685 })
686 .collect()
687 }
688
689 #[tokio::test(flavor = "multi_thread")]
690 async fn apply_sort_reverse_flips_listing_order() {
691 let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
692 let (tx, _rx) = unbounded_channel();
693 navigate_to_root(&mut sidebar, &tx).await;
694 let before = note_names(&sidebar);
695 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
696 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
697 poll_to_idle(&mut sidebar).await;
698 let after = note_names(&sidebar);
699 assert_eq!(
700 after,
701 before.iter().rev().cloned().collect::<Vec<_>>(),
702 "descending order should reverse the listing"
703 );
704 }
705
706 #[tokio::test(flavor = "multi_thread")]
707 async fn apply_sort_changes_field() {
708 let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
709 let (tx, _rx) = unbounded_channel();
710 navigate_to_root(&mut sidebar, &tx).await;
711 sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
712 poll_to_idle(&mut sidebar).await;
713 assert_eq!(sidebar.current_sort().0, SortField::Title);
714 assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
715 }
716
717 async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
719 let vault = temp_vault(prefix).await;
720 vault.validate_and_init().await.unwrap();
721 vault
722 .create_note(&VaultPath::note_path_from("alpha"), "body")
723 .await
724 .unwrap();
725 vault
726 .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
727 .await
728 .unwrap();
729 let settings = AppSettings::default();
730 SidebarComponent::new(
731 settings.key_bindings.clone(),
732 vault,
733 settings.icons(),
734 &settings,
735 )
736 }
737
738 fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
740 sidebar
741 .list
742 .as_ref()
743 .unwrap()
744 .visible_rows()
745 .iter()
746 .filter_map(|e| match e {
747 FileListEntry::Note { .. } => Some("note"),
748 FileListEntry::Directory { .. } => Some("dir"),
749 _ => None,
750 })
751 .collect()
752 }
753
754 #[tokio::test(flavor = "multi_thread")]
755 async fn group_dirs_puts_directories_first() {
756 let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
757 let (tx, _rx) = unbounded_channel();
758 navigate_to_root(&mut sidebar, &tx).await;
759 assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
760 sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
761 poll_to_idle(&mut sidebar).await;
762 assert_eq!(
763 row_kinds(&sidebar),
764 vec!["dir", "note"],
765 "grouping must cluster directories first"
766 );
767 }
768
769 #[tokio::test(flavor = "multi_thread")]
770 async fn apply_sort_updates_shared_state() {
771 let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
772 let (tx, _rx) = unbounded_channel();
773 navigate_to_root(&mut sidebar, &tx).await;
774 sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
775 poll_to_idle(&mut sidebar).await;
776 assert_eq!(
777 sidebar.current_sort(),
778 (SortField::Title, SortOrder::Descending)
779 );
780 assert!(!sidebar.group_dirs());
781 }
782
783 #[tokio::test(flavor = "multi_thread")]
788 async fn save_default_survives_navigation() {
789 let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
790 let (tx, _rx) = unbounded_channel();
791 navigate_to_root(&mut sidebar, &tx).await;
792
793 sidebar.save_default(SortField::Title, SortOrder::Descending, false);
794 poll_to_idle(&mut sidebar).await;
795
796 sidebar.navigate(VaultPath::root(), &tx);
799 poll_to_idle(&mut sidebar).await;
800 assert_eq!(
801 sidebar.current_sort(),
802 (SortField::Title, SortOrder::Descending),
803 "saved default must persist across navigation"
804 );
805 }
806}