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