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