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::Unhandled => EventState::NotConsumed,
498 }
499 } else {
500 EventState::NotConsumed
501 }
502 }
503
504 fn hint_shortcuts(&self) -> Vec<(String, String)> {
505 use crate::keys::action_shortcuts::ActionShortcuts;
506
507 crate::components::hints::hints_for(
508 &self.key_bindings,
509 &[
510 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
511 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
512 (ActionShortcuts::OpenSortDialog, "sort"),
513 ],
514 )
515 }
516
517 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
518 self.rendered_rect = rect;
519
520 let rows = Layout::default()
521 .direction(Direction::Vertical)
522 .constraints([
523 Constraint::Length(3),
524 Constraint::Length(3),
525 Constraint::Min(0),
526 ])
527 .split(rect);
528
529 let border_style = theme.border_style(focused);
530
531 let header = Block::default()
532 .title(format!("─ Files · {} ", self.current_dir))
533 .borders(Borders::ALL)
534 .border_style(border_style)
535 .style(theme.panel_style());
536 let header_inner = header.inner(rows[0]);
537 f.render_widget(header, rows[0]);
538
539 self.breadcrumb_cells.clear();
543 let seg_style = Style::default()
544 .fg(theme.fg_secondary.to_ratatui())
545 .bg(theme.bg_panel.to_ratatui());
546 let sep_style = Style::default()
547 .fg(theme.gray.to_ratatui())
548 .bg(theme.bg_panel.to_ratatui());
549 let mut spans: Vec<Span> = Vec::new();
550 let mut x = header_inner.x;
551 let mut push_segment =
552 |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
553 let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
554 if *x < header_inner.right() {
558 let visible = w.min(header_inner.right() - *x);
559 self.breadcrumb_cells
560 .push((Rect::new(*x, header_inner.y, visible, 1), dir));
561 }
562 spans.push(Span::styled(label, seg_style));
563 *x += w;
564 };
565 push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
566 let slices = self.current_dir.get_slices();
567 let mut acc = String::new();
568 for slice in &slices {
569 spans.push(Span::styled(" / ", sep_style));
570 x += 3;
571 acc.push('/');
572 acc.push_str(slice);
573 push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
574 }
575 let count = format!("{} notes", self.note_count());
576 let used: u16 = x - header_inner.x;
577 let pad = header_inner
578 .width
579 .saturating_sub(used)
580 .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
581 spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
582 spans.push(Span::styled(count, sep_style));
583 f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
584
585 let search_block = Block::default()
586 .title(" Search")
587 .borders(Borders::ALL)
588 .border_style(border_style)
589 .style(theme.panel_style());
590 let search_inner = search_block.inner(rows[1]);
591 f.render_widget(search_block, rows[1]);
592
593 let list_block = Block::default()
594 .borders(Borders::ALL)
595 .border_style(border_style)
596 .style(theme.panel_style());
597 let list_inner = list_block.inner(rows[2]);
598 f.render_widget(list_block, rows[2]);
599
600 if let Some(list) = &mut self.list {
604 list.poll();
605 }
606 self.stamp_open_marker();
607 if let Some(list) = &mut self.list {
608 list.render_query(f, search_inner, theme, focused);
609 list.render(f, list_inner, theme, focused);
610 list.set_list_rect(list_inner);
615 list.set_panel_rect(rect);
616 }
617 }
618}
619
620#[cfg(test)]
621impl SidebarComponent {
622 pub(crate) fn poll_for_test(&mut self) {
623 if let Some(list) = &mut self.list {
624 list.poll();
625 }
626 self.stamp_open_marker();
627 }
628
629 pub(crate) fn is_loading_for_test(&self) -> bool {
630 self.list.as_ref().is_some_and(|l| l.is_loading())
631 }
632
633 pub(crate) fn note_row_is_open_for_test(&self, name: &str) -> bool {
634 self.list.as_ref().is_some_and(|l| {
635 l.rows().iter().any(|r| {
636 matches!(r, FileListEntry::Note { path, is_open, .. }
637 if path.get_name() == name && *is_open)
638 })
639 })
640 }
641
642 pub(crate) fn note_row_title_for_test(&self, name: &str) -> Option<String> {
643 self.list.as_ref().and_then(|l| {
644 l.rows().iter().find_map(|r| match r {
645 FileListEntry::Note { path, title, .. } if path.get_name() == name => {
646 Some(title.clone())
647 }
648 _ => None,
649 })
650 })
651 }
652
653 pub(crate) fn note_row_journal_date_for_test(&self, path: &VaultPath) -> Option<String> {
654 self.list.as_ref().and_then(|l| {
655 l.rows().iter().find_map(|r| match r {
656 FileListEntry::Note {
657 path: row_path,
658 journal_date,
659 ..
660 } if row_path.is_like(path) => journal_date.clone(),
661 _ => None,
662 })
663 })
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use crate::settings::AppSettings;
671 use crate::test_support::{mouse_down_at, temp_vault};
672 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
673 use tokio::sync::mpsc::unbounded_channel;
674
675 async fn make_sidebar() -> SidebarComponent {
676 let vault = temp_vault("sidebar").await;
677 vault.validate_and_init().await.unwrap();
678 let settings = AppSettings::default();
679 SidebarComponent::new(
680 settings.key_bindings.clone(),
681 vault,
682 settings.icons(),
683 &settings,
684 )
685 }
686
687 async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
689 let vault = temp_vault(prefix).await;
690 vault.validate_and_init().await.unwrap();
691 for name in names {
692 vault
693 .create_note(&VaultPath::note_path_from(name), "body")
694 .await
695 .unwrap();
696 }
697 let settings = AppSettings::default();
698 SidebarComponent::new(
699 settings.key_bindings.clone(),
700 vault,
701 settings.icons(),
702 &settings,
703 )
704 }
705
706 #[tokio::test]
710 async fn mouse_down_in_sidebar_bounds_is_consumed() {
711 let mut sidebar = make_sidebar().await;
712 sidebar.rendered_rect = Rect {
713 x: 0,
714 y: 3,
715 width: 30,
716 height: 20,
717 };
718 let (tx, _rx) = unbounded_channel();
719
720 assert_eq!(
722 sidebar.handle_input(&mouse_down_at(5, 4), &tx),
723 EventState::Consumed
724 );
725 assert_eq!(
727 sidebar.handle_input(&mouse_down_at(5, 7), &tx),
728 EventState::Consumed
729 );
730 assert_eq!(
732 sidebar.handle_input(&mouse_down_at(40, 7), &tx),
733 EventState::NotConsumed
734 );
735 }
736
737 fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
738 InputEvent::Mouse(MouseEvent {
739 kind,
740 column: col,
741 row,
742 modifiers: KeyModifiers::NONE,
743 })
744 }
745
746 async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
749 sidebar.navigate(VaultPath::root(), tx);
750 for _ in 0..50 {
753 if let Some(list) = &mut sidebar.list {
754 list.poll();
755 if !list.is_loading() {
756 break;
757 }
758 }
759 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
760 }
761 if let Some(list) = &mut sidebar.list {
762 list.poll();
763 }
764 }
765
766 #[tokio::test(flavor = "multi_thread")]
769 async fn mouse_double_click_on_list_row_sends_open_path() {
770 let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
771 let (tx, mut rx) = unbounded_channel();
772 navigate_to_root(&mut sidebar, &tx).await;
773
774 sidebar.rendered_rect = Rect {
775 x: 0,
776 y: 3,
777 width: 30,
778 height: 20,
779 };
780 if let Some(list) = &mut sidebar.list {
783 list.set_list_rect(Rect {
784 x: 0,
785 y: 9,
786 width: 30,
787 height: 14,
788 });
789 }
790
791 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
793
794 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
796 let mut events = Vec::new();
797 while let Ok(evt) = rx.try_recv() {
798 events.push(evt);
799 }
800 assert!(
801 events
802 .iter()
803 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
804 "expected OpenPath for the activated note, got {events:?}"
805 );
806 }
807
808 #[tokio::test(flavor = "multi_thread")]
813 async fn scroll_down_in_sidebar_bounds_scrolls_list() {
814 let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
815 let (tx, _rx) = unbounded_channel();
816 navigate_to_root(&mut sidebar, &tx).await;
817
818 sidebar.rendered_rect = Rect {
819 x: 0,
820 y: 3,
821 width: 30,
822 height: 20,
823 };
824 if let Some(list) = &mut sidebar.list {
828 list.set_list_rect(Rect {
829 x: 0,
830 y: 9,
831 width: 30,
832 height: 1,
833 });
834 list.set_panel_rect(Rect {
835 x: 0,
836 y: 3,
837 width: 30,
838 height: 20,
839 });
840 }
841
842 let first = sidebar
843 .list
844 .as_ref()
845 .unwrap()
846 .selected_row()
847 .map(|e| e.path().to_string());
848
849 let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
851 assert_eq!(result, EventState::Consumed);
852 let after = sidebar
853 .list
854 .as_ref()
855 .unwrap()
856 .selected_row()
857 .map(|e| e.path().to_string());
858 assert_ne!(
859 first, after,
860 "scroll-from-header should scroll the list, carrying the selection"
861 );
862 }
863
864 #[tokio::test]
865 async fn mouse_down_outside_sidebar_is_not_consumed() {
866 let mut sidebar = make_sidebar().await;
867 sidebar.rendered_rect = Rect {
868 x: 0,
869 y: 3,
870 width: 30,
871 height: 20,
872 };
873 let (tx, mut rx) = unbounded_channel();
874
875 let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
877 assert_eq!(result, EventState::NotConsumed);
878 assert!(rx.try_recv().is_err());
879 }
880
881 #[tokio::test(flavor = "multi_thread")]
883 async fn navigate_loads_directory_notes() {
884 let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
885 assert!(sidebar.is_empty());
886 let (tx, _rx) = unbounded_channel();
887 navigate_to_root(&mut sidebar, &tx).await;
888 assert!(!sidebar.is_empty());
889 assert_eq!(sidebar.note_count(), 1);
890 }
891
892 async fn poll_to_idle(sidebar: &mut SidebarComponent) {
895 for _ in 0..50 {
896 if let Some(list) = &mut sidebar.list {
897 list.poll();
898 if !list.is_loading() {
899 break;
900 }
901 }
902 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
903 }
904 if let Some(list) = &mut sidebar.list {
905 list.poll();
906 }
907 }
908
909 fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
911 sidebar
912 .list
913 .as_ref()
914 .unwrap()
915 .visible_rows()
916 .iter()
917 .filter_map(|e| match e {
918 FileListEntry::Note { filename, .. } => Some(filename.clone()),
919 _ => None,
920 })
921 .collect()
922 }
923
924 #[tokio::test(flavor = "multi_thread")]
925 async fn apply_sort_reverse_flips_listing_order() {
926 let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
927 let (tx, _rx) = unbounded_channel();
928 navigate_to_root(&mut sidebar, &tx).await;
929 let before = note_names(&sidebar);
930 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
931 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
932 poll_to_idle(&mut sidebar).await;
933 let after = note_names(&sidebar);
934 assert_eq!(
935 after,
936 before.iter().rev().cloned().collect::<Vec<_>>(),
937 "descending order should reverse the listing"
938 );
939 }
940
941 #[tokio::test(flavor = "multi_thread")]
942 async fn apply_sort_changes_field() {
943 let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
944 let (tx, _rx) = unbounded_channel();
945 navigate_to_root(&mut sidebar, &tx).await;
946 sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
947 poll_to_idle(&mut sidebar).await;
948 assert_eq!(sidebar.current_sort().0, SortField::Title);
949 assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
950 }
951
952 async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
954 let vault = temp_vault(prefix).await;
955 vault.validate_and_init().await.unwrap();
956 vault
957 .create_note(&VaultPath::note_path_from("alpha"), "body")
958 .await
959 .unwrap();
960 vault
961 .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
962 .await
963 .unwrap();
964 let settings = AppSettings::default();
965 SidebarComponent::new(
966 settings.key_bindings.clone(),
967 vault,
968 settings.icons(),
969 &settings,
970 )
971 }
972
973 fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
975 sidebar
976 .list
977 .as_ref()
978 .unwrap()
979 .visible_rows()
980 .iter()
981 .filter_map(|e| match e {
982 FileListEntry::Note { .. } => Some("note"),
983 FileListEntry::Directory { .. } => Some("dir"),
984 _ => None,
985 })
986 .collect()
987 }
988
989 #[tokio::test(flavor = "multi_thread")]
990 async fn group_dirs_puts_directories_first() {
991 let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
992 let (tx, _rx) = unbounded_channel();
993 navigate_to_root(&mut sidebar, &tx).await;
994 assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
995 sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
996 poll_to_idle(&mut sidebar).await;
997 assert_eq!(
998 row_kinds(&sidebar),
999 vec!["dir", "note"],
1000 "grouping must cluster directories first"
1001 );
1002 }
1003
1004 #[tokio::test(flavor = "multi_thread")]
1005 async fn apply_sort_updates_shared_state() {
1006 let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
1007 let (tx, _rx) = unbounded_channel();
1008 navigate_to_root(&mut sidebar, &tx).await;
1009 sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1010 poll_to_idle(&mut sidebar).await;
1011 assert_eq!(
1012 sidebar.current_sort(),
1013 (SortField::Title, SortOrder::Descending)
1014 );
1015 assert!(!sidebar.group_dirs());
1016 }
1017
1018 #[tokio::test(flavor = "multi_thread")]
1019 async fn set_open_note_stamps_matching_row() {
1020 let mut sb = sidebar_with_notes("sb-open", &["alpha", "beta"]).await;
1021 let (tx, _rx) = unbounded_channel();
1022 navigate_to_root(&mut sb, &tx).await;
1023
1024 sb.set_open_note(Some(VaultPath::note_path_from("alpha")));
1025 assert!(
1026 sb.note_row_is_open_for_test("alpha.md"),
1027 "open note is marked"
1028 );
1029 assert!(
1030 !sb.note_row_is_open_for_test("beta.md"),
1031 "other note is not marked"
1032 );
1033
1034 sb.set_open_note(Some(VaultPath::note_path_from("beta")));
1035 assert!(!sb.note_row_is_open_for_test("alpha.md"));
1036 assert!(sb.note_row_is_open_for_test("beta.md"));
1037
1038 sb.set_open_note(None);
1039 assert!(!sb.note_row_is_open_for_test("beta.md"));
1040 }
1041
1042 #[tokio::test(flavor = "multi_thread")]
1043 async fn update_note_row_changes_title_in_place() {
1044 let mut sb = sidebar_with_notes("sb-title", &["alpha"]).await;
1045 let (tx, _rx) = unbounded_channel();
1046 navigate_to_root(&mut sb, &tx).await;
1047
1048 sb.update_note_row(&VaultPath::note_path_from("alpha"), "Fresh Title");
1049 assert_eq!(
1050 sb.note_row_title_for_test("alpha.md").as_deref(),
1051 Some("Fresh Title")
1052 );
1053 }
1054
1055 #[tokio::test(flavor = "multi_thread")]
1056 async fn rename_note_row_updates_path_and_filename() {
1057 let mut sb = sidebar_with_notes("sb-rename", &["alpha"]).await;
1058 let (tx, _rx) = unbounded_channel();
1059 navigate_to_root(&mut sb, &tx).await;
1060
1061 let to = VaultPath::note_path_from("gamma");
1062 let expected_filename = to.get_parent_path().1;
1063 sb.rename_note_row(&VaultPath::note_path_from("alpha"), &to);
1064 assert!(
1065 sb.note_row_title_for_test("gamma.md").is_some(),
1066 "row now at new name"
1067 );
1068 assert!(
1069 sb.note_row_title_for_test("alpha.md").is_none(),
1070 "old name gone"
1071 );
1072 let renamed_filename = sb
1074 .list
1075 .as_ref()
1076 .unwrap()
1077 .rows()
1078 .iter()
1079 .find_map(|r| match r {
1080 FileListEntry::Note { path, filename, .. } if path.is_like(&to) => {
1081 Some(filename.clone())
1082 }
1083 _ => None,
1084 });
1085 assert_eq!(
1086 renamed_filename.as_deref(),
1087 Some(expected_filename.as_str()),
1088 "filename field must be updated to the new name"
1089 );
1090 }
1091
1092 #[tokio::test(flavor = "multi_thread")]
1095 async fn rename_note_row_clears_journal_date_when_renamed_away_from_date_name() {
1096 let vault = crate::test_support::temp_vault("sb-jdate").await;
1099 vault.validate_and_init().await.unwrap();
1100 let journal_path = vault.journal_path().clone();
1101 let date_name = "2026-06-09";
1102 let from = journal_path
1103 .append(&VaultPath::note_path_from(date_name))
1104 .absolute();
1105 vault.create_note(&from, "journal body").await.unwrap();
1106
1107 let settings = AppSettings::default();
1108 let mut sb = SidebarComponent::new(
1109 settings.key_bindings.clone(),
1110 vault,
1111 settings.icons(),
1112 &settings,
1113 );
1114 let (tx, _rx) = unbounded_channel();
1115 sb.navigate(journal_path.clone(), &tx);
1117 for _ in 0..50 {
1118 sb.poll_for_test();
1119 if !sb.is_loading_for_test() {
1120 break;
1121 }
1122 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1123 }
1124 sb.poll_for_test();
1125
1126 assert!(
1128 sb.note_row_journal_date_for_test(&from).is_some(),
1129 "journal note must have a journal_date before rename"
1130 );
1131
1132 let to = journal_path
1134 .append(&VaultPath::note_path_from("meeting"))
1135 .absolute();
1136 sb.rename_note_row(&from, &to);
1137
1138 assert_eq!(
1140 sb.note_row_journal_date_for_test(&to),
1141 None,
1142 "journal_date must be cleared after renaming to a non-date name"
1143 );
1144 }
1145
1146 #[tokio::test(flavor = "multi_thread")]
1151 async fn save_default_survives_navigation() {
1152 let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
1153 let (tx, _rx) = unbounded_channel();
1154 navigate_to_root(&mut sidebar, &tx).await;
1155
1156 sidebar.save_default(SortField::Title, SortOrder::Descending, false);
1157 poll_to_idle(&mut sidebar).await;
1158
1159 sidebar.navigate(VaultPath::root(), &tx);
1162 poll_to_idle(&mut sidebar).await;
1163 assert_eq!(
1164 sidebar.current_sort(),
1165 (SortField::Title, SortOrder::Descending),
1166 "saved default must persist across navigation"
1167 );
1168 }
1169}