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 .yank_combos_from(&self.key_bindings)
243 .icons(self.icons.clone())
244 .build(),
245 );
246 }
247
248 pub fn refresh_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
253 if dir.is_like(&self.current_dir) {
254 self.navigate(self.current_dir.clone(), tx);
255 }
256 }
257
258 pub fn set_open_note(&mut self, path: Option<VaultPath>) {
262 self.open_note = path;
263 self.stamp_open_marker();
264 }
265
266 fn stamp_open_marker(&mut self) {
270 let open = self.open_note.clone();
271 if let Some(list) = &mut self.list {
272 list.update_rows(|row| {
273 if let FileListEntry::Note { path, is_open, .. } = row {
274 let want = open.as_ref().is_some_and(|o| path.is_like(o));
275 if *is_open != want {
276 *is_open = want;
277 return true;
278 }
279 }
280 false
281 });
282 }
283 }
284
285 pub fn update_note_row(&mut self, path: &VaultPath, new_title: &str) {
289 if let Some(list) = &mut self.list {
290 list.update_rows(|row| {
291 if let FileListEntry::Note {
292 path: row_path,
293 title,
294 ..
295 } = row
296 && row_path.is_like(path)
297 && title != new_title
298 {
299 *title = new_title.to_string();
300 return true;
301 }
302 false
303 });
304 }
305 }
306
307 pub fn rename_note_row(&mut self, from: &VaultPath, to: &VaultPath) {
313 let new_filename = to.get_parent_path().1;
314 let new_journal_date = self.vault.journal_date(to).map(format_journal_date);
315 if let Some(list) = &mut self.list {
316 list.update_rows(|row| {
317 if let FileListEntry::Note {
318 path,
319 filename,
320 journal_date,
321 ..
322 } = row
323 && path.is_like(from)
324 {
325 *path = to.clone();
326 *filename = new_filename.clone();
327 *journal_date = new_journal_date.clone();
328 return true;
329 }
330 false
331 });
332 }
333 }
334
335 pub fn set_current_dir(&mut self, dir: VaultPath) {
339 self.current_dir = dir;
340 }
341
342 pub fn current_sort(&self) -> (SortField, SortOrder) {
344 *self.sort.lock().unwrap()
345 }
346
347 pub fn group_dirs(&self) -> bool {
349 *self.group_dirs.lock().unwrap()
350 }
351
352 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
355 *self.sort.lock().unwrap() = (field, order);
356 *self.group_dirs.lock().unwrap() = group_dirs;
357 if let Some(list) = &mut self.list {
358 list.reload();
359 }
360 }
361
362 pub fn is_current_journal(&self) -> bool {
365 self.current_dir.is_like(self.vault.journal_path())
366 }
367
368 pub fn save_default(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
374 if self.is_current_journal() {
375 self.journal_sort_field = field;
376 self.journal_sort_order = order;
377 } else {
378 self.default_sort_field = field;
379 self.default_sort_order = order;
380 }
381 self.apply_sort(field, order, group_dirs);
382 }
383
384 fn note_count(&self) -> usize {
386 match &self.list {
387 None => 0,
388 Some(list) => list
389 .visible_rows()
390 .iter()
391 .filter(|e| matches!(e, FileListEntry::Note { .. }))
392 .count(),
393 }
394 }
395
396 fn activate_selected_entry(&self, tx: &AppTx) {
400 let Some(list) = &self.list else { return };
401 let Some(entry) = list.selected_row() else {
402 return;
403 };
404 match entry {
405 FileListEntry::CreateNote { path, .. } => {
406 let path = path.clone();
407 let vault = Arc::clone(&self.vault);
408 let tx2 = tx.clone();
409 tokio::spawn(async move {
410 match vault.load_or_create_note(&path, None).await {
411 Ok((_, created)) => tx2.announce_and_open(path, created),
412 Err(e) => {
413 tracing::warn!("create note failed for {path}: {e}");
414 }
415 }
416 });
417 }
418 FileListEntry::Attachment { path, .. } => {
419 tx.send(AppEvent::OpenAttachment(path.clone())).ok();
420 }
421 other => {
422 tx.send(AppEvent::open(other.path().clone())).ok();
423 }
424 }
425 }
426}
427
428fn format_journal_date(date: NaiveDate) -> String {
431 date.format("%A, %B %-d, %Y").to_string()
432}
433
434impl Component for SidebarComponent {
435 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
436 if let InputEvent::Mouse(mouse) = event {
437 let pos = Position {
438 x: mouse.column,
439 y: mouse.row,
440 };
441 if !self.rendered_rect.contains(pos) {
442 return EventState::NotConsumed;
443 }
444 if matches!(
446 mouse.kind,
447 ratatui::crossterm::event::MouseEventKind::Down(
448 ratatui::crossterm::event::MouseButton::Left
449 )
450 ) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
451 {
452 tx.send(AppEvent::open(dir.clone())).ok();
453 return EventState::Consumed;
454 }
455 if let Some(list) = &mut self.list {
461 match list.handle_mouse(mouse) {
462 SearchMouse::Activated(_) => self.activate_selected_entry(tx),
463 SearchMouse::Context(_) => {
465 if let Some(entry) = list.selected_row()
466 && !matches!(
467 entry,
468 FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
469 )
470 {
471 tx.send(AppEvent::FileOp(FileOp::ShowMenu(entry.path().clone())))
472 .ok();
473 }
474 }
475 SearchMouse::Selected(_)
478 | SearchMouse::Scrolled
479 | SearchMouse::ContentScrollUp
480 | SearchMouse::ContentScrollDown
481 | SearchMouse::None => {}
482 }
483 }
484 return EventState::Consumed;
485 }
486
487 if let InputEvent::Key(key) = event {
488 if self.list.is_none() {
489 return EventState::NotConsumed;
490 }
491 let reaction = self.list.as_mut().unwrap().handle_key(key);
492 match reaction {
493 KeyReaction::Submit => {
494 self.activate_selected_entry(tx);
495 EventState::Consumed
496 }
497 KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
498 KeyReaction::Yank(target) => {
499 crate::components::yank_row(target, tx);
500 EventState::Consumed
501 }
502 KeyReaction::Intercepted(_) | KeyReaction::ListVerb(_) | KeyReaction::Unhandled => {
503 EventState::NotConsumed
504 }
505 }
506 } else {
507 EventState::NotConsumed
508 }
509 }
510
511 fn hint_shortcuts(&self) -> Vec<(String, String)> {
512 use crate::keys::action_shortcuts::ActionShortcuts;
513
514 crate::components::hints::hints_for(
515 &self.key_bindings,
516 &[
517 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
518 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
519 (ActionShortcuts::OpenSortDialog, "sort"),
520 ],
521 )
522 }
523
524 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
525 self.rendered_rect = rect;
526
527 let rows = Layout::default()
528 .direction(Direction::Vertical)
529 .constraints([
530 Constraint::Length(3),
531 Constraint::Length(3),
532 Constraint::Min(0),
533 ])
534 .split(rect);
535
536 let border_style = theme.border_style(focused);
537
538 let header = Block::default()
539 .title(format!("─ Files · {} ", self.current_dir))
540 .borders(Borders::ALL)
541 .border_style(border_style)
542 .style(theme.panel_style());
543 let header_inner = header.inner(rows[0]);
544 f.render_widget(header, rows[0]);
545
546 self.breadcrumb_cells.clear();
550 let seg_style = Style::default()
551 .fg(theme.fg_secondary.to_ratatui())
552 .bg(theme.bg_panel.to_ratatui());
553 let sep_style = Style::default()
554 .fg(theme.gray.to_ratatui())
555 .bg(theme.bg_panel.to_ratatui());
556 let mut spans: Vec<Span> = Vec::new();
557 let mut x = header_inner.x;
558 let mut push_segment =
559 |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
560 let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
561 if *x < header_inner.right() {
565 let visible = w.min(header_inner.right() - *x);
566 self.breadcrumb_cells
567 .push((Rect::new(*x, header_inner.y, visible, 1), dir));
568 }
569 spans.push(Span::styled(label, seg_style));
570 *x += w;
571 };
572 push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
573 let slices = self.current_dir.get_slices();
574 let mut acc = String::new();
575 for slice in &slices {
576 spans.push(Span::styled(" / ", sep_style));
577 x += 3;
578 acc.push('/');
579 acc.push_str(slice);
580 push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
581 }
582 let count = format!("{} notes", self.note_count());
583 let used: u16 = x - header_inner.x;
584 let pad = header_inner
585 .width
586 .saturating_sub(used)
587 .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
588 spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
589 spans.push(Span::styled(count, sep_style));
590 f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
591
592 let search_block = Block::default()
593 .title(" Search")
594 .borders(Borders::ALL)
595 .border_style(border_style)
596 .style(theme.panel_style());
597 let search_inner = search_block.inner(rows[1]);
598 f.render_widget(search_block, rows[1]);
599
600 let list_block = Block::default()
601 .borders(Borders::ALL)
602 .border_style(border_style)
603 .style(theme.panel_style());
604 let list_inner = list_block.inner(rows[2]);
605 f.render_widget(list_block, rows[2]);
606
607 if let Some(list) = &mut self.list {
611 list.poll();
612 }
613 self.stamp_open_marker();
614 if let Some(list) = &mut self.list {
615 list.render_query(f, search_inner, theme, focused);
616 list.render(f, list_inner, theme, focused);
617 list.set_list_rect(list_inner);
622 list.set_panel_rect(rect);
623 }
624 }
625}
626
627#[cfg(test)]
628impl SidebarComponent {
629 pub(crate) fn poll_for_test(&mut self) {
630 if let Some(list) = &mut self.list {
631 list.poll();
632 }
633 self.stamp_open_marker();
634 }
635
636 pub(crate) fn is_loading_for_test(&self) -> bool {
637 self.list.as_ref().is_some_and(|l| l.is_loading())
638 }
639
640 pub(crate) fn note_row_is_open_for_test(&self, name: &str) -> bool {
641 self.list.as_ref().is_some_and(|l| {
642 l.rows().iter().any(|r| {
643 matches!(r, FileListEntry::Note { path, is_open, .. }
644 if path.get_name() == name && *is_open)
645 })
646 })
647 }
648
649 pub(crate) fn note_row_title_for_test(&self, name: &str) -> Option<String> {
650 self.list.as_ref().and_then(|l| {
651 l.rows().iter().find_map(|r| match r {
652 FileListEntry::Note { path, title, .. } if path.get_name() == name => {
653 Some(title.clone())
654 }
655 _ => None,
656 })
657 })
658 }
659
660 pub(crate) fn note_row_journal_date_for_test(&self, path: &VaultPath) -> Option<String> {
661 self.list.as_ref().and_then(|l| {
662 l.rows().iter().find_map(|r| match r {
663 FileListEntry::Note {
664 path: row_path,
665 journal_date,
666 ..
667 } if row_path.is_like(path) => journal_date.clone(),
668 _ => None,
669 })
670 })
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use crate::settings::AppSettings;
678 use crate::test_support::{mouse_down_at, temp_vault};
679 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
680 use tokio::sync::mpsc::unbounded_channel;
681
682 async fn make_sidebar() -> SidebarComponent {
683 let vault = temp_vault("sidebar").await;
684 vault.validate_and_init().await.unwrap();
685 let settings = AppSettings::default();
686 SidebarComponent::new(
687 settings.key_bindings.clone(),
688 vault,
689 settings.icons(),
690 &settings,
691 )
692 }
693
694 async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
696 let vault = temp_vault(prefix).await;
697 vault.validate_and_init().await.unwrap();
698 for name in names {
699 vault
700 .create_note(&VaultPath::note_path_from(name), "body")
701 .await
702 .unwrap();
703 }
704 let settings = AppSettings::default();
705 SidebarComponent::new(
706 settings.key_bindings.clone(),
707 vault,
708 settings.icons(),
709 &settings,
710 )
711 }
712
713 #[tokio::test]
717 async fn mouse_down_in_sidebar_bounds_is_consumed() {
718 let mut sidebar = make_sidebar().await;
719 sidebar.rendered_rect = Rect {
720 x: 0,
721 y: 3,
722 width: 30,
723 height: 20,
724 };
725 let (tx, _rx) = unbounded_channel();
726
727 assert_eq!(
729 sidebar.handle_input(&mouse_down_at(5, 4), &tx),
730 EventState::Consumed
731 );
732 assert_eq!(
734 sidebar.handle_input(&mouse_down_at(5, 7), &tx),
735 EventState::Consumed
736 );
737 assert_eq!(
739 sidebar.handle_input(&mouse_down_at(40, 7), &tx),
740 EventState::NotConsumed
741 );
742 }
743
744 fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
745 InputEvent::Mouse(MouseEvent {
746 kind,
747 column: col,
748 row,
749 modifiers: KeyModifiers::NONE,
750 })
751 }
752
753 async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
756 sidebar.navigate(VaultPath::root(), tx);
757 for _ in 0..50 {
760 if let Some(list) = &mut sidebar.list {
761 list.poll();
762 if !list.is_loading() {
763 break;
764 }
765 }
766 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
767 }
768 if let Some(list) = &mut sidebar.list {
769 list.poll();
770 }
771 }
772
773 #[tokio::test(flavor = "multi_thread")]
776 async fn mouse_double_click_on_list_row_sends_open_path() {
777 let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
778 let (tx, mut rx) = unbounded_channel();
779 navigate_to_root(&mut sidebar, &tx).await;
780
781 sidebar.rendered_rect = Rect {
782 x: 0,
783 y: 3,
784 width: 30,
785 height: 20,
786 };
787 if let Some(list) = &mut sidebar.list {
790 list.set_list_rect(Rect {
791 x: 0,
792 y: 9,
793 width: 30,
794 height: 14,
795 });
796 }
797
798 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
800
801 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
803 let mut events = Vec::new();
804 while let Ok(evt) = rx.try_recv() {
805 events.push(evt);
806 }
807 assert!(
808 events
809 .iter()
810 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
811 "expected OpenPath for the activated note, got {events:?}"
812 );
813 }
814
815 #[tokio::test(flavor = "multi_thread")]
820 async fn scroll_down_in_sidebar_bounds_scrolls_list() {
821 let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
822 let (tx, _rx) = unbounded_channel();
823 navigate_to_root(&mut sidebar, &tx).await;
824
825 sidebar.rendered_rect = Rect {
826 x: 0,
827 y: 3,
828 width: 30,
829 height: 20,
830 };
831 if let Some(list) = &mut sidebar.list {
835 list.set_list_rect(Rect {
836 x: 0,
837 y: 9,
838 width: 30,
839 height: 1,
840 });
841 list.set_panel_rect(Rect {
842 x: 0,
843 y: 3,
844 width: 30,
845 height: 20,
846 });
847 }
848
849 let first = sidebar
850 .list
851 .as_ref()
852 .unwrap()
853 .selected_row()
854 .map(|e| e.path().to_string());
855
856 let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
858 assert_eq!(result, EventState::Consumed);
859 let after = sidebar
860 .list
861 .as_ref()
862 .unwrap()
863 .selected_row()
864 .map(|e| e.path().to_string());
865 assert_ne!(
866 first, after,
867 "scroll-from-header should scroll the list, carrying the selection"
868 );
869 }
870
871 #[tokio::test]
872 async fn mouse_down_outside_sidebar_is_not_consumed() {
873 let mut sidebar = make_sidebar().await;
874 sidebar.rendered_rect = Rect {
875 x: 0,
876 y: 3,
877 width: 30,
878 height: 20,
879 };
880 let (tx, mut rx) = unbounded_channel();
881
882 let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
884 assert_eq!(result, EventState::NotConsumed);
885 assert!(rx.try_recv().is_err());
886 }
887
888 #[tokio::test(flavor = "multi_thread")]
890 async fn navigate_loads_directory_notes() {
891 let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
892 assert!(sidebar.is_empty());
893 let (tx, _rx) = unbounded_channel();
894 navigate_to_root(&mut sidebar, &tx).await;
895 assert!(!sidebar.is_empty());
896 assert_eq!(sidebar.note_count(), 1);
897 }
898
899 async fn poll_to_idle(sidebar: &mut SidebarComponent) {
902 for _ in 0..50 {
903 if let Some(list) = &mut sidebar.list {
904 list.poll();
905 if !list.is_loading() {
906 break;
907 }
908 }
909 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
910 }
911 if let Some(list) = &mut sidebar.list {
912 list.poll();
913 }
914 }
915
916 fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
918 sidebar
919 .list
920 .as_ref()
921 .unwrap()
922 .visible_rows()
923 .iter()
924 .filter_map(|e| match e {
925 FileListEntry::Note { filename, .. } => Some(filename.clone()),
926 _ => None,
927 })
928 .collect()
929 }
930
931 #[tokio::test(flavor = "multi_thread")]
932 async fn apply_sort_reverse_flips_listing_order() {
933 let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
934 let (tx, _rx) = unbounded_channel();
935 navigate_to_root(&mut sidebar, &tx).await;
936 let before = note_names(&sidebar);
937 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
938 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
939 poll_to_idle(&mut sidebar).await;
940 let after = note_names(&sidebar);
941 assert_eq!(
942 after,
943 before.iter().rev().cloned().collect::<Vec<_>>(),
944 "descending order should reverse the listing"
945 );
946 }
947
948 #[tokio::test(flavor = "multi_thread")]
949 async fn apply_sort_changes_field() {
950 let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
951 let (tx, _rx) = unbounded_channel();
952 navigate_to_root(&mut sidebar, &tx).await;
953 sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
954 poll_to_idle(&mut sidebar).await;
955 assert_eq!(sidebar.current_sort().0, SortField::Title);
956 assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
957 }
958
959 async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
961 let vault = temp_vault(prefix).await;
962 vault.validate_and_init().await.unwrap();
963 vault
964 .create_note(&VaultPath::note_path_from("alpha"), "body")
965 .await
966 .unwrap();
967 vault
968 .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
969 .await
970 .unwrap();
971 let settings = AppSettings::default();
972 SidebarComponent::new(
973 settings.key_bindings.clone(),
974 vault,
975 settings.icons(),
976 &settings,
977 )
978 }
979
980 fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
982 sidebar
983 .list
984 .as_ref()
985 .unwrap()
986 .visible_rows()
987 .iter()
988 .filter_map(|e| match e {
989 FileListEntry::Note { .. } => Some("note"),
990 FileListEntry::Directory { .. } => Some("dir"),
991 _ => None,
992 })
993 .collect()
994 }
995
996 #[tokio::test(flavor = "multi_thread")]
997 async fn group_dirs_puts_directories_first() {
998 let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
999 let (tx, _rx) = unbounded_channel();
1000 navigate_to_root(&mut sidebar, &tx).await;
1001 assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
1002 sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
1003 poll_to_idle(&mut sidebar).await;
1004 assert_eq!(
1005 row_kinds(&sidebar),
1006 vec!["dir", "note"],
1007 "grouping must cluster directories first"
1008 );
1009 }
1010
1011 #[tokio::test(flavor = "multi_thread")]
1012 async fn apply_sort_updates_shared_state() {
1013 let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
1014 let (tx, _rx) = unbounded_channel();
1015 navigate_to_root(&mut sidebar, &tx).await;
1016 sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1017 poll_to_idle(&mut sidebar).await;
1018 assert_eq!(
1019 sidebar.current_sort(),
1020 (SortField::Title, SortOrder::Descending)
1021 );
1022 assert!(!sidebar.group_dirs());
1023 }
1024
1025 #[tokio::test(flavor = "multi_thread")]
1026 async fn set_open_note_stamps_matching_row() {
1027 let mut sb = sidebar_with_notes("sb-open", &["alpha", "beta"]).await;
1028 let (tx, _rx) = unbounded_channel();
1029 navigate_to_root(&mut sb, &tx).await;
1030
1031 sb.set_open_note(Some(VaultPath::note_path_from("alpha")));
1032 assert!(
1033 sb.note_row_is_open_for_test("alpha.md"),
1034 "open note is marked"
1035 );
1036 assert!(
1037 !sb.note_row_is_open_for_test("beta.md"),
1038 "other note is not marked"
1039 );
1040
1041 sb.set_open_note(Some(VaultPath::note_path_from("beta")));
1042 assert!(!sb.note_row_is_open_for_test("alpha.md"));
1043 assert!(sb.note_row_is_open_for_test("beta.md"));
1044
1045 sb.set_open_note(None);
1046 assert!(!sb.note_row_is_open_for_test("beta.md"));
1047 }
1048
1049 #[tokio::test(flavor = "multi_thread")]
1050 async fn update_note_row_changes_title_in_place() {
1051 let mut sb = sidebar_with_notes("sb-title", &["alpha"]).await;
1052 let (tx, _rx) = unbounded_channel();
1053 navigate_to_root(&mut sb, &tx).await;
1054
1055 sb.update_note_row(&VaultPath::note_path_from("alpha"), "Fresh Title");
1056 assert_eq!(
1057 sb.note_row_title_for_test("alpha.md").as_deref(),
1058 Some("Fresh Title")
1059 );
1060 }
1061
1062 #[tokio::test(flavor = "multi_thread")]
1063 async fn rename_note_row_updates_path_and_filename() {
1064 let mut sb = sidebar_with_notes("sb-rename", &["alpha"]).await;
1065 let (tx, _rx) = unbounded_channel();
1066 navigate_to_root(&mut sb, &tx).await;
1067
1068 let to = VaultPath::note_path_from("gamma");
1069 let expected_filename = to.get_parent_path().1;
1070 sb.rename_note_row(&VaultPath::note_path_from("alpha"), &to);
1071 assert!(
1072 sb.note_row_title_for_test("gamma.md").is_some(),
1073 "row now at new name"
1074 );
1075 assert!(
1076 sb.note_row_title_for_test("alpha.md").is_none(),
1077 "old name gone"
1078 );
1079 let renamed_filename = sb
1081 .list
1082 .as_ref()
1083 .unwrap()
1084 .rows()
1085 .iter()
1086 .find_map(|r| match r {
1087 FileListEntry::Note { path, filename, .. } if path.is_like(&to) => {
1088 Some(filename.clone())
1089 }
1090 _ => None,
1091 });
1092 assert_eq!(
1093 renamed_filename.as_deref(),
1094 Some(expected_filename.as_str()),
1095 "filename field must be updated to the new name"
1096 );
1097 }
1098
1099 #[tokio::test(flavor = "multi_thread")]
1102 async fn rename_note_row_clears_journal_date_when_renamed_away_from_date_name() {
1103 let vault = crate::test_support::temp_vault("sb-jdate").await;
1106 vault.validate_and_init().await.unwrap();
1107 let journal_path = vault.journal_path().clone();
1108 let date_name = "2026-06-09";
1109 let from = journal_path
1110 .append(&VaultPath::note_path_from(date_name))
1111 .absolute();
1112 vault.create_note(&from, "journal body").await.unwrap();
1113
1114 let settings = AppSettings::default();
1115 let mut sb = SidebarComponent::new(
1116 settings.key_bindings.clone(),
1117 vault,
1118 settings.icons(),
1119 &settings,
1120 );
1121 let (tx, _rx) = unbounded_channel();
1122 sb.navigate(journal_path.clone(), &tx);
1124 for _ in 0..50 {
1125 sb.poll_for_test();
1126 if !sb.is_loading_for_test() {
1127 break;
1128 }
1129 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1130 }
1131 sb.poll_for_test();
1132
1133 assert!(
1135 sb.note_row_journal_date_for_test(&from).is_some(),
1136 "journal note must have a journal_date before rename"
1137 );
1138
1139 let to = journal_path
1141 .append(&VaultPath::note_path_from("meeting"))
1142 .absolute();
1143 sb.rename_note_row(&from, &to);
1144
1145 assert_eq!(
1147 sb.note_row_journal_date_for_test(&to),
1148 None,
1149 "journal_date must be cleared after renaming to a non-date name"
1150 );
1151 }
1152
1153 #[tokio::test(flavor = "multi_thread")]
1158 async fn save_default_survives_navigation() {
1159 let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
1160 let (tx, _rx) = unbounded_channel();
1161 navigate_to_root(&mut sidebar, &tx).await;
1162
1163 sidebar.save_default(SortField::Title, SortOrder::Descending, false);
1164 poll_to_idle(&mut sidebar).await;
1165
1166 sidebar.navigate(VaultPath::root(), &tx);
1169 poll_to_idle(&mut sidebar).await;
1170 assert_eq!(
1171 sidebar.current_sort(),
1172 (SortField::Title, SortOrder::Descending),
1173 "saved default must persist across navigation"
1174 );
1175 }
1176}