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, 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 list: Option<SearchList<FileListEntry>>,
134 vault: Arc<NoteVault>,
135 icons: Icons,
136 default_sort_field: SortField,
137 default_sort_order: SortOrder,
138 journal_sort_field: SortField,
139 journal_sort_order: SortOrder,
140 sort: Arc<Mutex<(SortField, SortOrder)>>,
144 group_dirs: Arc<Mutex<bool>>,
147 rendered_rect: Rect,
148 breadcrumb_cells: Vec<(Rect, VaultPath)>,
151 key_bindings: KeyBindings,
152}
153
154impl SidebarComponent {
155 pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
159 Self::new(
160 settings.key_bindings.clone(),
161 vault,
162 settings.icons(),
163 settings,
164 )
165 }
166
167 pub fn new(
168 key_bindings: KeyBindings,
169 vault: Arc<NoteVault>,
170 icons: Icons,
171 settings: &AppSettings,
172 ) -> Self {
173 let default_sort_field = SortField::from(settings.default_sort_field);
174 let default_sort_order = SortOrder::from(settings.default_sort_order);
175 Self {
176 current_dir: VaultPath::root(),
177 list: None,
178 vault,
179 icons,
180 default_sort_field,
181 default_sort_order,
182 journal_sort_field: SortField::from(settings.journal_sort_field),
183 journal_sort_order: SortOrder::from(settings.journal_sort_order),
184 sort: Arc::new(Mutex::new((default_sort_field, default_sort_order))),
185 group_dirs: Arc::new(Mutex::new(settings.group_directories)),
186 rendered_rect: Rect::default(),
187 breadcrumb_cells: Vec::new(),
188 key_bindings,
189 }
190 }
191
192 fn breadcrumb_at(&self, column: u16, row: u16) -> Option<&VaultPath> {
194 self.breadcrumb_cells
195 .iter()
196 .find(|(rect, _)| rect.contains(Position { x: column, y: row }))
197 .map(|(_, dir)| dir)
198 }
199
200 pub fn current_dir(&self) -> &VaultPath {
201 &self.current_dir
202 }
203
204 pub fn is_empty(&self) -> bool {
207 self.list.is_none()
208 }
209
210 fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
212 if dir == self.vault.journal_path() {
213 (self.journal_sort_field, self.journal_sort_order)
214 } else {
215 (self.default_sort_field, self.default_sort_order)
216 }
217 }
218
219 pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
223 self.current_dir = dir.clone();
224 let (sort_field, sort_order) = self.sort_for(&dir);
225 self.sort = Arc::new(Mutex::new((sort_field, sort_order)));
226 let source = DirListingSource {
227 vault: self.vault.clone(),
228 dir,
229 sort: self.sort.clone(),
230 group_dirs: self.group_dirs.clone(),
231 };
232 self.list = Some(
233 SearchList::builder(source, redraw_callback(tx.clone()))
234 .filter(Filter::Fuzzy)
235 .icons(self.icons.clone())
236 .build(),
237 );
238 }
239
240 pub fn current_sort(&self) -> (SortField, SortOrder) {
242 *self.sort.lock().unwrap()
243 }
244
245 pub fn group_dirs(&self) -> bool {
247 *self.group_dirs.lock().unwrap()
248 }
249
250 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
253 *self.sort.lock().unwrap() = (field, order);
254 *self.group_dirs.lock().unwrap() = group_dirs;
255 if let Some(list) = &mut self.list {
256 list.reload();
257 }
258 }
259
260 pub fn is_current_journal(&self) -> bool {
263 &self.current_dir == self.vault.journal_path()
264 }
265
266 pub fn save_default(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
272 if self.is_current_journal() {
273 self.journal_sort_field = field;
274 self.journal_sort_order = order;
275 } else {
276 self.default_sort_field = field;
277 self.default_sort_order = order;
278 }
279 self.apply_sort(field, order, group_dirs);
280 }
281
282 fn note_count(&self) -> usize {
284 match &self.list {
285 None => 0,
286 Some(list) => list
287 .visible_rows()
288 .iter()
289 .filter(|e| matches!(e, FileListEntry::Note { .. }))
290 .count(),
291 }
292 }
293
294 fn activate_selected_entry(&self, tx: &AppTx) {
298 let Some(list) = &self.list else { return };
299 let Some(entry) = list.selected_row() else {
300 return;
301 };
302 match entry {
303 FileListEntry::CreateNote { path, .. } => {
304 let path = path.clone();
305 let vault = Arc::clone(&self.vault);
306 let tx2 = tx.clone();
307 tokio::spawn(async move {
308 if let Err(e) = vault.load_or_create_note(&path, None).await {
309 tracing::warn!("create note failed for {path}: {e}");
310 return;
311 }
312 tx2.send(AppEvent::open(path)).ok();
313 });
314 }
315 other => {
316 tx.send(AppEvent::open(other.path().clone())).ok();
317 }
318 }
319 }
320}
321
322fn format_journal_date(date: NaiveDate) -> String {
325 date.format("%A, %B %-d, %Y").to_string()
326}
327
328impl Component for SidebarComponent {
329 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
330 if let InputEvent::Mouse(mouse) = event {
331 let pos = Position {
332 x: mouse.column,
333 y: mouse.row,
334 };
335 if !self.rendered_rect.contains(pos) {
336 return EventState::NotConsumed;
337 }
338 if matches!(
340 mouse.kind,
341 ratatui::crossterm::event::MouseEventKind::Down(
342 ratatui::crossterm::event::MouseButton::Left
343 )
344 ) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
345 {
346 tx.send(AppEvent::open(dir.clone())).ok();
347 return EventState::Consumed;
348 }
349 if let Some(list) = &mut self.list {
355 match list.handle_mouse(mouse) {
356 SearchMouse::Activated(_) => self.activate_selected_entry(tx),
357 SearchMouse::Context(_) => {
359 if let Some(entry) = list.selected_row()
360 && !matches!(
361 entry,
362 FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
363 )
364 {
365 tx.send(AppEvent::ShowFileOpsMenu(entry.path().clone()))
366 .ok();
367 }
368 }
369 SearchMouse::Selected(_)
372 | SearchMouse::Scrolled
373 | SearchMouse::ContentScrollUp
374 | SearchMouse::ContentScrollDown
375 | SearchMouse::None => {}
376 }
377 }
378 return EventState::Consumed;
379 }
380
381 if let InputEvent::Key(key) = event {
382 if self.list.is_none() {
383 return EventState::NotConsumed;
384 }
385 let reaction = self.list.as_mut().unwrap().handle_key(key);
386 match reaction {
387 KeyReaction::Submit => {
388 self.activate_selected_entry(tx);
389 EventState::Consumed
390 }
391 KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
392 KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
393 }
394 } else {
395 EventState::NotConsumed
396 }
397 }
398
399 fn hint_shortcuts(&self) -> Vec<(String, String)> {
400 use crate::keys::action_shortcuts::ActionShortcuts;
401
402 crate::components::hints::hints_for(
403 &self.key_bindings,
404 &[
405 (ActionShortcuts::FocusEditor, "editor \u{2192}"),
406 (ActionShortcuts::OpenSortDialog, "sort"),
407 ],
408 )
409 }
410
411 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
412 self.rendered_rect = rect;
413
414 let rows = Layout::default()
415 .direction(Direction::Vertical)
416 .constraints([
417 Constraint::Length(3),
418 Constraint::Length(3),
419 Constraint::Min(0),
420 ])
421 .split(rect);
422
423 let border_style = theme.border_style(focused);
424
425 let header = Block::default()
426 .title(format!("─ Files · {} ", self.current_dir))
427 .borders(Borders::ALL)
428 .border_style(border_style)
429 .style(theme.panel_style());
430 let header_inner = header.inner(rows[0]);
431 f.render_widget(header, rows[0]);
432
433 self.breadcrumb_cells.clear();
437 let seg_style = Style::default()
438 .fg(theme.fg_secondary.to_ratatui())
439 .bg(theme.bg_panel.to_ratatui());
440 let sep_style = Style::default()
441 .fg(theme.gray.to_ratatui())
442 .bg(theme.bg_panel.to_ratatui());
443 let mut spans: Vec<Span> = Vec::new();
444 let mut x = header_inner.x;
445 let mut push_segment =
446 |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
447 let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
448 if *x < header_inner.right() {
452 let visible = w.min(header_inner.right() - *x);
453 self.breadcrumb_cells
454 .push((Rect::new(*x, header_inner.y, visible, 1), dir));
455 }
456 spans.push(Span::styled(label, seg_style));
457 *x += w;
458 };
459 push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
460 let slices = self.current_dir.get_slices();
461 let mut acc = String::new();
462 for slice in &slices {
463 spans.push(Span::styled(" / ", sep_style));
464 x += 3;
465 acc.push('/');
466 acc.push_str(slice);
467 push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
468 }
469 let count = format!("{} notes", self.note_count());
470 let used: u16 = x - header_inner.x;
471 let pad = header_inner
472 .width
473 .saturating_sub(used)
474 .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
475 spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
476 spans.push(Span::styled(count, sep_style));
477 f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
478
479 let search_block = Block::default()
480 .title(" Search")
481 .borders(Borders::ALL)
482 .border_style(border_style)
483 .style(theme.panel_style());
484 let search_inner = search_block.inner(rows[1]);
485 f.render_widget(search_block, rows[1]);
486
487 let list_block = Block::default()
488 .borders(Borders::ALL)
489 .border_style(border_style)
490 .style(theme.panel_style());
491 let list_inner = list_block.inner(rows[2]);
492 f.render_widget(list_block, rows[2]);
493
494 if let Some(list) = &mut self.list {
495 list.render_query(f, search_inner, theme, focused);
496 list.render(f, list_inner, theme, focused);
497 list.set_list_rect(list_inner);
502 list.set_panel_rect(rect);
503 }
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::settings::AppSettings;
511 use crate::test_support::{mouse_down_at, temp_vault};
512 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
513 use tokio::sync::mpsc::unbounded_channel;
514
515 async fn make_sidebar() -> SidebarComponent {
516 let vault = temp_vault("sidebar").await;
517 vault.validate_and_init().await.unwrap();
518 let settings = AppSettings::default();
519 SidebarComponent::new(
520 settings.key_bindings.clone(),
521 vault,
522 settings.icons(),
523 &settings,
524 )
525 }
526
527 async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
529 let vault = temp_vault(prefix).await;
530 vault.validate_and_init().await.unwrap();
531 for name in names {
532 vault
533 .create_note(&VaultPath::note_path_from(name), "body")
534 .await
535 .unwrap();
536 }
537 let settings = AppSettings::default();
538 SidebarComponent::new(
539 settings.key_bindings.clone(),
540 vault,
541 settings.icons(),
542 &settings,
543 )
544 }
545
546 #[tokio::test]
550 async fn mouse_down_in_sidebar_bounds_is_consumed() {
551 let mut sidebar = make_sidebar().await;
552 sidebar.rendered_rect = Rect {
553 x: 0,
554 y: 3,
555 width: 30,
556 height: 20,
557 };
558 let (tx, _rx) = unbounded_channel();
559
560 assert_eq!(
562 sidebar.handle_input(&mouse_down_at(5, 4), &tx),
563 EventState::Consumed
564 );
565 assert_eq!(
567 sidebar.handle_input(&mouse_down_at(5, 7), &tx),
568 EventState::Consumed
569 );
570 assert_eq!(
572 sidebar.handle_input(&mouse_down_at(40, 7), &tx),
573 EventState::NotConsumed
574 );
575 }
576
577 fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
578 InputEvent::Mouse(MouseEvent {
579 kind,
580 column: col,
581 row,
582 modifiers: KeyModifiers::NONE,
583 })
584 }
585
586 async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
589 sidebar.navigate(VaultPath::root(), tx);
590 for _ in 0..50 {
593 if let Some(list) = &mut sidebar.list {
594 list.poll();
595 if !list.is_loading() {
596 break;
597 }
598 }
599 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
600 }
601 if let Some(list) = &mut sidebar.list {
602 list.poll();
603 }
604 }
605
606 #[tokio::test(flavor = "multi_thread")]
609 async fn mouse_double_click_on_list_row_sends_open_path() {
610 let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
611 let (tx, mut rx) = unbounded_channel();
612 navigate_to_root(&mut sidebar, &tx).await;
613
614 sidebar.rendered_rect = Rect {
615 x: 0,
616 y: 3,
617 width: 30,
618 height: 20,
619 };
620 if let Some(list) = &mut sidebar.list {
623 list.set_list_rect(Rect {
624 x: 0,
625 y: 9,
626 width: 30,
627 height: 14,
628 });
629 }
630
631 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
633
634 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
636 let mut events = Vec::new();
637 while let Ok(evt) = rx.try_recv() {
638 events.push(evt);
639 }
640 assert!(
641 events
642 .iter()
643 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
644 "expected OpenPath for the activated note, got {events:?}"
645 );
646 }
647
648 #[tokio::test(flavor = "multi_thread")]
653 async fn scroll_down_in_sidebar_bounds_scrolls_list() {
654 let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
655 let (tx, _rx) = unbounded_channel();
656 navigate_to_root(&mut sidebar, &tx).await;
657
658 sidebar.rendered_rect = Rect {
659 x: 0,
660 y: 3,
661 width: 30,
662 height: 20,
663 };
664 if let Some(list) = &mut sidebar.list {
668 list.set_list_rect(Rect {
669 x: 0,
670 y: 9,
671 width: 30,
672 height: 1,
673 });
674 list.set_panel_rect(Rect {
675 x: 0,
676 y: 3,
677 width: 30,
678 height: 20,
679 });
680 }
681
682 let first = sidebar
683 .list
684 .as_ref()
685 .unwrap()
686 .selected_row()
687 .map(|e| e.path().to_string());
688
689 let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
691 assert_eq!(result, EventState::Consumed);
692 let after = sidebar
693 .list
694 .as_ref()
695 .unwrap()
696 .selected_row()
697 .map(|e| e.path().to_string());
698 assert_ne!(
699 first, after,
700 "scroll-from-header should scroll the list, carrying the selection"
701 );
702 }
703
704 #[tokio::test]
705 async fn mouse_down_outside_sidebar_is_not_consumed() {
706 let mut sidebar = make_sidebar().await;
707 sidebar.rendered_rect = Rect {
708 x: 0,
709 y: 3,
710 width: 30,
711 height: 20,
712 };
713 let (tx, mut rx) = unbounded_channel();
714
715 let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
717 assert_eq!(result, EventState::NotConsumed);
718 assert!(rx.try_recv().is_err());
719 }
720
721 #[tokio::test(flavor = "multi_thread")]
723 async fn navigate_loads_directory_notes() {
724 let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
725 assert!(sidebar.is_empty());
726 let (tx, _rx) = unbounded_channel();
727 navigate_to_root(&mut sidebar, &tx).await;
728 assert!(!sidebar.is_empty());
729 assert_eq!(sidebar.note_count(), 1);
730 }
731
732 async fn poll_to_idle(sidebar: &mut SidebarComponent) {
735 for _ in 0..50 {
736 if let Some(list) = &mut sidebar.list {
737 list.poll();
738 if !list.is_loading() {
739 break;
740 }
741 }
742 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
743 }
744 if let Some(list) = &mut sidebar.list {
745 list.poll();
746 }
747 }
748
749 fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
751 sidebar
752 .list
753 .as_ref()
754 .unwrap()
755 .visible_rows()
756 .iter()
757 .filter_map(|e| match e {
758 FileListEntry::Note { filename, .. } => Some(filename.clone()),
759 _ => None,
760 })
761 .collect()
762 }
763
764 #[tokio::test(flavor = "multi_thread")]
765 async fn apply_sort_reverse_flips_listing_order() {
766 let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
767 let (tx, _rx) = unbounded_channel();
768 navigate_to_root(&mut sidebar, &tx).await;
769 let before = note_names(&sidebar);
770 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
771 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
772 poll_to_idle(&mut sidebar).await;
773 let after = note_names(&sidebar);
774 assert_eq!(
775 after,
776 before.iter().rev().cloned().collect::<Vec<_>>(),
777 "descending order should reverse the listing"
778 );
779 }
780
781 #[tokio::test(flavor = "multi_thread")]
782 async fn apply_sort_changes_field() {
783 let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
784 let (tx, _rx) = unbounded_channel();
785 navigate_to_root(&mut sidebar, &tx).await;
786 sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
787 poll_to_idle(&mut sidebar).await;
788 assert_eq!(sidebar.current_sort().0, SortField::Title);
789 assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
790 }
791
792 async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
794 let vault = temp_vault(prefix).await;
795 vault.validate_and_init().await.unwrap();
796 vault
797 .create_note(&VaultPath::note_path_from("alpha"), "body")
798 .await
799 .unwrap();
800 vault
801 .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
802 .await
803 .unwrap();
804 let settings = AppSettings::default();
805 SidebarComponent::new(
806 settings.key_bindings.clone(),
807 vault,
808 settings.icons(),
809 &settings,
810 )
811 }
812
813 fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
815 sidebar
816 .list
817 .as_ref()
818 .unwrap()
819 .visible_rows()
820 .iter()
821 .filter_map(|e| match e {
822 FileListEntry::Note { .. } => Some("note"),
823 FileListEntry::Directory { .. } => Some("dir"),
824 _ => None,
825 })
826 .collect()
827 }
828
829 #[tokio::test(flavor = "multi_thread")]
830 async fn group_dirs_puts_directories_first() {
831 let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
832 let (tx, _rx) = unbounded_channel();
833 navigate_to_root(&mut sidebar, &tx).await;
834 assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
835 sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
836 poll_to_idle(&mut sidebar).await;
837 assert_eq!(
838 row_kinds(&sidebar),
839 vec!["dir", "note"],
840 "grouping must cluster directories first"
841 );
842 }
843
844 #[tokio::test(flavor = "multi_thread")]
845 async fn apply_sort_updates_shared_state() {
846 let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
847 let (tx, _rx) = unbounded_channel();
848 navigate_to_root(&mut sidebar, &tx).await;
849 sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
850 poll_to_idle(&mut sidebar).await;
851 assert_eq!(
852 sidebar.current_sort(),
853 (SortField::Title, SortOrder::Descending)
854 );
855 assert!(!sidebar.group_dirs());
856 }
857
858 #[tokio::test(flavor = "multi_thread")]
863 async fn save_default_survives_navigation() {
864 let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
865 let (tx, _rx) = unbounded_channel();
866 navigate_to_root(&mut sidebar, &tx).await;
867
868 sidebar.save_default(SortField::Title, SortOrder::Descending, false);
869 poll_to_idle(&mut sidebar).await;
870
871 sidebar.navigate(VaultPath::root(), &tx);
874 poll_to_idle(&mut sidebar).await;
875 assert_eq!(
876 sidebar.current_sort(),
877 (SortField::Title, SortOrder::Descending),
878 "saved default must persist across navigation"
879 );
880 }
881}