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