1use std::sync::Arc;
2
3use crate::settings::themes::Theme;
4use async_trait::async_trait;
5use chrono::NaiveDate;
6use futures::StreamExt;
7use kimun_core::nfs::VaultPath;
8use kimun_core::{NoteVault, NotesValidation, ResultType, VaultBrowseOptionsBuilder};
9use ratatui::Frame;
10use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
11use ratatui::style::Style;
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Borders, Paragraph};
14
15use crate::components::Component;
16use crate::components::event_state::EventState;
17use crate::components::events::{AppEvent, AppTx, AppTxExt, FileOp, InputEvent, redraw_callback};
18use crate::components::file_list::{FileListEntry, SortField, SortOrder};
19use crate::components::search_list::{
20 Emit, Filter, KeyReaction, OrderFn, RowSource, SearchList, SearchMouse,
21};
22use crate::keys::KeyBindings;
23use crate::settings::AppSettings;
24use crate::settings::icons::Icons;
25
26struct DirListingSource {
34 vault: Arc<NoteVault>,
35 dir: VaultPath,
36}
37
38fn listing_order(field: SortField, order: SortOrder, group_dirs: bool) -> OrderFn<FileListEntry> {
42 Arc::new(move |a: &FileListEntry, b: &FileListEntry| {
43 let rank = |e: &FileListEntry| match e {
44 FileListEntry::Up { .. } => 0,
45 FileListEntry::Directory { .. } if group_dirs => 1,
46 _ => 2,
47 };
48 rank(a).cmp(&rank(b)).then_with(|| {
49 let ka = a.sort_key(field);
50 let kb = b.sort_key(field);
51 match order {
52 SortOrder::Ascending => ka.cmp(&kb),
53 SortOrder::Descending => kb.cmp(&ka),
54 }
55 })
56 })
57}
58
59#[async_trait]
60impl RowSource<FileListEntry> for DirListingSource {
61 async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
62 if !self.dir.is_root_or_empty() {
64 emit.push(FileListEntry::Up {
65 parent: self.dir.get_parent_path().0,
66 });
67 }
68
69 let options = VaultBrowseOptionsBuilder::new(&self.dir)
70 .recursive(false)
71 .validation(NotesValidation::Full)
72 .build();
73
74 let mut stream = std::pin::pin!(self.vault.browse_vault_stream(options));
75 while let Some(item) = stream.next().await {
76 let result = match item {
77 Ok(result) => result,
78 Err(e) => {
79 tracing::warn!("sidebar directory listing failed: {e}");
80 break;
81 }
82 };
83 if matches!(result.rtype, ResultType::Directory) && result.path.is_like(&self.dir) {
86 continue;
87 }
88 let journal_date = self
89 .vault
90 .journal_date(&result.path)
91 .map(format_journal_date);
92 emit.push(FileListEntry::from_result(result, journal_date));
93 }
94 emit.done();
95 }
96
97 fn leading_row(&self, query: &str) -> Option<FileListEntry> {
98 if query.is_empty() {
99 None
100 } else {
101 let path = self.dir.append(&VaultPath::note_path_from(query)).flatten();
102 Some(FileListEntry::CreateNote {
103 filename: path.to_string(),
104 path,
105 })
106 }
107 }
108
109 fn reload_on_query(&self) -> bool {
110 false
113 }
114}
115
116pub struct SidebarComponent {
117 current_dir: VaultPath,
118 open_note: Option<VaultPath>,
122 list: Option<SearchList<FileListEntry>>,
123 vault: Arc<NoteVault>,
124 icons: Icons,
125 default_sort_field: SortField,
126 default_sort_order: SortOrder,
127 journal_sort_field: SortField,
128 journal_sort_order: SortOrder,
129 sort: (SortField, SortOrder),
133 group_dirs: bool,
135 rendered_rect: Rect,
136 breadcrumb_cells: Vec<(Rect, VaultPath)>,
139 key_bindings: KeyBindings,
140}
141
142impl SidebarComponent {
143 pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
147 Self::new(
148 settings.key_bindings.clone(),
149 vault,
150 settings.icons(),
151 settings,
152 )
153 }
154
155 pub fn new(
156 key_bindings: KeyBindings,
157 vault: Arc<NoteVault>,
158 icons: Icons,
159 settings: &AppSettings,
160 ) -> Self {
161 let default_sort_field = SortField::from(settings.default_sort_field);
162 let default_sort_order = SortOrder::from(settings.default_sort_order);
163 Self {
164 current_dir: VaultPath::root(),
165 open_note: None,
166 list: None,
167 vault,
168 icons,
169 default_sort_field,
170 default_sort_order,
171 journal_sort_field: SortField::from(settings.journal_sort_field),
172 journal_sort_order: SortOrder::from(settings.journal_sort_order),
173 sort: (default_sort_field, default_sort_order),
174 group_dirs: settings.group_directories,
175 rendered_rect: Rect::default(),
176 breadcrumb_cells: Vec::new(),
177 key_bindings,
178 }
179 }
180
181 fn breadcrumb_at(&self, column: u16, row: u16) -> Option<&VaultPath> {
183 self.breadcrumb_cells
184 .iter()
185 .find(|(rect, _)| rect.contains(Position { x: column, y: row }))
186 .map(|(_, dir)| dir)
187 }
188
189 pub fn current_dir(&self) -> &VaultPath {
190 &self.current_dir
191 }
192
193 pub fn is_empty(&self) -> bool {
196 self.list.is_none()
197 }
198
199 fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
201 if dir.is_like(self.vault.journal_path()) {
202 (self.journal_sort_field, self.journal_sort_order)
203 } else {
204 (self.default_sort_field, self.default_sort_order)
205 }
206 }
207
208 pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
212 self.current_dir = dir.clone();
213 let (sort_field, sort_order) = self.sort_for(&dir);
214 self.sort = (sort_field, sort_order);
215 let source = DirListingSource {
216 vault: self.vault.clone(),
217 dir,
218 };
219 self.list = Some(
220 SearchList::builder(source, redraw_callback(tx.clone()))
221 .filter(Filter::Fuzzy)
222 .order_by(listing_order(sort_field, sort_order, self.group_dirs))
223 .yank_combos_from(&self.key_bindings)
224 .icons(self.icons.clone())
225 .build(),
226 );
227 }
228
229 pub fn refresh_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
234 if dir.is_like(&self.current_dir) {
235 self.navigate(self.current_dir.clone(), tx);
236 }
237 }
238
239 pub fn set_open_note(&mut self, path: Option<VaultPath>) {
243 self.open_note = path;
244 self.stamp_open_marker();
245 }
246
247 fn stamp_open_marker(&mut self) {
251 let open = self.open_note.clone();
252 if let Some(list) = &mut self.list {
253 list.update_rows(|row| {
254 if let FileListEntry::Note { path, is_open, .. } = row {
255 let want = open.as_ref().is_some_and(|o| path.is_like(o));
256 if *is_open != want {
257 *is_open = want;
258 return true;
259 }
260 }
261 false
262 });
263 }
264 }
265
266 pub fn update_note_row(&mut self, path: &VaultPath, new_title: &str) {
270 if let Some(list) = &mut self.list {
271 list.update_rows(|row| {
272 if let FileListEntry::Note {
273 path: row_path,
274 title,
275 ..
276 } = row
277 && row_path.is_like(path)
278 && title != new_title
279 {
280 *title = new_title.to_string();
281 return true;
282 }
283 false
284 });
285 }
286 }
287
288 pub fn rename_note_row(&mut self, from: &VaultPath, to: &VaultPath) {
296 let new_filename = to.get_parent_path().1;
297 let new_journal_date = self.vault.journal_date(to).map(format_journal_date);
298 if let Some(list) = &mut self.list {
299 list.update_rows(|row| {
300 if let FileListEntry::Note {
301 path,
302 filename,
303 journal_date,
304 ..
305 } = row
306 && path.is_like(from)
307 {
308 *path = to.clone();
309 *filename = new_filename.clone();
310 *journal_date = new_journal_date.clone();
311 return true;
312 }
313 false
314 });
315 }
316 }
317
318 pub fn set_current_dir(&mut self, dir: VaultPath) {
322 self.current_dir = dir;
323 }
324
325 pub fn current_sort(&self) -> (SortField, SortOrder) {
327 self.sort
328 }
329
330 pub fn group_dirs(&self) -> bool {
332 self.group_dirs
333 }
334
335 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
338 self.sort = (field, order);
339 self.group_dirs = group_dirs;
340 if let Some(list) = &mut self.list {
341 list.set_order(Some(listing_order(field, order, group_dirs)));
342 }
343 }
344
345 pub fn is_current_journal(&self) -> bool {
348 self.current_dir.is_like(self.vault.journal_path())
349 }
350
351 pub fn save_default(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
357 if self.is_current_journal() {
358 self.journal_sort_field = field;
359 self.journal_sort_order = order;
360 } else {
361 self.default_sort_field = field;
362 self.default_sort_order = order;
363 }
364 self.apply_sort(field, order, group_dirs);
365 }
366
367 fn note_count(&self) -> usize {
369 match &self.list {
370 None => 0,
371 Some(list) => list
372 .visible_rows()
373 .iter()
374 .filter(|e| matches!(e, FileListEntry::Note { .. }))
375 .count(),
376 }
377 }
378
379 fn activate_selected_entry(&self, tx: &AppTx) {
383 let Some(list) = &self.list else { return };
384 let Some(entry) = list.selected_row() else {
385 return;
386 };
387 match entry {
388 FileListEntry::CreateNote { path, .. } => {
389 let path = path.clone();
390 let vault = Arc::clone(&self.vault);
391 let tx2 = tx.clone();
392 tokio::spawn(async move {
393 match vault.load_or_create_note(&path, None).await {
394 Ok((_, created)) => tx2.announce_and_open(path, created),
395 Err(e) => {
396 tracing::warn!("create note failed for {path}: {e}");
397 }
398 }
399 });
400 }
401 FileListEntry::Attachment { path, .. } => {
402 tx.send(AppEvent::OpenAttachment(path.clone())).ok();
403 }
404 other => {
405 tx.send(AppEvent::open(other.path().clone())).ok();
406 }
407 }
408 }
409}
410
411fn format_journal_date(date: NaiveDate) -> String {
414 date.format("%A, %B %-d, %Y").to_string()
415}
416
417impl Component for SidebarComponent {
418 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
419 if let InputEvent::Mouse(mouse) = event {
420 let pos = Position {
421 x: mouse.column,
422 y: mouse.row,
423 };
424 if !self.rendered_rect.contains(pos) {
425 return EventState::NotConsumed;
426 }
427 if matches!(
429 mouse.kind,
430 ratatui::crossterm::event::MouseEventKind::Down(
431 ratatui::crossterm::event::MouseButton::Left
432 )
433 ) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
434 {
435 tx.send(AppEvent::open(dir.clone())).ok();
436 return EventState::Consumed;
437 }
438 if let Some(list) = &mut self.list {
444 match list.handle_mouse(mouse) {
445 SearchMouse::Activated(_) => self.activate_selected_entry(tx),
446 SearchMouse::Context(_) => {
448 if let Some(entry) = list.selected_row()
449 && !matches!(
450 entry,
451 FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
452 )
453 {
454 tx.send(AppEvent::FileOp(FileOp::ShowMenu(entry.path().clone())))
455 .ok();
456 }
457 }
458 SearchMouse::Selected(_)
461 | SearchMouse::Scrolled
462 | SearchMouse::ContentScrollUp
463 | SearchMouse::ContentScrollDown
464 | SearchMouse::None => {}
465 }
466 }
467 return EventState::Consumed;
468 }
469
470 if let InputEvent::Key(key) = event {
471 if self.list.is_none() {
472 return EventState::NotConsumed;
473 }
474 let reaction = self.list.as_mut().unwrap().handle_key(key);
475 match reaction {
476 KeyReaction::Submit => {
477 self.activate_selected_entry(tx);
478 EventState::Consumed
479 }
480 KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
481 KeyReaction::Yank(target) => {
482 crate::components::yank_row(target, tx);
483 EventState::Consumed
484 }
485 KeyReaction::Intercepted(_) | KeyReaction::ListVerb(_) | KeyReaction::Unhandled => {
486 EventState::NotConsumed
487 }
488 }
489 } else {
490 EventState::NotConsumed
491 }
492 }
493
494 fn hint_shortcuts(&self) -> Vec<(String, String)> {
495 use crate::keys::action_shortcuts::ActionShortcuts;
496
497 crate::components::hints::hints_for(
498 &self.key_bindings,
499 &[
500 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
501 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
502 (ActionShortcuts::OpenSortDialog, "sort"),
503 ],
504 )
505 }
506
507 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
508 self.rendered_rect = rect;
509
510 let rows = Layout::default()
511 .direction(Direction::Vertical)
512 .constraints([
513 Constraint::Length(3),
514 Constraint::Length(3),
515 Constraint::Min(0),
516 ])
517 .split(rect);
518
519 let border_style = theme.border_style(focused);
520
521 let header = Block::default()
522 .title(format!("─ Files · {} ", self.current_dir))
523 .borders(Borders::ALL)
524 .border_style(border_style)
525 .style(theme.panel_style());
526 let header_inner = header.inner(rows[0]);
527 f.render_widget(header, rows[0]);
528
529 self.breadcrumb_cells.clear();
533 let seg_style = Style::default()
534 .fg(theme.fg_secondary.to_ratatui())
535 .bg(theme.bg_panel.to_ratatui());
536 let sep_style = Style::default()
537 .fg(theme.gray.to_ratatui())
538 .bg(theme.bg_panel.to_ratatui());
539 let mut spans: Vec<Span> = Vec::new();
540 let mut x = header_inner.x;
541 let mut push_segment =
542 |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
543 let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
544 if *x < header_inner.right() {
548 let visible = w.min(header_inner.right() - *x);
549 self.breadcrumb_cells
550 .push((Rect::new(*x, header_inner.y, visible, 1), dir));
551 }
552 spans.push(Span::styled(label, seg_style));
553 *x += w;
554 };
555 push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
556 let slices = self.current_dir.get_slices();
557 let mut acc = String::new();
558 for slice in &slices {
559 spans.push(Span::styled(" / ", sep_style));
560 x += 3;
561 acc.push('/');
562 acc.push_str(slice);
563 push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
564 }
565 let count = format!("{} notes", self.note_count());
566 let used: u16 = x - header_inner.x;
567 let pad = header_inner
568 .width
569 .saturating_sub(used)
570 .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
571 spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
572 spans.push(Span::styled(count, sep_style));
573 f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
574
575 let search_block = Block::default()
576 .title(" Search")
577 .borders(Borders::ALL)
578 .border_style(border_style)
579 .style(theme.panel_style());
580 let search_inner = search_block.inner(rows[1]);
581 f.render_widget(search_block, rows[1]);
582
583 let list_block = Block::default()
584 .borders(Borders::ALL)
585 .border_style(border_style)
586 .style(theme.panel_style());
587 let list_inner = list_block.inner(rows[2]);
588 f.render_widget(list_block, rows[2]);
589
590 if let Some(list) = &mut self.list {
594 list.poll();
595 }
596 self.stamp_open_marker();
597 if let Some(list) = &mut self.list {
598 list.render_query(f, search_inner, theme, focused);
599 list.render(f, list_inner, theme, focused);
600 list.set_list_rect(list_inner);
605 list.set_panel_rect(rect);
606 }
607 }
608}
609
610#[cfg(test)]
611impl SidebarComponent {
612 pub(crate) fn poll_for_test(&mut self) {
613 if let Some(list) = &mut self.list {
614 list.poll();
615 }
616 self.stamp_open_marker();
617 }
618
619 pub(crate) fn is_loading_for_test(&self) -> bool {
620 self.list.as_ref().is_some_and(|l| l.is_loading())
621 }
622
623 pub(crate) fn note_row_is_open_for_test(&self, name: &str) -> bool {
624 self.list.as_ref().is_some_and(|l| {
625 l.rows().iter().any(|r| {
626 matches!(r, FileListEntry::Note { path, is_open, .. }
627 if path.get_name() == name && *is_open)
628 })
629 })
630 }
631
632 pub(crate) fn note_row_title_for_test(&self, name: &str) -> Option<String> {
633 self.list.as_ref().and_then(|l| {
634 l.rows().iter().find_map(|r| match r {
635 FileListEntry::Note { path, title, .. } if path.get_name() == name => {
636 Some(title.clone())
637 }
638 _ => None,
639 })
640 })
641 }
642
643 pub(crate) fn note_row_journal_date_for_test(&self, path: &VaultPath) -> Option<String> {
644 self.list.as_ref().and_then(|l| {
645 l.rows().iter().find_map(|r| match r {
646 FileListEntry::Note {
647 path: row_path,
648 journal_date,
649 ..
650 } if row_path.is_like(path) => journal_date.clone(),
651 _ => None,
652 })
653 })
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use crate::settings::AppSettings;
661 use crate::test_support::{mouse_down_at, temp_vault};
662 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
663 use tokio::sync::mpsc::unbounded_channel;
664
665 async fn make_sidebar() -> SidebarComponent {
666 let vault = temp_vault("sidebar").await;
667 vault.validate_and_init().await.unwrap();
668 let settings = AppSettings::default();
669 SidebarComponent::new(
670 settings.key_bindings.clone(),
671 vault,
672 settings.icons(),
673 &settings,
674 )
675 }
676
677 async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
679 let vault = temp_vault(prefix).await;
680 vault.validate_and_init().await.unwrap();
681 for name in names {
682 vault
683 .create_note(&VaultPath::note_path_from(name), "body")
684 .await
685 .unwrap();
686 }
687 let settings = AppSettings::default();
688 SidebarComponent::new(
689 settings.key_bindings.clone(),
690 vault,
691 settings.icons(),
692 &settings,
693 )
694 }
695
696 #[tokio::test]
700 async fn mouse_down_in_sidebar_bounds_is_consumed() {
701 let mut sidebar = make_sidebar().await;
702 sidebar.rendered_rect = Rect {
703 x: 0,
704 y: 3,
705 width: 30,
706 height: 20,
707 };
708 let (tx, _rx) = unbounded_channel();
709
710 assert_eq!(
712 sidebar.handle_input(&mouse_down_at(5, 4), &tx),
713 EventState::Consumed
714 );
715 assert_eq!(
717 sidebar.handle_input(&mouse_down_at(5, 7), &tx),
718 EventState::Consumed
719 );
720 assert_eq!(
722 sidebar.handle_input(&mouse_down_at(40, 7), &tx),
723 EventState::NotConsumed
724 );
725 }
726
727 fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
728 InputEvent::Mouse(MouseEvent {
729 kind,
730 column: col,
731 row,
732 modifiers: KeyModifiers::NONE,
733 })
734 }
735
736 async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
739 sidebar.navigate(VaultPath::root(), tx);
740 for _ in 0..50 {
743 if let Some(list) = &mut sidebar.list {
744 list.poll();
745 if !list.is_loading() {
746 break;
747 }
748 }
749 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
750 }
751 if let Some(list) = &mut sidebar.list {
752 list.poll();
753 }
754 }
755
756 #[tokio::test(flavor = "multi_thread")]
759 async fn mouse_double_click_on_list_row_sends_open_path() {
760 let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
761 let (tx, mut rx) = unbounded_channel();
762 navigate_to_root(&mut sidebar, &tx).await;
763
764 sidebar.rendered_rect = Rect {
765 x: 0,
766 y: 3,
767 width: 30,
768 height: 20,
769 };
770 if let Some(list) = &mut sidebar.list {
773 list.set_list_rect(Rect {
774 x: 0,
775 y: 9,
776 width: 30,
777 height: 14,
778 });
779 }
780
781 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
783
784 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
786 let mut events = Vec::new();
787 while let Ok(evt) = rx.try_recv() {
788 events.push(evt);
789 }
790 assert!(
791 events
792 .iter()
793 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
794 "expected OpenPath for the activated note, got {events:?}"
795 );
796 }
797
798 #[tokio::test(flavor = "multi_thread")]
803 async fn scroll_down_in_sidebar_bounds_scrolls_list() {
804 let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
805 let (tx, _rx) = unbounded_channel();
806 navigate_to_root(&mut sidebar, &tx).await;
807
808 sidebar.rendered_rect = Rect {
809 x: 0,
810 y: 3,
811 width: 30,
812 height: 20,
813 };
814 if let Some(list) = &mut sidebar.list {
818 list.set_list_rect(Rect {
819 x: 0,
820 y: 9,
821 width: 30,
822 height: 1,
823 });
824 list.set_panel_rect(Rect {
825 x: 0,
826 y: 3,
827 width: 30,
828 height: 20,
829 });
830 }
831
832 let first = sidebar
833 .list
834 .as_ref()
835 .unwrap()
836 .selected_row()
837 .map(|e| e.path().to_string());
838
839 let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
841 assert_eq!(result, EventState::Consumed);
842 let after = sidebar
843 .list
844 .as_ref()
845 .unwrap()
846 .selected_row()
847 .map(|e| e.path().to_string());
848 assert_ne!(
849 first, after,
850 "scroll-from-header should scroll the list, carrying the selection"
851 );
852 }
853
854 #[tokio::test]
855 async fn mouse_down_outside_sidebar_is_not_consumed() {
856 let mut sidebar = make_sidebar().await;
857 sidebar.rendered_rect = Rect {
858 x: 0,
859 y: 3,
860 width: 30,
861 height: 20,
862 };
863 let (tx, mut rx) = unbounded_channel();
864
865 let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
867 assert_eq!(result, EventState::NotConsumed);
868 assert!(rx.try_recv().is_err());
869 }
870
871 #[tokio::test(flavor = "multi_thread")]
873 async fn navigate_loads_directory_notes() {
874 let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
875 assert!(sidebar.is_empty());
876 let (tx, _rx) = unbounded_channel();
877 navigate_to_root(&mut sidebar, &tx).await;
878 assert!(!sidebar.is_empty());
879 assert_eq!(sidebar.note_count(), 1);
880 }
881
882 async fn poll_to_idle(sidebar: &mut SidebarComponent) {
885 for _ in 0..50 {
886 if let Some(list) = &mut sidebar.list {
887 list.poll();
888 if !list.is_loading() {
889 break;
890 }
891 }
892 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
893 }
894 if let Some(list) = &mut sidebar.list {
895 list.poll();
896 }
897 }
898
899 fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
901 sidebar
902 .list
903 .as_ref()
904 .unwrap()
905 .visible_rows()
906 .iter()
907 .filter_map(|e| match e {
908 FileListEntry::Note { filename, .. } => Some(filename.clone()),
909 _ => None,
910 })
911 .collect()
912 }
913
914 #[tokio::test(flavor = "multi_thread")]
918 async fn listing_streams_each_row_then_done() {
919 use crate::components::search_list::Loaded;
920
921 let sidebar = sidebar_with_notes("sidebar-stream", &["charlie", "alpha", "bravo"]).await;
922 let source = DirListingSource {
923 vault: sidebar.vault.clone(),
924 dir: VaultPath::root(),
925 };
926 let (emit, rx) = Emit::capture();
927
928 source.load("", emit).await;
929
930 let events: Vec<Loaded<FileListEntry>> = rx.try_iter().map(|(_, ev)| ev).collect();
931 let pushed_notes = events
932 .iter()
933 .filter(|e| matches!(e, Loaded::Push(FileListEntry::Note { .. })))
934 .count();
935 assert_eq!(pushed_notes, 3, "each note is streamed as its own row");
936 assert!(
937 !events.iter().any(|e| matches!(e, Loaded::Replace(_))),
938 "no whole-set re-delivery"
939 );
940 assert!(
941 matches!(events.last(), Some(Loaded::Done)),
942 "done closes the load"
943 );
944 }
945
946 #[tokio::test(flavor = "multi_thread")]
949 async fn apply_sort_reorders_without_reloading() {
950 let mut sidebar =
951 sidebar_with_notes("sidebar-resort", &["alpha", "bravo", "charlie"]).await;
952 let (tx, _rx) = unbounded_channel();
953 navigate_to_root(&mut sidebar, &tx).await;
954 let before = note_names(&sidebar);
955 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
956
957 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
958
959 let list = sidebar.list.as_ref().unwrap();
960 assert!(
961 !list.is_loading(),
962 "a sort change must not reload the listing"
963 );
964 assert_eq!(
965 note_names(&sidebar),
966 before.iter().rev().cloned().collect::<Vec<_>>(),
967 "rows re-ordered in place"
968 );
969 }
970
971 #[tokio::test(flavor = "multi_thread")]
972 async fn apply_sort_reverse_flips_listing_order() {
973 let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
974 let (tx, _rx) = unbounded_channel();
975 navigate_to_root(&mut sidebar, &tx).await;
976 let before = note_names(&sidebar);
977 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
978 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
979 poll_to_idle(&mut sidebar).await;
980 let after = note_names(&sidebar);
981 assert_eq!(
982 after,
983 before.iter().rev().cloned().collect::<Vec<_>>(),
984 "descending order should reverse the listing"
985 );
986 }
987
988 #[tokio::test(flavor = "multi_thread")]
989 async fn apply_sort_changes_field() {
990 let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
991 let (tx, _rx) = unbounded_channel();
992 navigate_to_root(&mut sidebar, &tx).await;
993 sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
994 poll_to_idle(&mut sidebar).await;
995 assert_eq!(sidebar.current_sort().0, SortField::Title);
996 assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
997 }
998
999 async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
1001 let vault = temp_vault(prefix).await;
1002 vault.validate_and_init().await.unwrap();
1003 vault
1004 .create_note(&VaultPath::note_path_from("alpha"), "body")
1005 .await
1006 .unwrap();
1007 vault
1008 .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
1009 .await
1010 .unwrap();
1011 let settings = AppSettings::default();
1012 SidebarComponent::new(
1013 settings.key_bindings.clone(),
1014 vault,
1015 settings.icons(),
1016 &settings,
1017 )
1018 }
1019
1020 fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
1022 sidebar
1023 .list
1024 .as_ref()
1025 .unwrap()
1026 .visible_rows()
1027 .iter()
1028 .filter_map(|e| match e {
1029 FileListEntry::Note { .. } => Some("note"),
1030 FileListEntry::Directory { .. } => Some("dir"),
1031 _ => None,
1032 })
1033 .collect()
1034 }
1035
1036 #[tokio::test(flavor = "multi_thread")]
1037 async fn group_dirs_puts_directories_first() {
1038 let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
1039 let (tx, _rx) = unbounded_channel();
1040 navigate_to_root(&mut sidebar, &tx).await;
1041 assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
1042 sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
1043 poll_to_idle(&mut sidebar).await;
1044 assert_eq!(
1045 row_kinds(&sidebar),
1046 vec!["dir", "note"],
1047 "grouping must cluster directories first"
1048 );
1049 }
1050
1051 #[tokio::test(flavor = "multi_thread")]
1052 async fn apply_sort_updates_shared_state() {
1053 let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
1054 let (tx, _rx) = unbounded_channel();
1055 navigate_to_root(&mut sidebar, &tx).await;
1056 sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1057 poll_to_idle(&mut sidebar).await;
1058 assert_eq!(
1059 sidebar.current_sort(),
1060 (SortField::Title, SortOrder::Descending)
1061 );
1062 assert!(!sidebar.group_dirs());
1063 }
1064
1065 #[tokio::test(flavor = "multi_thread")]
1066 async fn set_open_note_stamps_matching_row() {
1067 let mut sb = sidebar_with_notes("sb-open", &["alpha", "beta"]).await;
1068 let (tx, _rx) = unbounded_channel();
1069 navigate_to_root(&mut sb, &tx).await;
1070
1071 sb.set_open_note(Some(VaultPath::note_path_from("alpha")));
1072 assert!(
1073 sb.note_row_is_open_for_test("alpha.md"),
1074 "open note is marked"
1075 );
1076 assert!(
1077 !sb.note_row_is_open_for_test("beta.md"),
1078 "other note is not marked"
1079 );
1080
1081 sb.set_open_note(Some(VaultPath::note_path_from("beta")));
1082 assert!(!sb.note_row_is_open_for_test("alpha.md"));
1083 assert!(sb.note_row_is_open_for_test("beta.md"));
1084
1085 sb.set_open_note(None);
1086 assert!(!sb.note_row_is_open_for_test("beta.md"));
1087 }
1088
1089 #[tokio::test(flavor = "multi_thread")]
1090 async fn update_note_row_changes_title_in_place() {
1091 let mut sb = sidebar_with_notes("sb-title", &["alpha"]).await;
1092 let (tx, _rx) = unbounded_channel();
1093 navigate_to_root(&mut sb, &tx).await;
1094
1095 sb.update_note_row(&VaultPath::note_path_from("alpha"), "Fresh Title");
1096 assert_eq!(
1097 sb.note_row_title_for_test("alpha.md").as_deref(),
1098 Some("Fresh Title")
1099 );
1100 }
1101
1102 #[tokio::test(flavor = "multi_thread")]
1103 async fn rename_note_row_updates_path_and_filename() {
1104 let mut sb = sidebar_with_notes("sb-rename", &["alpha"]).await;
1105 let (tx, _rx) = unbounded_channel();
1106 navigate_to_root(&mut sb, &tx).await;
1107
1108 let to = VaultPath::note_path_from("gamma");
1109 let expected_filename = to.get_parent_path().1;
1110 sb.rename_note_row(&VaultPath::note_path_from("alpha"), &to);
1111 assert!(
1112 sb.note_row_title_for_test("gamma.md").is_some(),
1113 "row now at new name"
1114 );
1115 assert!(
1116 sb.note_row_title_for_test("alpha.md").is_none(),
1117 "old name gone"
1118 );
1119 let renamed_filename = sb
1121 .list
1122 .as_ref()
1123 .unwrap()
1124 .rows()
1125 .iter()
1126 .find_map(|r| match r {
1127 FileListEntry::Note { path, filename, .. } if path.is_like(&to) => {
1128 Some(filename.clone())
1129 }
1130 _ => None,
1131 });
1132 assert_eq!(
1133 renamed_filename.as_deref(),
1134 Some(expected_filename.as_str()),
1135 "filename field must be updated to the new name"
1136 );
1137 }
1138
1139 #[tokio::test(flavor = "multi_thread")]
1142 async fn rename_note_row_clears_journal_date_when_renamed_away_from_date_name() {
1143 let vault = crate::test_support::temp_vault("sb-jdate").await;
1146 vault.validate_and_init().await.unwrap();
1147 let journal_path = vault.journal_path().clone();
1148 let date_name = "2026-06-09";
1149 let from = journal_path
1150 .append(&VaultPath::note_path_from(date_name))
1151 .absolute();
1152 vault.create_note(&from, "journal body").await.unwrap();
1153
1154 let settings = AppSettings::default();
1155 let mut sb = SidebarComponent::new(
1156 settings.key_bindings.clone(),
1157 vault,
1158 settings.icons(),
1159 &settings,
1160 );
1161 let (tx, _rx) = unbounded_channel();
1162 sb.navigate(journal_path.clone(), &tx);
1164 for _ in 0..50 {
1165 sb.poll_for_test();
1166 if !sb.is_loading_for_test() {
1167 break;
1168 }
1169 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1170 }
1171 sb.poll_for_test();
1172
1173 assert!(
1175 sb.note_row_journal_date_for_test(&from).is_some(),
1176 "journal note must have a journal_date before rename"
1177 );
1178
1179 let to = journal_path
1181 .append(&VaultPath::note_path_from("meeting"))
1182 .absolute();
1183 sb.rename_note_row(&from, &to);
1184
1185 assert_eq!(
1187 sb.note_row_journal_date_for_test(&to),
1188 None,
1189 "journal_date must be cleared after renaming to a non-date name"
1190 );
1191 }
1192
1193 #[tokio::test(flavor = "multi_thread")]
1198 async fn save_default_survives_navigation() {
1199 let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
1200 let (tx, _rx) = unbounded_channel();
1201 navigate_to_root(&mut sidebar, &tx).await;
1202
1203 sidebar.save_default(SortField::Title, SortOrder::Descending, false);
1204 poll_to_idle(&mut sidebar).await;
1205
1206 sidebar.navigate(VaultPath::root(), &tx);
1209 poll_to_idle(&mut sidebar).await;
1210 assert_eq!(
1211 sidebar.current_sort(),
1212 (SortField::Title, SortOrder::Descending),
1213 "saved default must persist across navigation"
1214 );
1215 }
1216
1217 struct SlowListingSource {
1229 slot: Arc<std::sync::Mutex<Option<Emit<FileListEntry>>>>,
1230 }
1231
1232 #[async_trait]
1233 impl RowSource<FileListEntry> for SlowListingSource {
1234 async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
1235 *self.slot.lock().unwrap() = Some(emit);
1236 }
1237 fn reload_on_query(&self) -> bool {
1238 false
1239 }
1240 }
1241
1242 async fn slow_listing(
1245 field: SortField,
1246 order: SortOrder,
1247 group_dirs: bool,
1248 ) -> (SearchList<FileListEntry>, Emit<FileListEntry>) {
1249 let slot = Arc::new(std::sync::Mutex::new(None));
1250 let list = SearchList::builder(
1251 SlowListingSource { slot: slot.clone() },
1252 Arc::new(|| {}) as Arc<dyn Fn() + Send + Sync>,
1253 )
1254 .filter(Filter::Fuzzy)
1255 .order_by(listing_order(field, order, group_dirs))
1256 .build();
1257 let emit = loop {
1258 if let Some(e) = slot.lock().unwrap().clone() {
1259 break e;
1260 }
1261 tokio::task::yield_now().await;
1262 };
1263 (list, emit)
1264 }
1265
1266 fn note_row(title: &str) -> FileListEntry {
1267 let path = VaultPath::note_path_from(title);
1268 let filename = path.get_parent_path().1;
1269 FileListEntry::Note {
1270 path,
1271 title: title.to_string(),
1272 filename,
1273 journal_date: None,
1274 is_open: false,
1275 }
1276 }
1277
1278 fn dir_row(name: &str) -> FileListEntry {
1279 FileListEntry::Directory {
1280 path: VaultPath::new(name),
1281 name: name.to_string(),
1282 }
1283 }
1284
1285 fn labels(list: &SearchList<FileListEntry>) -> Vec<String> {
1288 list.visible_rows().iter().map(|r| label(r)).collect()
1289 }
1290
1291 fn label(row: &FileListEntry) -> String {
1292 match row {
1293 FileListEntry::Up { .. } => "..".to_string(),
1294 FileListEntry::Directory { name, .. } => format!("{name}/"),
1295 FileListEntry::Note { title, .. } => title.clone(),
1296 FileListEntry::Attachment { filename, .. } => filename.clone(),
1297 FileListEntry::CreateNote { filename, .. } => format!("create:{filename}"),
1298 }
1299 }
1300
1301 fn highlighted(list: &SearchList<FileListEntry>) -> Option<String> {
1302 list.selected_row().map(label)
1303 }
1304
1305 #[tokio::test]
1309 async fn a_slow_listing_keeps_the_untouched_highlight_on_the_top_row() {
1310 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1311
1312 emit.push(note_row("mike"));
1313 list.poll();
1314 assert_eq!(highlighted(&list).as_deref(), Some("mike"));
1315
1316 emit.push(note_row("delta"));
1317 list.poll();
1318 assert_eq!(labels(&list), ["delta", "mike"]);
1319 assert_eq!(highlighted(&list).as_deref(), Some("delta"));
1320
1321 emit.push(note_row("alpha"));
1322 list.poll();
1323 assert_eq!(labels(&list), ["alpha", "delta", "mike"]);
1324 assert_eq!(
1325 highlighted(&list).as_deref(),
1326 Some("alpha"),
1327 "the highlight follows the top of the sort, not the first row to arrive"
1328 );
1329 emit.done();
1330 }
1331
1332 #[tokio::test]
1337 async fn a_slow_subdirectory_listing_keeps_the_highlight_on_the_up_row() {
1338 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1339
1340 emit.push(FileListEntry::Up {
1341 parent: VaultPath::root(),
1342 });
1343 list.poll();
1344 assert_eq!(highlighted(&list).as_deref(), Some(".."));
1345
1346 emit.push(note_row("alpha"));
1347 list.poll();
1348 emit.push(note_row("aaron")); list.poll();
1350 assert_eq!(labels(&list), ["..", "aaron", "alpha"]);
1351 assert_eq!(
1352 highlighted(&list).as_deref(),
1353 Some(".."),
1354 "`Up` outranks every arriving entry, so the seed never leaves it"
1355 );
1356 emit.done();
1357 }
1358
1359 #[tokio::test]
1364 async fn a_sort_change_mid_stream_keeps_the_untouched_highlight_on_top() {
1365 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1366
1367 emit.push(note_row("bravo"));
1368 emit.push(note_row("delta"));
1369 list.poll();
1370 assert_eq!(labels(&list), ["bravo", "delta"]);
1371 assert_eq!(highlighted(&list).as_deref(), Some("bravo"));
1372
1373 list.set_order(Some(listing_order(
1375 SortField::Name,
1376 SortOrder::Descending,
1377 false,
1378 )));
1379 assert_eq!(labels(&list), ["delta", "bravo"]);
1380 assert_eq!(
1381 highlighted(&list).as_deref(),
1382 Some("delta"),
1383 "a re-sort moves the untouched highlight to the new top row"
1384 );
1385
1386 emit.push(note_row("echo"));
1389 list.poll();
1390 assert_eq!(labels(&list), ["echo", "delta", "bravo"]);
1391 assert_eq!(
1392 highlighted(&list).as_deref(),
1393 Some("echo"),
1394 "the sort change must not turn the seed into a user choice"
1395 );
1396 emit.done();
1397 }
1398
1399 #[tokio::test]
1403 async fn a_row_chosen_mid_stream_survives_a_sort_change_and_later_arrivals() {
1404 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1405
1406 emit.push(note_row("bravo"));
1407 emit.push(note_row("delta"));
1408 list.poll();
1409 list.select_next(); assert_eq!(highlighted(&list).as_deref(), Some("delta"));
1411
1412 list.set_order(Some(listing_order(
1413 SortField::Name,
1414 SortOrder::Descending,
1415 false,
1416 )));
1417 assert_eq!(labels(&list), ["delta", "bravo"]);
1418 assert_eq!(highlighted(&list).as_deref(), Some("delta"));
1419
1420 emit.push(note_row("echo")); list.poll();
1422 assert_eq!(labels(&list), ["echo", "delta", "bravo"]);
1423 assert_eq!(
1424 highlighted(&list).as_deref(),
1425 Some("delta"),
1426 "a chosen row keeps the highlight through a re-sort and later arrivals"
1427 );
1428 emit.done();
1429 }
1430
1431 #[tokio::test]
1435 async fn a_late_directory_takes_the_top_when_directories_are_grouped() {
1436 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, true).await;
1437
1438 emit.push(note_row("alpha"));
1439 emit.push(note_row("bravo"));
1440 list.poll();
1441 assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
1442
1443 emit.push(dir_row("zeta"));
1444 list.poll();
1445 assert_eq!(labels(&list), ["zeta/", "alpha", "bravo"]);
1446 assert_eq!(
1447 highlighted(&list).as_deref(),
1448 Some("zeta/"),
1449 "grouped directories outrank notes whenever they arrive"
1450 );
1451 emit.done();
1452 }
1453
1454 #[tokio::test]
1458 async fn filtering_mid_stream_keeps_a_chosen_row_while_more_rows_arrive() {
1459 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1460
1461 emit.push(note_row("alpha"));
1462 emit.push(note_row("albatross"));
1463 emit.push(note_row("zebra"));
1464 list.poll();
1465 list.set_query("al");
1466 assert_eq!(labels(&list), ["albatross", "alpha"]);
1467 list.select_next(); assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
1469
1470 emit.push(note_row("alabama"));
1473 emit.push(note_row("yankee"));
1474 list.poll();
1475 assert_eq!(labels(&list), ["alabama", "albatross", "alpha"]);
1476 assert_eq!(
1477 highlighted(&list).as_deref(),
1478 Some("alpha"),
1479 "the chosen row keeps the highlight behind an active filter"
1480 );
1481
1482 list.set_query("");
1484 assert_eq!(
1485 labels(&list),
1486 ["alabama", "albatross", "alpha", "yankee", "zebra"]
1487 );
1488 assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
1489 emit.done();
1490 }
1491
1492 #[tokio::test]
1497 async fn an_in_place_update_that_re_sorts_carries_the_highlight_with_the_row() {
1498 let (mut list, emit) = slow_listing(SortField::Title, SortOrder::Ascending, false).await;
1499 emit.push(note_row("alpha"));
1500 emit.push(note_row("bravo"));
1501 emit.push(note_row("charlie"));
1502 list.poll();
1503 list.select_next();
1504 list.select_next();
1505 assert_eq!(highlighted(&list).as_deref(), Some("charlie"));
1506
1507 list.update_rows(|row| {
1509 if let FileListEntry::Note { title, .. } = row
1510 && title == "charlie"
1511 {
1512 *title = "aaa".to_string();
1513 return true;
1514 }
1515 false
1516 });
1517
1518 assert_eq!(labels(&list), ["aaa", "alpha", "bravo"]);
1519 assert_eq!(
1520 highlighted(&list).as_deref(),
1521 Some("aaa"),
1522 "the highlight belongs to the row, not to the position it used to hold"
1523 );
1524 emit.done();
1525 }
1526
1527 #[tokio::test]
1530 async fn an_in_place_update_that_re_sorts_leaves_a_seed_on_the_top_row() {
1531 let (mut list, emit) = slow_listing(SortField::Title, SortOrder::Ascending, false).await;
1532 emit.push(note_row("bravo"));
1533 emit.push(note_row("charlie"));
1534 list.poll();
1535 assert_eq!(highlighted(&list).as_deref(), Some("bravo"));
1536
1537 list.update_rows(|row| {
1538 if let FileListEntry::Note { title, .. } = row
1539 && title == "charlie"
1540 {
1541 *title = "aaa".to_string();
1542 return true;
1543 }
1544 false
1545 });
1546
1547 assert_eq!(labels(&list), ["aaa", "bravo"]);
1548 assert_eq!(
1549 highlighted(&list).as_deref(),
1550 Some("aaa"),
1551 "an untouched highlight belongs to the top of the order after any recompute"
1552 );
1553 emit.done();
1554 }
1555}