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(_) | SearchMouse::Scrolled | SearchMouse::None => {}
334 }
335 }
336 return EventState::Consumed;
337 }
338
339 if let InputEvent::Key(key) = event {
340 if self.list.is_none() {
341 return EventState::NotConsumed;
342 }
343 let reaction = self.list.as_mut().unwrap().handle_key(key);
344 match reaction {
345 KeyReaction::Submit => {
346 self.activate_selected_entry(tx);
347 EventState::Consumed
348 }
349 KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
350 KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
351 }
352 } else {
353 EventState::NotConsumed
354 }
355 }
356
357 fn hint_shortcuts(&self) -> Vec<(String, String)> {
358 use crate::keys::action_shortcuts::ActionShortcuts;
359
360 [
361 (ActionShortcuts::FocusEditor, "editor \u{2192}"),
362 (ActionShortcuts::OpenSortDialog, "sort"),
363 ]
364 .iter()
365 .filter_map(|(action, label)| {
366 self.key_bindings
367 .first_combo_for(action)
368 .map(|k| (k, label.to_string()))
369 })
370 .collect()
371 }
372
373 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
374 self.rendered_rect = rect;
375
376 let rows = Layout::default()
377 .direction(Direction::Vertical)
378 .constraints([
379 Constraint::Length(3),
380 Constraint::Length(3),
381 Constraint::Min(0),
382 ])
383 .split(rect);
384
385 let border_style = theme.border_style(focused);
386
387 let header = Block::default()
388 .title(self.current_dir.to_string())
389 .borders(Borders::ALL)
390 .border_style(border_style)
391 .style(theme.panel_style());
392 let header_inner = header.inner(rows[0]);
393 f.render_widget(header, rows[0]);
394 f.render_widget(
395 Paragraph::new(format!("{} notes", self.note_count())).style(
396 Style::default()
397 .fg(theme.fg_muted.to_ratatui())
398 .bg(theme.bg_panel.to_ratatui()),
399 ),
400 header_inner,
401 );
402
403 let search_block = Block::default()
404 .title(" Search")
405 .borders(Borders::ALL)
406 .border_style(border_style)
407 .style(theme.panel_style());
408 let search_inner = search_block.inner(rows[1]);
409 f.render_widget(search_block, rows[1]);
410
411 let list_block = Block::default()
412 .borders(Borders::ALL)
413 .border_style(border_style)
414 .style(theme.panel_style());
415 let list_inner = list_block.inner(rows[2]);
416 f.render_widget(list_block, rows[2]);
417
418 if let Some(list) = &mut self.list {
419 list.render_query(f, search_inner, theme, focused);
420 list.render(f, list_inner, theme, focused);
421 list.set_list_rect(list_inner);
426 list.set_panel_rect(rect);
427 }
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::settings::AppSettings;
435 use crate::test_support::{mouse_down_at, temp_vault};
436 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
437 use tokio::sync::mpsc::unbounded_channel;
438
439 async fn make_sidebar() -> SidebarComponent {
440 let vault = temp_vault("sidebar").await;
441 vault.validate_and_init().await.unwrap();
442 let settings = AppSettings::default();
443 SidebarComponent::new(
444 settings.key_bindings.clone(),
445 vault,
446 settings.icons(),
447 &settings,
448 )
449 }
450
451 async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
453 let vault = temp_vault(prefix).await;
454 vault.validate_and_init().await.unwrap();
455 for name in names {
456 vault
457 .create_note(&VaultPath::note_path_from(name), "body")
458 .await
459 .unwrap();
460 }
461 let settings = AppSettings::default();
462 SidebarComponent::new(
463 settings.key_bindings.clone(),
464 vault,
465 settings.icons(),
466 &settings,
467 )
468 }
469
470 #[tokio::test]
474 async fn mouse_down_in_sidebar_bounds_is_consumed() {
475 let mut sidebar = make_sidebar().await;
476 sidebar.rendered_rect = Rect {
477 x: 0,
478 y: 3,
479 width: 30,
480 height: 20,
481 };
482 let (tx, _rx) = unbounded_channel();
483
484 assert_eq!(
486 sidebar.handle_input(&mouse_down_at(5, 4), &tx),
487 EventState::Consumed
488 );
489 assert_eq!(
491 sidebar.handle_input(&mouse_down_at(5, 7), &tx),
492 EventState::Consumed
493 );
494 assert_eq!(
496 sidebar.handle_input(&mouse_down_at(40, 7), &tx),
497 EventState::NotConsumed
498 );
499 }
500
501 fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
502 InputEvent::Mouse(MouseEvent {
503 kind,
504 column: col,
505 row,
506 modifiers: KeyModifiers::NONE,
507 })
508 }
509
510 async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
513 sidebar.navigate(VaultPath::root(), tx);
514 for _ in 0..50 {
517 if let Some(list) = &mut sidebar.list {
518 list.poll();
519 if !list.is_loading() {
520 break;
521 }
522 }
523 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
524 }
525 if let Some(list) = &mut sidebar.list {
526 list.poll();
527 }
528 }
529
530 #[tokio::test(flavor = "multi_thread")]
533 async fn mouse_double_click_on_list_row_sends_open_path() {
534 let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
535 let (tx, mut rx) = unbounded_channel();
536 navigate_to_root(&mut sidebar, &tx).await;
537
538 sidebar.rendered_rect = Rect {
539 x: 0,
540 y: 3,
541 width: 30,
542 height: 20,
543 };
544 if let Some(list) = &mut sidebar.list {
547 list.set_list_rect(Rect {
548 x: 0,
549 y: 9,
550 width: 30,
551 height: 14,
552 });
553 }
554
555 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
557
558 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
560 let mut events = Vec::new();
561 while let Ok(evt) = rx.try_recv() {
562 events.push(evt);
563 }
564 assert!(
565 events
566 .iter()
567 .any(|e| matches!(e, AppEvent::OpenPath(p) if p.to_string().contains("alpha"))),
568 "expected OpenPath for the activated note, got {events:?}"
569 );
570 }
571
572 #[tokio::test(flavor = "multi_thread")]
577 async fn scroll_down_in_sidebar_bounds_scrolls_list() {
578 let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
579 let (tx, _rx) = unbounded_channel();
580 navigate_to_root(&mut sidebar, &tx).await;
581
582 sidebar.rendered_rect = Rect {
583 x: 0,
584 y: 3,
585 width: 30,
586 height: 20,
587 };
588 if let Some(list) = &mut sidebar.list {
592 list.set_list_rect(Rect {
593 x: 0,
594 y: 9,
595 width: 30,
596 height: 1,
597 });
598 list.set_panel_rect(Rect {
599 x: 0,
600 y: 3,
601 width: 30,
602 height: 20,
603 });
604 }
605
606 let first = sidebar
607 .list
608 .as_ref()
609 .unwrap()
610 .selected_row()
611 .map(|e| e.path().to_string());
612
613 let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
615 assert_eq!(result, EventState::Consumed);
616 let after = sidebar
617 .list
618 .as_ref()
619 .unwrap()
620 .selected_row()
621 .map(|e| e.path().to_string());
622 assert_ne!(
623 first, after,
624 "scroll-from-header should scroll the list, carrying the selection"
625 );
626 }
627
628 #[tokio::test]
629 async fn mouse_down_outside_sidebar_is_not_consumed() {
630 let mut sidebar = make_sidebar().await;
631 sidebar.rendered_rect = Rect {
632 x: 0,
633 y: 3,
634 width: 30,
635 height: 20,
636 };
637 let (tx, mut rx) = unbounded_channel();
638
639 let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
641 assert_eq!(result, EventState::NotConsumed);
642 assert!(rx.try_recv().is_err());
643 }
644
645 #[tokio::test(flavor = "multi_thread")]
647 async fn navigate_loads_directory_notes() {
648 let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
649 assert!(sidebar.is_empty());
650 let (tx, _rx) = unbounded_channel();
651 navigate_to_root(&mut sidebar, &tx).await;
652 assert!(!sidebar.is_empty());
653 assert_eq!(sidebar.note_count(), 1);
654 }
655
656 async fn poll_to_idle(sidebar: &mut SidebarComponent) {
659 for _ in 0..50 {
660 if let Some(list) = &mut sidebar.list {
661 list.poll();
662 if !list.is_loading() {
663 break;
664 }
665 }
666 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
667 }
668 if let Some(list) = &mut sidebar.list {
669 list.poll();
670 }
671 }
672
673 fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
675 sidebar
676 .list
677 .as_ref()
678 .unwrap()
679 .visible_rows()
680 .iter()
681 .filter_map(|e| match e {
682 FileListEntry::Note { filename, .. } => Some(filename.clone()),
683 _ => None,
684 })
685 .collect()
686 }
687
688 #[tokio::test(flavor = "multi_thread")]
689 async fn apply_sort_reverse_flips_listing_order() {
690 let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
691 let (tx, _rx) = unbounded_channel();
692 navigate_to_root(&mut sidebar, &tx).await;
693 let before = note_names(&sidebar);
694 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
695 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
696 poll_to_idle(&mut sidebar).await;
697 let after = note_names(&sidebar);
698 assert_eq!(
699 after,
700 before.iter().rev().cloned().collect::<Vec<_>>(),
701 "descending order should reverse the listing"
702 );
703 }
704
705 #[tokio::test(flavor = "multi_thread")]
706 async fn apply_sort_changes_field() {
707 let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
708 let (tx, _rx) = unbounded_channel();
709 navigate_to_root(&mut sidebar, &tx).await;
710 sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
711 poll_to_idle(&mut sidebar).await;
712 assert_eq!(sidebar.current_sort().0, SortField::Title);
713 assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
714 }
715
716 async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
718 let vault = temp_vault(prefix).await;
719 vault.validate_and_init().await.unwrap();
720 vault
721 .create_note(&VaultPath::note_path_from("alpha"), "body")
722 .await
723 .unwrap();
724 vault
725 .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
726 .await
727 .unwrap();
728 let settings = AppSettings::default();
729 SidebarComponent::new(
730 settings.key_bindings.clone(),
731 vault,
732 settings.icons(),
733 &settings,
734 )
735 }
736
737 fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
739 sidebar
740 .list
741 .as_ref()
742 .unwrap()
743 .visible_rows()
744 .iter()
745 .filter_map(|e| match e {
746 FileListEntry::Note { .. } => Some("note"),
747 FileListEntry::Directory { .. } => Some("dir"),
748 _ => None,
749 })
750 .collect()
751 }
752
753 #[tokio::test(flavor = "multi_thread")]
754 async fn group_dirs_puts_directories_first() {
755 let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
756 let (tx, _rx) = unbounded_channel();
757 navigate_to_root(&mut sidebar, &tx).await;
758 assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
759 sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
760 poll_to_idle(&mut sidebar).await;
761 assert_eq!(
762 row_kinds(&sidebar),
763 vec!["dir", "note"],
764 "grouping must cluster directories first"
765 );
766 }
767
768 #[tokio::test(flavor = "multi_thread")]
769 async fn apply_sort_updates_shared_state() {
770 let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
771 let (tx, _rx) = unbounded_channel();
772 navigate_to_root(&mut sidebar, &tx).await;
773 sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
774 poll_to_idle(&mut sidebar).await;
775 assert_eq!(
776 sidebar.current_sort(),
777 (SortField::Title, SortOrder::Descending)
778 );
779 assert!(!sidebar.group_dirs());
780 }
781
782 #[tokio::test(flavor = "multi_thread")]
787 async fn save_default_survives_navigation() {
788 let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
789 let (tx, _rx) = unbounded_channel();
790 navigate_to_root(&mut sidebar, &tx).await;
791
792 sidebar.save_default(SortField::Title, SortOrder::Descending, false);
793 poll_to_idle(&mut sidebar).await;
794
795 sidebar.navigate(VaultPath::root(), &tx);
798 poll_to_idle(&mut sidebar).await;
799 assert_eq!(
800 sidebar.current_sort(),
801 (SortField::Title, SortOrder::Descending),
802 "saved default must persist across navigation"
803 );
804 }
805}