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