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::text::{Line, Span};
12use ratatui::widgets::{Block, Borders, Paragraph};
13
14use crate::components::Component;
15use crate::components::event_state::EventState;
16use crate::components::events::{AppEvent, AppTx, AppTxExt, FileOp, 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.is_like(&dir) {
73 continue;
74 }
75 let journal_date = vault.journal_date(&result.path).map(format_journal_date);
76 entries.push(FileListEntry::from_result(result, journal_date));
77 }
78 let cmp = |a: &FileListEntry, b: &FileListEntry| {
79 let ka = a.sort_key(field);
80 let kb = b.sort_key(field);
81 match order {
82 SortOrder::Ascending => ka.cmp(&kb),
83 SortOrder::Descending => kb.cmp(&ka),
84 }
85 };
86 if group_dirs {
87 let (mut dirs, mut rest): (Vec<_>, Vec<_>) = entries
88 .into_iter()
89 .partition(|e| matches!(e, FileListEntry::Directory { .. }));
90 dirs.sort_by(&cmp);
91 rest.sort_by(&cmp);
92 dirs.extend(rest);
93 dirs
94 } else {
95 entries.sort_by(&cmp);
96 entries
97 }
98 });
99
100 match drain.await {
101 Ok(entries) => {
102 for entry in entries {
103 emit.push(entry);
104 }
105 }
106 Err(e) => tracing::warn!("sidebar directory listing drain failed: {e}"),
107 }
108 if let Err(e) = browse.await {
109 tracing::warn!("sidebar browse_vault task failed: {e}");
110 }
111 emit.done();
112 }
113
114 fn leading_row(&self, query: &str) -> Option<FileListEntry> {
115 if query.is_empty() {
116 None
117 } else {
118 let path = self.dir.append(&VaultPath::note_path_from(query)).flatten();
119 Some(FileListEntry::CreateNote {
120 filename: path.to_string(),
121 path,
122 })
123 }
124 }
125
126 fn reload_on_query(&self) -> bool {
127 false
130 }
131}
132
133pub struct SidebarComponent {
134 current_dir: VaultPath,
135 open_note: Option<VaultPath>,
139 list: Option<SearchList<FileListEntry>>,
140 vault: Arc<NoteVault>,
141 icons: Icons,
142 default_sort_field: SortField,
143 default_sort_order: SortOrder,
144 journal_sort_field: SortField,
145 journal_sort_order: SortOrder,
146 sort: Arc<Mutex<(SortField, SortOrder)>>,
150 group_dirs: Arc<Mutex<bool>>,
153 rendered_rect: Rect,
154 breadcrumb_cells: Vec<(Rect, VaultPath)>,
157 key_bindings: KeyBindings,
158}
159
160impl SidebarComponent {
161 pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
165 Self::new(
166 settings.key_bindings.clone(),
167 vault,
168 settings.icons(),
169 settings,
170 )
171 }
172
173 pub fn new(
174 key_bindings: KeyBindings,
175 vault: Arc<NoteVault>,
176 icons: Icons,
177 settings: &AppSettings,
178 ) -> Self {
179 let default_sort_field = SortField::from(settings.default_sort_field);
180 let default_sort_order = SortOrder::from(settings.default_sort_order);
181 Self {
182 current_dir: VaultPath::root(),
183 open_note: None,
184 list: None,
185 vault,
186 icons,
187 default_sort_field,
188 default_sort_order,
189 journal_sort_field: SortField::from(settings.journal_sort_field),
190 journal_sort_order: SortOrder::from(settings.journal_sort_order),
191 sort: Arc::new(Mutex::new((default_sort_field, default_sort_order))),
192 group_dirs: Arc::new(Mutex::new(settings.group_directories)),
193 rendered_rect: Rect::default(),
194 breadcrumb_cells: Vec::new(),
195 key_bindings,
196 }
197 }
198
199 fn breadcrumb_at(&self, column: u16, row: u16) -> Option<&VaultPath> {
201 self.breadcrumb_cells
202 .iter()
203 .find(|(rect, _)| rect.contains(Position { x: column, y: row }))
204 .map(|(_, dir)| dir)
205 }
206
207 pub fn current_dir(&self) -> &VaultPath {
208 &self.current_dir
209 }
210
211 pub fn is_empty(&self) -> bool {
214 self.list.is_none()
215 }
216
217 fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
219 if dir.is_like(self.vault.journal_path()) {
220 (self.journal_sort_field, self.journal_sort_order)
221 } else {
222 (self.default_sort_field, self.default_sort_order)
223 }
224 }
225
226 pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
230 self.current_dir = dir.clone();
231 let (sort_field, sort_order) = self.sort_for(&dir);
232 self.sort = Arc::new(Mutex::new((sort_field, sort_order)));
233 let source = DirListingSource {
234 vault: self.vault.clone(),
235 dir,
236 sort: self.sort.clone(),
237 group_dirs: self.group_dirs.clone(),
238 };
239 self.list = Some(
240 SearchList::builder(source, redraw_callback(tx.clone()))
241 .filter(Filter::Fuzzy)
242 .icons(self.icons.clone())
243 .build(),
244 );
245 }
246
247 pub fn refresh_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
252 if dir.is_like(&self.current_dir) {
253 self.navigate(self.current_dir.clone(), tx);
254 }
255 }
256
257 pub fn set_open_note(&mut self, path: Option<VaultPath>) {
261 self.open_note = path;
262 self.stamp_open_marker();
263 }
264
265 fn stamp_open_marker(&mut self) {
269 let open = self.open_note.clone();
270 if let Some(list) = &mut self.list {
271 list.update_rows(|row| {
272 if let FileListEntry::Note { path, is_open, .. } = row {
273 let want = open.as_ref().is_some_and(|o| path.is_like(o));
274 if *is_open != want {
275 *is_open = want;
276 return true;
277 }
278 }
279 false
280 });
281 }
282 }
283
284 pub fn update_note_row(&mut self, path: &VaultPath, new_title: &str) {
288 if let Some(list) = &mut self.list {
289 list.update_rows(|row| {
290 if let FileListEntry::Note {
291 path: row_path,
292 title,
293 ..
294 } = row
295 && row_path.is_like(path)
296 && title != new_title
297 {
298 *title = new_title.to_string();
299 return true;
300 }
301 false
302 });
303 }
304 }
305
306 pub fn rename_note_row(&mut self, from: &VaultPath, to: &VaultPath) {
312 let new_filename = to.get_parent_path().1;
313 let new_journal_date = self.vault.journal_date(to).map(format_journal_date);
314 if let Some(list) = &mut self.list {
315 list.update_rows(|row| {
316 if let FileListEntry::Note {
317 path,
318 filename,
319 journal_date,
320 ..
321 } = row
322 && path.is_like(from)
323 {
324 *path = to.clone();
325 *filename = new_filename.clone();
326 *journal_date = new_journal_date.clone();
327 return true;
328 }
329 false
330 });
331 }
332 }
333
334 pub fn set_current_dir(&mut self, dir: VaultPath) {
338 self.current_dir = dir;
339 }
340
341 pub fn current_sort(&self) -> (SortField, SortOrder) {
343 *self.sort.lock().unwrap()
344 }
345
346 pub fn group_dirs(&self) -> bool {
348 *self.group_dirs.lock().unwrap()
349 }
350
351 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
354 *self.sort.lock().unwrap() = (field, order);
355 *self.group_dirs.lock().unwrap() = group_dirs;
356 if let Some(list) = &mut self.list {
357 list.reload();
358 }
359 }
360
361 pub fn is_current_journal(&self) -> bool {
364 self.current_dir.is_like(self.vault.journal_path())
365 }
366
367 pub fn save_default(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
373 if self.is_current_journal() {
374 self.journal_sort_field = field;
375 self.journal_sort_order = order;
376 } else {
377 self.default_sort_field = field;
378 self.default_sort_order = order;
379 }
380 self.apply_sort(field, order, group_dirs);
381 }
382
383 fn note_count(&self) -> usize {
385 match &self.list {
386 None => 0,
387 Some(list) => list
388 .visible_rows()
389 .iter()
390 .filter(|e| matches!(e, FileListEntry::Note { .. }))
391 .count(),
392 }
393 }
394
395 fn activate_selected_entry(&self, tx: &AppTx) {
399 let Some(list) = &self.list else { return };
400 let Some(entry) = list.selected_row() else {
401 return;
402 };
403 match entry {
404 FileListEntry::CreateNote { path, .. } => {
405 let path = path.clone();
406 let vault = Arc::clone(&self.vault);
407 let tx2 = tx.clone();
408 tokio::spawn(async move {
409 match vault.load_or_create_note(&path, None).await {
410 Ok((_, created)) => tx2.announce_and_open(path, created),
411 Err(e) => {
412 tracing::warn!("create note failed for {path}: {e}");
413 }
414 }
415 });
416 }
417 FileListEntry::Attachment { path, .. } => {
418 tx.send(AppEvent::OpenAttachment(path.clone())).ok();
419 }
420 other => {
421 tx.send(AppEvent::open(other.path().clone())).ok();
422 }
423 }
424 }
425}
426
427fn format_journal_date(date: NaiveDate) -> String {
430 date.format("%A, %B %-d, %Y").to_string()
431}
432
433impl Component for SidebarComponent {
434 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
435 if let InputEvent::Mouse(mouse) = event {
436 let pos = Position {
437 x: mouse.column,
438 y: mouse.row,
439 };
440 if !self.rendered_rect.contains(pos) {
441 return EventState::NotConsumed;
442 }
443 if matches!(
445 mouse.kind,
446 ratatui::crossterm::event::MouseEventKind::Down(
447 ratatui::crossterm::event::MouseButton::Left
448 )
449 ) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
450 {
451 tx.send(AppEvent::open(dir.clone())).ok();
452 return EventState::Consumed;
453 }
454 if let Some(list) = &mut self.list {
460 match list.handle_mouse(mouse) {
461 SearchMouse::Activated(_) => self.activate_selected_entry(tx),
462 SearchMouse::Context(_) => {
464 if let Some(entry) = list.selected_row()
465 && !matches!(
466 entry,
467 FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
468 )
469 {
470 tx.send(AppEvent::FileOp(FileOp::ShowMenu(entry.path().clone())))
471 .ok();
472 }
473 }
474 SearchMouse::Selected(_)
477 | SearchMouse::Scrolled
478 | SearchMouse::ContentScrollUp
479 | SearchMouse::ContentScrollDown
480 | SearchMouse::None => {}
481 }
482 }
483 return EventState::Consumed;
484 }
485
486 if let InputEvent::Key(key) = event {
487 if self.list.is_none() {
488 return EventState::NotConsumed;
489 }
490 let reaction = self.list.as_mut().unwrap().handle_key(key);
491 match reaction {
492 KeyReaction::Submit => {
493 self.activate_selected_entry(tx);
494 EventState::Consumed
495 }
496 KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
497 KeyReaction::Intercepted(_) | KeyReaction::ListVerb(_) | KeyReaction::Unhandled => {
498 EventState::NotConsumed
499 }
500 }
501 } else {
502 EventState::NotConsumed
503 }
504 }
505
506 fn hint_shortcuts(&self) -> Vec<(String, String)> {
507 use crate::keys::action_shortcuts::ActionShortcuts;
508
509 crate::components::hints::hints_for(
510 &self.key_bindings,
511 &[
512 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
513 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
514 (ActionShortcuts::OpenSortDialog, "sort"),
515 ],
516 )
517 }
518
519 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
520 self.rendered_rect = rect;
521
522 let rows = Layout::default()
523 .direction(Direction::Vertical)
524 .constraints([
525 Constraint::Length(3),
526 Constraint::Length(3),
527 Constraint::Min(0),
528 ])
529 .split(rect);
530
531 let border_style = theme.border_style(focused);
532
533 let header = Block::default()
534 .title(format!("─ Files · {} ", self.current_dir))
535 .borders(Borders::ALL)
536 .border_style(border_style)
537 .style(theme.panel_style());
538 let header_inner = header.inner(rows[0]);
539 f.render_widget(header, rows[0]);
540
541 self.breadcrumb_cells.clear();
545 let seg_style = Style::default()
546 .fg(theme.fg_secondary.to_ratatui())
547 .bg(theme.bg_panel.to_ratatui());
548 let sep_style = Style::default()
549 .fg(theme.gray.to_ratatui())
550 .bg(theme.bg_panel.to_ratatui());
551 let mut spans: Vec<Span> = Vec::new();
552 let mut x = header_inner.x;
553 let mut push_segment =
554 |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
555 let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
556 if *x < header_inner.right() {
560 let visible = w.min(header_inner.right() - *x);
561 self.breadcrumb_cells
562 .push((Rect::new(*x, header_inner.y, visible, 1), dir));
563 }
564 spans.push(Span::styled(label, seg_style));
565 *x += w;
566 };
567 push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
568 let slices = self.current_dir.get_slices();
569 let mut acc = String::new();
570 for slice in &slices {
571 spans.push(Span::styled(" / ", sep_style));
572 x += 3;
573 acc.push('/');
574 acc.push_str(slice);
575 push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
576 }
577 let count = format!("{} notes", self.note_count());
578 let used: u16 = x - header_inner.x;
579 let pad = header_inner
580 .width
581 .saturating_sub(used)
582 .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
583 spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
584 spans.push(Span::styled(count, sep_style));
585 f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
586
587 let search_block = Block::default()
588 .title(" Search")
589 .borders(Borders::ALL)
590 .border_style(border_style)
591 .style(theme.panel_style());
592 let search_inner = search_block.inner(rows[1]);
593 f.render_widget(search_block, rows[1]);
594
595 let list_block = Block::default()
596 .borders(Borders::ALL)
597 .border_style(border_style)
598 .style(theme.panel_style());
599 let list_inner = list_block.inner(rows[2]);
600 f.render_widget(list_block, rows[2]);
601
602 if let Some(list) = &mut self.list {
606 list.poll();
607 }
608 self.stamp_open_marker();
609 if let Some(list) = &mut self.list {
610 list.render_query(f, search_inner, theme, focused);
611 list.render(f, list_inner, theme, focused);
612 list.set_list_rect(list_inner);
617 list.set_panel_rect(rect);
618 }
619 }
620}
621
622#[cfg(test)]
623impl SidebarComponent {
624 pub(crate) fn poll_for_test(&mut self) {
625 if let Some(list) = &mut self.list {
626 list.poll();
627 }
628 self.stamp_open_marker();
629 }
630
631 pub(crate) fn is_loading_for_test(&self) -> bool {
632 self.list.as_ref().is_some_and(|l| l.is_loading())
633 }
634
635 pub(crate) fn note_row_is_open_for_test(&self, name: &str) -> bool {
636 self.list.as_ref().is_some_and(|l| {
637 l.rows().iter().any(|r| {
638 matches!(r, FileListEntry::Note { path, is_open, .. }
639 if path.get_name() == name && *is_open)
640 })
641 })
642 }
643
644 pub(crate) fn note_row_title_for_test(&self, name: &str) -> Option<String> {
645 self.list.as_ref().and_then(|l| {
646 l.rows().iter().find_map(|r| match r {
647 FileListEntry::Note { path, title, .. } if path.get_name() == name => {
648 Some(title.clone())
649 }
650 _ => None,
651 })
652 })
653 }
654
655 pub(crate) fn note_row_journal_date_for_test(&self, path: &VaultPath) -> Option<String> {
656 self.list.as_ref().and_then(|l| {
657 l.rows().iter().find_map(|r| match r {
658 FileListEntry::Note {
659 path: row_path,
660 journal_date,
661 ..
662 } if row_path.is_like(path) => journal_date.clone(),
663 _ => None,
664 })
665 })
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::settings::AppSettings;
673 use crate::test_support::{mouse_down_at, temp_vault};
674 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
675 use tokio::sync::mpsc::unbounded_channel;
676
677 async fn make_sidebar() -> SidebarComponent {
678 let vault = temp_vault("sidebar").await;
679 vault.validate_and_init().await.unwrap();
680 let settings = AppSettings::default();
681 SidebarComponent::new(
682 settings.key_bindings.clone(),
683 vault,
684 settings.icons(),
685 &settings,
686 )
687 }
688
689 async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
691 let vault = temp_vault(prefix).await;
692 vault.validate_and_init().await.unwrap();
693 for name in names {
694 vault
695 .create_note(&VaultPath::note_path_from(name), "body")
696 .await
697 .unwrap();
698 }
699 let settings = AppSettings::default();
700 SidebarComponent::new(
701 settings.key_bindings.clone(),
702 vault,
703 settings.icons(),
704 &settings,
705 )
706 }
707
708 #[tokio::test]
712 async fn mouse_down_in_sidebar_bounds_is_consumed() {
713 let mut sidebar = make_sidebar().await;
714 sidebar.rendered_rect = Rect {
715 x: 0,
716 y: 3,
717 width: 30,
718 height: 20,
719 };
720 let (tx, _rx) = unbounded_channel();
721
722 assert_eq!(
724 sidebar.handle_input(&mouse_down_at(5, 4), &tx),
725 EventState::Consumed
726 );
727 assert_eq!(
729 sidebar.handle_input(&mouse_down_at(5, 7), &tx),
730 EventState::Consumed
731 );
732 assert_eq!(
734 sidebar.handle_input(&mouse_down_at(40, 7), &tx),
735 EventState::NotConsumed
736 );
737 }
738
739 fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
740 InputEvent::Mouse(MouseEvent {
741 kind,
742 column: col,
743 row,
744 modifiers: KeyModifiers::NONE,
745 })
746 }
747
748 async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
751 sidebar.navigate(VaultPath::root(), tx);
752 for _ in 0..50 {
755 if let Some(list) = &mut sidebar.list {
756 list.poll();
757 if !list.is_loading() {
758 break;
759 }
760 }
761 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
762 }
763 if let Some(list) = &mut sidebar.list {
764 list.poll();
765 }
766 }
767
768 #[tokio::test(flavor = "multi_thread")]
771 async fn mouse_double_click_on_list_row_sends_open_path() {
772 let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
773 let (tx, mut rx) = unbounded_channel();
774 navigate_to_root(&mut sidebar, &tx).await;
775
776 sidebar.rendered_rect = Rect {
777 x: 0,
778 y: 3,
779 width: 30,
780 height: 20,
781 };
782 if let Some(list) = &mut sidebar.list {
785 list.set_list_rect(Rect {
786 x: 0,
787 y: 9,
788 width: 30,
789 height: 14,
790 });
791 }
792
793 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
795
796 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
798 let mut events = Vec::new();
799 while let Ok(evt) = rx.try_recv() {
800 events.push(evt);
801 }
802 assert!(
803 events
804 .iter()
805 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
806 "expected OpenPath for the activated note, got {events:?}"
807 );
808 }
809
810 #[tokio::test(flavor = "multi_thread")]
815 async fn scroll_down_in_sidebar_bounds_scrolls_list() {
816 let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
817 let (tx, _rx) = unbounded_channel();
818 navigate_to_root(&mut sidebar, &tx).await;
819
820 sidebar.rendered_rect = Rect {
821 x: 0,
822 y: 3,
823 width: 30,
824 height: 20,
825 };
826 if let Some(list) = &mut sidebar.list {
830 list.set_list_rect(Rect {
831 x: 0,
832 y: 9,
833 width: 30,
834 height: 1,
835 });
836 list.set_panel_rect(Rect {
837 x: 0,
838 y: 3,
839 width: 30,
840 height: 20,
841 });
842 }
843
844 let first = sidebar
845 .list
846 .as_ref()
847 .unwrap()
848 .selected_row()
849 .map(|e| e.path().to_string());
850
851 let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
853 assert_eq!(result, EventState::Consumed);
854 let after = sidebar
855 .list
856 .as_ref()
857 .unwrap()
858 .selected_row()
859 .map(|e| e.path().to_string());
860 assert_ne!(
861 first, after,
862 "scroll-from-header should scroll the list, carrying the selection"
863 );
864 }
865
866 #[tokio::test]
867 async fn mouse_down_outside_sidebar_is_not_consumed() {
868 let mut sidebar = make_sidebar().await;
869 sidebar.rendered_rect = Rect {
870 x: 0,
871 y: 3,
872 width: 30,
873 height: 20,
874 };
875 let (tx, mut rx) = unbounded_channel();
876
877 let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
879 assert_eq!(result, EventState::NotConsumed);
880 assert!(rx.try_recv().is_err());
881 }
882
883 #[tokio::test(flavor = "multi_thread")]
885 async fn navigate_loads_directory_notes() {
886 let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
887 assert!(sidebar.is_empty());
888 let (tx, _rx) = unbounded_channel();
889 navigate_to_root(&mut sidebar, &tx).await;
890 assert!(!sidebar.is_empty());
891 assert_eq!(sidebar.note_count(), 1);
892 }
893
894 async fn poll_to_idle(sidebar: &mut SidebarComponent) {
897 for _ in 0..50 {
898 if let Some(list) = &mut sidebar.list {
899 list.poll();
900 if !list.is_loading() {
901 break;
902 }
903 }
904 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
905 }
906 if let Some(list) = &mut sidebar.list {
907 list.poll();
908 }
909 }
910
911 fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
913 sidebar
914 .list
915 .as_ref()
916 .unwrap()
917 .visible_rows()
918 .iter()
919 .filter_map(|e| match e {
920 FileListEntry::Note { filename, .. } => Some(filename.clone()),
921 _ => None,
922 })
923 .collect()
924 }
925
926 #[tokio::test(flavor = "multi_thread")]
927 async fn apply_sort_reverse_flips_listing_order() {
928 let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
929 let (tx, _rx) = unbounded_channel();
930 navigate_to_root(&mut sidebar, &tx).await;
931 let before = note_names(&sidebar);
932 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
933 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
934 poll_to_idle(&mut sidebar).await;
935 let after = note_names(&sidebar);
936 assert_eq!(
937 after,
938 before.iter().rev().cloned().collect::<Vec<_>>(),
939 "descending order should reverse the listing"
940 );
941 }
942
943 #[tokio::test(flavor = "multi_thread")]
944 async fn apply_sort_changes_field() {
945 let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
946 let (tx, _rx) = unbounded_channel();
947 navigate_to_root(&mut sidebar, &tx).await;
948 sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
949 poll_to_idle(&mut sidebar).await;
950 assert_eq!(sidebar.current_sort().0, SortField::Title);
951 assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
952 }
953
954 async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
956 let vault = temp_vault(prefix).await;
957 vault.validate_and_init().await.unwrap();
958 vault
959 .create_note(&VaultPath::note_path_from("alpha"), "body")
960 .await
961 .unwrap();
962 vault
963 .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
964 .await
965 .unwrap();
966 let settings = AppSettings::default();
967 SidebarComponent::new(
968 settings.key_bindings.clone(),
969 vault,
970 settings.icons(),
971 &settings,
972 )
973 }
974
975 fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
977 sidebar
978 .list
979 .as_ref()
980 .unwrap()
981 .visible_rows()
982 .iter()
983 .filter_map(|e| match e {
984 FileListEntry::Note { .. } => Some("note"),
985 FileListEntry::Directory { .. } => Some("dir"),
986 _ => None,
987 })
988 .collect()
989 }
990
991 #[tokio::test(flavor = "multi_thread")]
992 async fn group_dirs_puts_directories_first() {
993 let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
994 let (tx, _rx) = unbounded_channel();
995 navigate_to_root(&mut sidebar, &tx).await;
996 assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
997 sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
998 poll_to_idle(&mut sidebar).await;
999 assert_eq!(
1000 row_kinds(&sidebar),
1001 vec!["dir", "note"],
1002 "grouping must cluster directories first"
1003 );
1004 }
1005
1006 #[tokio::test(flavor = "multi_thread")]
1007 async fn apply_sort_updates_shared_state() {
1008 let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
1009 let (tx, _rx) = unbounded_channel();
1010 navigate_to_root(&mut sidebar, &tx).await;
1011 sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1012 poll_to_idle(&mut sidebar).await;
1013 assert_eq!(
1014 sidebar.current_sort(),
1015 (SortField::Title, SortOrder::Descending)
1016 );
1017 assert!(!sidebar.group_dirs());
1018 }
1019
1020 #[tokio::test(flavor = "multi_thread")]
1021 async fn set_open_note_stamps_matching_row() {
1022 let mut sb = sidebar_with_notes("sb-open", &["alpha", "beta"]).await;
1023 let (tx, _rx) = unbounded_channel();
1024 navigate_to_root(&mut sb, &tx).await;
1025
1026 sb.set_open_note(Some(VaultPath::note_path_from("alpha")));
1027 assert!(
1028 sb.note_row_is_open_for_test("alpha.md"),
1029 "open note is marked"
1030 );
1031 assert!(
1032 !sb.note_row_is_open_for_test("beta.md"),
1033 "other note is not marked"
1034 );
1035
1036 sb.set_open_note(Some(VaultPath::note_path_from("beta")));
1037 assert!(!sb.note_row_is_open_for_test("alpha.md"));
1038 assert!(sb.note_row_is_open_for_test("beta.md"));
1039
1040 sb.set_open_note(None);
1041 assert!(!sb.note_row_is_open_for_test("beta.md"));
1042 }
1043
1044 #[tokio::test(flavor = "multi_thread")]
1045 async fn update_note_row_changes_title_in_place() {
1046 let mut sb = sidebar_with_notes("sb-title", &["alpha"]).await;
1047 let (tx, _rx) = unbounded_channel();
1048 navigate_to_root(&mut sb, &tx).await;
1049
1050 sb.update_note_row(&VaultPath::note_path_from("alpha"), "Fresh Title");
1051 assert_eq!(
1052 sb.note_row_title_for_test("alpha.md").as_deref(),
1053 Some("Fresh Title")
1054 );
1055 }
1056
1057 #[tokio::test(flavor = "multi_thread")]
1058 async fn rename_note_row_updates_path_and_filename() {
1059 let mut sb = sidebar_with_notes("sb-rename", &["alpha"]).await;
1060 let (tx, _rx) = unbounded_channel();
1061 navigate_to_root(&mut sb, &tx).await;
1062
1063 let to = VaultPath::note_path_from("gamma");
1064 let expected_filename = to.get_parent_path().1;
1065 sb.rename_note_row(&VaultPath::note_path_from("alpha"), &to);
1066 assert!(
1067 sb.note_row_title_for_test("gamma.md").is_some(),
1068 "row now at new name"
1069 );
1070 assert!(
1071 sb.note_row_title_for_test("alpha.md").is_none(),
1072 "old name gone"
1073 );
1074 let renamed_filename = sb
1076 .list
1077 .as_ref()
1078 .unwrap()
1079 .rows()
1080 .iter()
1081 .find_map(|r| match r {
1082 FileListEntry::Note { path, filename, .. } if path.is_like(&to) => {
1083 Some(filename.clone())
1084 }
1085 _ => None,
1086 });
1087 assert_eq!(
1088 renamed_filename.as_deref(),
1089 Some(expected_filename.as_str()),
1090 "filename field must be updated to the new name"
1091 );
1092 }
1093
1094 #[tokio::test(flavor = "multi_thread")]
1097 async fn rename_note_row_clears_journal_date_when_renamed_away_from_date_name() {
1098 let vault = crate::test_support::temp_vault("sb-jdate").await;
1101 vault.validate_and_init().await.unwrap();
1102 let journal_path = vault.journal_path().clone();
1103 let date_name = "2026-06-09";
1104 let from = journal_path
1105 .append(&VaultPath::note_path_from(date_name))
1106 .absolute();
1107 vault.create_note(&from, "journal body").await.unwrap();
1108
1109 let settings = AppSettings::default();
1110 let mut sb = SidebarComponent::new(
1111 settings.key_bindings.clone(),
1112 vault,
1113 settings.icons(),
1114 &settings,
1115 );
1116 let (tx, _rx) = unbounded_channel();
1117 sb.navigate(journal_path.clone(), &tx);
1119 for _ in 0..50 {
1120 sb.poll_for_test();
1121 if !sb.is_loading_for_test() {
1122 break;
1123 }
1124 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1125 }
1126 sb.poll_for_test();
1127
1128 assert!(
1130 sb.note_row_journal_date_for_test(&from).is_some(),
1131 "journal note must have a journal_date before rename"
1132 );
1133
1134 let to = journal_path
1136 .append(&VaultPath::note_path_from("meeting"))
1137 .absolute();
1138 sb.rename_note_row(&from, &to);
1139
1140 assert_eq!(
1142 sb.note_row_journal_date_for_test(&to),
1143 None,
1144 "journal_date must be cleared after renaming to a non-date name"
1145 );
1146 }
1147
1148 #[tokio::test(flavor = "multi_thread")]
1153 async fn save_default_survives_navigation() {
1154 let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
1155 let (tx, _rx) = unbounded_channel();
1156 navigate_to_root(&mut sidebar, &tx).await;
1157
1158 sidebar.save_default(SortField::Title, SortOrder::Descending, false);
1159 poll_to_idle(&mut sidebar).await;
1160
1161 sidebar.navigate(VaultPath::root(), &tx);
1164 poll_to_idle(&mut sidebar).await;
1165 assert_eq!(
1166 sidebar.current_sort(),
1167 (SortField::Title, SortOrder::Descending),
1168 "saved default must persist across navigation"
1169 );
1170 }
1171}