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::{
18 AppEvent, AppTx, AppTxExt, FileOp, InputEvent, SortTarget, redraw_callback,
19};
20use crate::components::file_list::{FileListEntry, SortField, SortOrder, entry_order};
21use crate::components::search_list::{
22 Emit, Filter, KeyReaction, OrderFn, RowSource, SearchList, SearchMouse,
23};
24use crate::components::sortable::{
25 PropertySort, SortState, SortableList, is_blank_property, property_key,
26};
27use crate::keys::KeyBindings;
28use crate::settings::AppSettings;
29use crate::settings::icons::Icons;
30
31struct DirListingSource {
39 vault: Arc<NoteVault>,
40 dir: VaultPath,
41}
42
43#[async_trait]
44impl RowSource<FileListEntry> for DirListingSource {
45 async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
46 if !self.dir.is_root_or_empty() {
48 emit.push(FileListEntry::Up {
49 parent: self.dir.get_parent_path().0,
50 });
51 }
52
53 let options = VaultBrowseOptionsBuilder::new(&self.dir)
54 .recursive(false)
55 .validation(NotesValidation::Full)
56 .build();
57
58 let mut stream = std::pin::pin!(self.vault.browse_vault_stream(options));
59 while let Some(item) = stream.next().await {
60 let result = match item {
61 Ok(result) => result,
62 Err(e) => {
63 tracing::warn!("sidebar directory listing failed: {e}");
64 break;
65 }
66 };
67 if matches!(result.rtype, ResultType::Directory) && result.path.is_like(&self.dir) {
70 continue;
71 }
72 let journal_date = self
73 .vault
74 .journal_date(&result.path)
75 .map(format_journal_date);
76 emit.push(FileListEntry::from_result(result, journal_date));
77 }
78 emit.done();
79 }
80
81 fn leading_row(&self, query: &str) -> Option<FileListEntry> {
82 if query.is_empty() {
83 None
84 } else {
85 let path = self.dir.append(&VaultPath::note_path_from(query)).flatten();
86 Some(FileListEntry::CreateNote {
87 filename: path.to_string(),
88 path,
89 })
90 }
91 }
92
93 fn reload_on_query(&self) -> bool {
94 false
97 }
98}
99
100pub struct SidebarComponent {
101 current_dir: VaultPath,
102 open_note: Option<VaultPath>,
106 list: Option<SearchList<FileListEntry>>,
107 vault: Arc<NoteVault>,
108 icons: Icons,
109 default_sort_field: SortField,
110 default_sort_order: SortOrder,
111 journal_sort_field: SortField,
112 journal_sort_order: SortOrder,
113 sort: (SortField, SortOrder),
118 property_sort: PropertySort,
120 group_dirs: bool,
122 rendered_rect: Rect,
123 breadcrumb_cells: Vec<(Rect, VaultPath)>,
126 sort_chip: crate::components::clickable::BorderChip,
128 show_sort_chip: bool,
131 key_bindings: KeyBindings,
132}
133
134impl SidebarComponent {
135 pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
139 Self::new(
140 settings.key_bindings.clone(),
141 vault,
142 settings.icons(),
143 settings,
144 )
145 }
146
147 pub fn new(
148 key_bindings: KeyBindings,
149 vault: Arc<NoteVault>,
150 icons: Icons,
151 settings: &AppSettings,
152 ) -> Self {
153 let default_sort_field = SortField::from(settings.default_sort_field);
154 let default_sort_order = SortOrder::from(settings.default_sort_order);
155 Self {
156 current_dir: VaultPath::root(),
157 open_note: None,
158 list: None,
159 vault,
160 icons,
161 default_sort_field: default_sort_field.clone(),
162 default_sort_order,
163 journal_sort_field: SortField::from(settings.journal_sort_field),
164 journal_sort_order: SortOrder::from(settings.journal_sort_order),
165 sort: (default_sort_field, default_sort_order),
166 property_sort: PropertySort::default(),
167 group_dirs: settings.group_directories,
168 rendered_rect: Rect::default(),
169 breadcrumb_cells: Vec::new(),
170 sort_chip: Default::default(),
171 show_sort_chip: true,
172 key_bindings,
173 }
174 }
175
176 fn breadcrumb_at(&self, column: u16, row: u16) -> Option<&VaultPath> {
178 self.breadcrumb_cells
179 .iter()
180 .find(|(rect, _)| rect.contains(Position { x: column, y: row }))
181 .map(|(_, dir)| dir)
182 }
183
184 pub fn current_dir(&self) -> &VaultPath {
185 &self.current_dir
186 }
187
188 pub fn is_empty(&self) -> bool {
191 self.list.is_none()
192 }
193
194 fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
196 if dir.is_like(self.vault.journal_path()) {
197 (self.journal_sort_field.clone(), self.journal_sort_order)
198 } else {
199 (self.default_sort_field.clone(), self.default_sort_order)
200 }
201 }
202
203 pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
208 self.sort = self.sort_for(&dir);
209 self.current_dir = dir;
210 self.build_listing(tx);
211 }
212
213 fn build_listing(&mut self, tx: &AppTx) {
217 let source = DirListingSource {
218 vault: self.vault.clone(),
219 dir: self.current_dir.clone(),
220 };
221 self.list = Some(
222 SearchList::builder(source, redraw_callback(tx.clone()))
223 .filter(Filter::Fuzzy)
224 .order_by(self.order())
225 .yank_combos_from(&self.key_bindings)
226 .icons(self.icons.clone())
227 .build(),
228 );
229 self.fetch_property_values(tx);
230 }
231
232 fn fetch_property_values(&mut self, tx: &AppTx) {
235 match property_key(&self.sort.0) {
236 Some(key) => self.property_sort.fetch(&self.vault, key, tx),
237 None => self.property_sort.clear(),
238 }
239 }
240
241 fn order(&self) -> OrderFn<FileListEntry> {
244 let (field, order) = self.sort.clone();
245 let values = self.property_sort.values_for_field(&field);
246 entry_order(field, order, self.group_dirs, values)
247 }
248
249 fn reorder(&mut self) {
252 if self.property_sort.is_awaiting(&self.sort.0) {
253 return;
254 }
255 let order = self.order();
256 if let Some(list) = &mut self.list {
257 list.set_order(Some(order));
258 }
259 }
260
261 pub(crate) fn on_property_sort_values(
264 &mut self,
265 key: &str,
266 values: crate::components::file_list::PropertyValues,
267 ) -> bool {
268 let accepted = self.property_sort.receive(key, values);
269 if accepted {
270 self.reorder();
271 }
272 accepted
273 }
274
275 fn poll_property_sort(&mut self) {
277 if let Some((key, values)) = self.property_sort.poll() {
278 self.on_property_sort_values(&key, values);
279 }
280 }
281
282 pub fn refresh_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
288 if dir.is_like(&self.current_dir) {
289 self.build_listing(tx);
290 }
291 }
292
293 pub fn set_open_note(&mut self, path: Option<VaultPath>) {
297 self.open_note = path;
298 self.stamp_open_marker();
299 }
300
301 fn stamp_open_marker(&mut self) {
305 let open = self.open_note.clone();
306 if let Some(list) = &mut self.list {
307 list.update_rows(|row| {
308 if let FileListEntry::Note { path, is_open, .. } = row {
309 let want = open.as_ref().is_some_and(|o| path.is_like(o));
310 if *is_open != want {
311 *is_open = want;
312 return true;
313 }
314 }
315 false
316 });
317 }
318 }
319
320 pub fn update_note_row(&mut self, path: &VaultPath, new_title: &str) {
324 if let Some(list) = &mut self.list {
325 list.update_rows(|row| {
326 if let FileListEntry::Note {
327 path: row_path,
328 title,
329 ..
330 } = row
331 && row_path.is_like(path)
332 && title != new_title
333 {
334 *title = new_title.to_string();
335 return true;
336 }
337 false
338 });
339 }
340 }
341
342 pub fn rename_note_row(&mut self, from: &VaultPath, to: &VaultPath) {
350 let new_filename = to.get_parent_path().1;
351 let new_journal_date = self.vault.journal_date(to).map(format_journal_date);
352 if let Some(list) = &mut self.list {
353 list.update_rows(|row| {
354 if let FileListEntry::Note {
355 path,
356 filename,
357 journal_date,
358 ..
359 } = row
360 && path.is_like(from)
361 {
362 *path = to.clone();
363 *filename = new_filename.clone();
364 *journal_date = new_journal_date.clone();
365 return true;
366 }
367 false
368 });
369 }
370 }
371
372 pub fn set_sort_chip(&mut self, show: bool) {
375 self.show_sort_chip = show;
376 }
377
378 pub fn set_current_dir(&mut self, dir: VaultPath) {
382 self.current_dir = dir;
383 }
384
385 pub fn current_sort(&self) -> (SortField, SortOrder) {
387 self.sort.clone()
388 }
389
390 pub fn group_dirs(&self) -> bool {
392 self.group_dirs
393 }
394
395 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
400 self.sort = (field, order);
401 self.group_dirs = group_dirs;
402 self.reorder();
403 }
404
405 pub fn is_current_journal(&self) -> bool {
408 self.current_dir.is_like(self.vault.journal_path())
409 }
410
411 pub fn remember_default(&mut self, field: SortField, order: SortOrder) {
418 if self.is_current_journal() {
419 self.journal_sort_field = field;
420 self.journal_sort_order = order;
421 } else {
422 self.default_sort_field = field;
423 self.default_sort_order = order;
424 }
425 }
426
427 fn note_count(&self) -> usize {
429 match &self.list {
430 None => 0,
431 Some(list) => list
432 .visible_rows()
433 .iter()
434 .filter(|e| matches!(e, FileListEntry::Note { .. }))
435 .count(),
436 }
437 }
438
439 fn activate_selected_entry(&self, tx: &AppTx) {
443 let Some(list) = &self.list else { return };
444 let Some(entry) = list.selected_row() else {
445 return;
446 };
447 match entry {
448 FileListEntry::CreateNote { path, .. } => {
449 let path = path.clone();
450 let vault = Arc::clone(&self.vault);
451 let tx2 = tx.clone();
452 tokio::spawn(async move {
453 match vault.load_or_create_note(&path, None).await {
454 Ok((_, created)) => tx2.announce_and_open(path, created),
455 Err(e) => {
456 tracing::warn!("create note failed for {path}: {e}");
457 }
458 }
459 });
460 }
461 FileListEntry::Attachment { path, .. } => {
462 tx.send(AppEvent::OpenAttachment(path.clone())).ok();
463 }
464 other => {
465 tx.send(AppEvent::open(other.path().clone())).ok();
466 }
467 }
468 }
469}
470
471fn format_journal_date(date: NaiveDate) -> String {
474 date.format("%A, %B %-d, %Y").to_string()
475}
476
477impl SortableList for SidebarComponent {
478 fn sort_state(&self) -> SortState {
479 let (field, order) = self.current_sort();
480 SortState {
481 field,
482 order,
483 group_dirs: Some(self.group_dirs),
484 }
485 }
486
487 fn apply_sort(&mut self, state: &SortState, tx: &AppTx) {
492 if is_blank_property(&state.field) {
493 return;
494 }
495 let group_dirs = state.group_dirs.unwrap_or(self.group_dirs);
496 SidebarComponent::apply_sort(self, state.field.clone(), state.order, group_dirs);
497 self.property_sort.sync(&self.vault, &state.field, tx);
498 }
499
500 fn allows_property(&self) -> bool {
501 true
502 }
503}
504
505impl Component for SidebarComponent {
506 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
507 if let InputEvent::Mouse(mouse) = event {
508 let pos = Position {
509 x: mouse.column,
510 y: mouse.row,
511 };
512 if !self.rendered_rect.contains(pos) {
513 return EventState::NotConsumed;
514 }
515 if matches!(
517 mouse.kind,
518 ratatui::crossterm::event::MouseEventKind::Down(
519 ratatui::crossterm::event::MouseButton::Left
520 )
521 ) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
522 {
523 tx.send(AppEvent::open(dir.clone())).ok();
524 return EventState::Consumed;
525 }
526 if self.sort_chip.hit(mouse) {
528 tx.send(AppEvent::OpenSortDialog(SortTarget::Sidebar)).ok();
529 return EventState::Consumed;
530 }
531 if let Some(list) = &mut self.list {
537 match list.handle_mouse(mouse) {
538 SearchMouse::Activated(_) | SearchMouse::DoubleClicked { repeat: false, .. } => {
539 self.activate_selected_entry(tx)
540 }
541 SearchMouse::Context(_) => {
543 if let Some(entry) = list.selected_row()
544 && !matches!(
545 entry,
546 FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
547 )
548 {
549 tx.send(AppEvent::FileOp(FileOp::ShowMenu(entry.path().clone())))
550 .ok();
551 }
552 }
553 SearchMouse::Selected(_)
556 | SearchMouse::Scrolled
557 | SearchMouse::ContentScrollUp
558 | SearchMouse::ContentScrollDown
559 | SearchMouse::InputFocused
560 | SearchMouse::DoubleClicked { repeat: true, .. }
562 | SearchMouse::Autocomplete { .. }
563 | SearchMouse::None => {}
564 }
565 }
566 return EventState::Consumed;
567 }
568
569 if let InputEvent::Key(key) = event {
570 if self.list.is_none() {
571 return EventState::NotConsumed;
572 }
573 let reaction = self.list.as_mut().unwrap().handle_key(key);
574 match reaction {
575 KeyReaction::Submit => {
576 self.activate_selected_entry(tx);
577 EventState::Consumed
578 }
579 KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
580 KeyReaction::Yank(target) => {
581 crate::components::yank_row(target, tx);
582 EventState::Consumed
583 }
584 KeyReaction::Intercepted(_) | KeyReaction::ListVerb(_) | KeyReaction::Unhandled => {
585 EventState::NotConsumed
586 }
587 }
588 } else {
589 EventState::NotConsumed
590 }
591 }
592
593 fn hint_shortcuts(&self) -> Vec<(String, String)> {
594 use crate::keys::action_shortcuts::ActionShortcuts;
595
596 crate::components::hints::hints_for(
597 &self.key_bindings,
598 &[
599 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
600 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
601 (ActionShortcuts::OpenSortDialog, "sort"),
602 ],
603 )
604 }
605
606 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
607 self.rendered_rect = rect;
608
609 let rows = Layout::default()
610 .direction(Direction::Vertical)
611 .constraints([
612 Constraint::Length(3),
613 Constraint::Length(3),
614 Constraint::Min(0),
615 ])
616 .split(rect);
617
618 let border_style = theme.border_style(focused);
619
620 let header = Block::default()
621 .title(format!("─ Files · {} ", self.current_dir))
622 .borders(Borders::ALL)
623 .border_style(border_style)
624 .style(theme.panel_style());
625 let header_inner = header.inner(rows[0]);
626 f.render_widget(header, rows[0]);
627
628 self.breadcrumb_cells.clear();
632 let seg_style = Style::default()
633 .fg(theme.fg_secondary.to_ratatui())
634 .bg(theme.bg_panel.to_ratatui());
635 let sep_style = Style::default()
636 .fg(theme.gray.to_ratatui())
637 .bg(theme.bg_panel.to_ratatui());
638 let mut spans: Vec<Span> = Vec::new();
639 let mut x = header_inner.x;
640 let mut push_segment =
641 |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
642 let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
643 if *x < header_inner.right() {
647 let visible = w.min(header_inner.right() - *x);
648 let r = Rect::new(*x, header_inner.y, visible, 1);
649 crate::components::clickable::register(r);
650 self.breadcrumb_cells.push((r, dir));
651 }
652 spans.push(Span::styled(label, seg_style));
653 *x += w;
654 };
655 push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
656 let slices = self.current_dir.get_slices();
657 let mut acc = String::new();
658 for slice in &slices {
659 spans.push(Span::styled(" / ", sep_style));
660 x += 3;
661 acc.push('/');
662 acc.push_str(slice);
663 push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
664 }
665 let count = format!("{} notes", self.note_count());
666 let used: u16 = x - header_inner.x;
667 let pad = header_inner
668 .width
669 .saturating_sub(used)
670 .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
671 spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
672 spans.push(Span::styled(count, sep_style));
673 f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
674
675 let mut search_block = Block::default().title(" Search");
677 if self.show_sort_chip {
678 let (field, order) = self.current_sort();
679 search_block = self.sort_chip.place(
680 search_block,
681 rows[1],
682 crate::components::clickable::Edge::Top,
683 crate::components::sortable::sort_chip_line(&field, order, theme),
684 );
685 } else {
686 self.sort_chip.hide();
687 }
688 let search_block = search_block
689 .borders(Borders::ALL)
690 .border_style(border_style)
691 .style(theme.panel_style());
692 let search_inner = search_block.inner(rows[1]);
693 f.render_widget(search_block, rows[1]);
694
695 let list_block = Block::default()
696 .borders(Borders::ALL)
697 .border_style(border_style)
698 .style(theme.panel_style());
699 let list_inner = list_block.inner(rows[2]);
700 f.render_widget(list_block, rows[2]);
701
702 if let Some(list) = &mut self.list {
706 list.poll();
707 }
708 self.poll_property_sort();
709 self.stamp_open_marker();
710 if let Some(list) = &mut self.list {
711 list.render_query(f, search_inner, theme, focused);
712 list.render(f, list_inner, theme, focused);
713 list.set_list_rect(list_inner);
718 list.set_panel_rect(rect);
719 }
720 }
721}
722
723#[cfg(test)]
724impl SidebarComponent {
725 pub(crate) fn poll_for_test(&mut self) {
726 if let Some(list) = &mut self.list {
727 list.poll();
728 }
729 self.poll_property_sort();
730 self.stamp_open_marker();
731 }
732
733 pub(crate) fn is_loading_for_test(&self) -> bool {
734 self.list.as_ref().is_some_and(|l| l.is_loading())
735 }
736
737 pub(crate) fn note_row_is_open_for_test(&self, name: &str) -> bool {
738 self.list.as_ref().is_some_and(|l| {
739 l.rows().iter().any(|r| {
740 matches!(r, FileListEntry::Note { path, is_open, .. }
741 if path.get_name() == name && *is_open)
742 })
743 })
744 }
745
746 pub(crate) fn note_row_title_for_test(&self, name: &str) -> Option<String> {
747 self.list.as_ref().and_then(|l| {
748 l.rows().iter().find_map(|r| match r {
749 FileListEntry::Note { path, title, .. } if path.get_name() == name => {
750 Some(title.clone())
751 }
752 _ => None,
753 })
754 })
755 }
756
757 pub(crate) fn note_row_journal_date_for_test(&self, path: &VaultPath) -> Option<String> {
758 self.list.as_ref().and_then(|l| {
759 l.rows().iter().find_map(|r| match r {
760 FileListEntry::Note {
761 path: row_path,
762 journal_date,
763 ..
764 } if row_path.is_like(path) => journal_date.clone(),
765 _ => None,
766 })
767 })
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use crate::settings::AppSettings;
775 use crate::test_support::{mouse_down_at, temp_vault};
776 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
777 use tokio::sync::mpsc::unbounded_channel;
778
779 async fn make_sidebar() -> SidebarComponent {
780 let vault = temp_vault("sidebar").await;
781 vault.validate_and_init().await.unwrap();
782 let settings = AppSettings::default();
783 SidebarComponent::new(
784 settings.key_bindings.clone(),
785 vault,
786 settings.icons(),
787 &settings,
788 )
789 }
790
791 async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
793 let vault = temp_vault(prefix).await;
794 vault.validate_and_init().await.unwrap();
795 for name in names {
796 vault
797 .create_note(&VaultPath::note_path_from(name), "body")
798 .await
799 .unwrap();
800 }
801 let settings = AppSettings::default();
802 SidebarComponent::new(
803 settings.key_bindings.clone(),
804 vault,
805 settings.icons(),
806 &settings,
807 )
808 }
809
810 #[tokio::test]
814 async fn mouse_down_in_sidebar_bounds_is_consumed() {
815 let mut sidebar = make_sidebar().await;
816 sidebar.rendered_rect = Rect {
817 x: 0,
818 y: 3,
819 width: 30,
820 height: 20,
821 };
822 let (tx, _rx) = unbounded_channel();
823
824 assert_eq!(
826 sidebar.handle_input(&mouse_down_at(5, 4), &tx),
827 EventState::Consumed
828 );
829 assert_eq!(
831 sidebar.handle_input(&mouse_down_at(5, 7), &tx),
832 EventState::Consumed
833 );
834 assert_eq!(
836 sidebar.handle_input(&mouse_down_at(40, 7), &tx),
837 EventState::NotConsumed
838 );
839 }
840
841 fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
842 InputEvent::Mouse(MouseEvent {
843 kind,
844 column: col,
845 row,
846 modifiers: KeyModifiers::NONE,
847 })
848 }
849
850 async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
853 sidebar.navigate(VaultPath::root(), tx);
854 for _ in 0..50 {
857 if let Some(list) = &mut sidebar.list {
858 list.poll();
859 if !list.is_loading() {
860 break;
861 }
862 }
863 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
864 }
865 if let Some(list) = &mut sidebar.list {
866 list.poll();
867 }
868 }
869
870 #[tokio::test(flavor = "multi_thread")]
873 async fn mouse_double_click_on_list_row_sends_open_path() {
874 let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
875 let (tx, mut rx) = unbounded_channel();
876 navigate_to_root(&mut sidebar, &tx).await;
877
878 sidebar.rendered_rect = Rect {
879 x: 0,
880 y: 3,
881 width: 30,
882 height: 20,
883 };
884 if let Some(list) = &mut sidebar.list {
887 list.set_list_rect(Rect {
888 x: 0,
889 y: 9,
890 width: 30,
891 height: 14,
892 });
893 }
894
895 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
897
898 sidebar.handle_input(&mouse_down_at(5, 9), &tx);
900 let mut events = Vec::new();
901 while let Ok(evt) = rx.try_recv() {
902 events.push(evt);
903 }
904 assert!(
905 events
906 .iter()
907 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
908 "expected OpenPath for the activated note, got {events:?}"
909 );
910 }
911
912 #[tokio::test(flavor = "multi_thread")]
917 async fn scroll_down_in_sidebar_bounds_scrolls_list() {
918 let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
919 let (tx, _rx) = unbounded_channel();
920 navigate_to_root(&mut sidebar, &tx).await;
921
922 sidebar.rendered_rect = Rect {
923 x: 0,
924 y: 3,
925 width: 30,
926 height: 20,
927 };
928 if let Some(list) = &mut sidebar.list {
932 list.set_list_rect(Rect {
933 x: 0,
934 y: 9,
935 width: 30,
936 height: 1,
937 });
938 list.set_panel_rect(Rect {
939 x: 0,
940 y: 3,
941 width: 30,
942 height: 20,
943 });
944 }
945
946 let first = sidebar
947 .list
948 .as_ref()
949 .unwrap()
950 .selected_row()
951 .map(|e| e.path().to_string());
952
953 let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
955 assert_eq!(result, EventState::Consumed);
956 let after = sidebar
957 .list
958 .as_ref()
959 .unwrap()
960 .selected_row()
961 .map(|e| e.path().to_string());
962 assert_ne!(
963 first, after,
964 "scroll-from-header should scroll the list, carrying the selection"
965 );
966 }
967
968 #[tokio::test]
969 async fn mouse_down_outside_sidebar_is_not_consumed() {
970 let mut sidebar = make_sidebar().await;
971 sidebar.rendered_rect = Rect {
972 x: 0,
973 y: 3,
974 width: 30,
975 height: 20,
976 };
977 let (tx, mut rx) = unbounded_channel();
978
979 let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
981 assert_eq!(result, EventState::NotConsumed);
982 assert!(rx.try_recv().is_err());
983 }
984
985 #[tokio::test(flavor = "multi_thread")]
987 async fn navigate_loads_directory_notes() {
988 let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
989 assert!(sidebar.is_empty());
990 let (tx, _rx) = unbounded_channel();
991 navigate_to_root(&mut sidebar, &tx).await;
992 assert!(!sidebar.is_empty());
993 assert_eq!(sidebar.note_count(), 1);
994 }
995
996 async fn poll_to_idle(sidebar: &mut SidebarComponent) {
999 for _ in 0..50 {
1000 if let Some(list) = &mut sidebar.list {
1001 list.poll();
1002 if !list.is_loading() {
1003 break;
1004 }
1005 }
1006 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1007 }
1008 if let Some(list) = &mut sidebar.list {
1009 list.poll();
1010 }
1011 }
1012
1013 fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
1015 sidebar
1016 .list
1017 .as_ref()
1018 .unwrap()
1019 .visible_rows()
1020 .iter()
1021 .filter_map(|e| match e {
1022 FileListEntry::Note { filename, .. } => Some(filename.clone()),
1023 _ => None,
1024 })
1025 .collect()
1026 }
1027
1028 #[tokio::test(flavor = "multi_thread")]
1032 async fn listing_streams_each_row_then_done() {
1033 use crate::components::search_list::Loaded;
1034
1035 let sidebar = sidebar_with_notes("sidebar-stream", &["charlie", "alpha", "bravo"]).await;
1036 let source = DirListingSource {
1037 vault: sidebar.vault.clone(),
1038 dir: VaultPath::root(),
1039 };
1040 let (emit, rx) = Emit::capture();
1041
1042 source.load("", emit).await;
1043
1044 let events: Vec<Loaded<FileListEntry>> = rx.try_iter().map(|(_, ev)| ev).collect();
1045 let pushed_notes = events
1046 .iter()
1047 .filter(|e| matches!(e, Loaded::Push(FileListEntry::Note { .. })))
1048 .count();
1049 assert_eq!(pushed_notes, 3, "each note is streamed as its own row");
1050 assert!(
1051 !events.iter().any(|e| matches!(e, Loaded::Replace(_))),
1052 "no whole-set re-delivery"
1053 );
1054 assert!(
1055 matches!(events.last(), Some(Loaded::Done)),
1056 "done closes the load"
1057 );
1058 }
1059
1060 #[tokio::test(flavor = "multi_thread")]
1063 async fn apply_sort_reorders_without_reloading() {
1064 let mut sidebar =
1065 sidebar_with_notes("sidebar-resort", &["alpha", "bravo", "charlie"]).await;
1066 let (tx, _rx) = unbounded_channel();
1067 navigate_to_root(&mut sidebar, &tx).await;
1068 let before = note_names(&sidebar);
1069 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
1070
1071 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
1072
1073 let list = sidebar.list.as_ref().unwrap();
1074 assert!(
1075 !list.is_loading(),
1076 "a sort change must not reload the listing"
1077 );
1078 assert_eq!(
1079 note_names(&sidebar),
1080 before.iter().rev().cloned().collect::<Vec<_>>(),
1081 "rows re-ordered in place"
1082 );
1083 }
1084
1085 #[tokio::test(flavor = "multi_thread")]
1086 async fn apply_sort_reverse_flips_listing_order() {
1087 let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
1088 let (tx, _rx) = unbounded_channel();
1089 navigate_to_root(&mut sidebar, &tx).await;
1090 let before = note_names(&sidebar);
1091 assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
1092 sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
1093 poll_to_idle(&mut sidebar).await;
1094 let after = note_names(&sidebar);
1095 assert_eq!(
1096 after,
1097 before.iter().rev().cloned().collect::<Vec<_>>(),
1098 "descending order should reverse the listing"
1099 );
1100 }
1101
1102 #[tokio::test(flavor = "multi_thread")]
1103 async fn apply_sort_changes_field() {
1104 let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
1105 let (tx, _rx) = unbounded_channel();
1106 navigate_to_root(&mut sidebar, &tx).await;
1107 sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
1108 poll_to_idle(&mut sidebar).await;
1109 assert_eq!(sidebar.current_sort().0, SortField::Title);
1110 assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
1111 }
1112
1113 async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
1115 let vault = temp_vault(prefix).await;
1116 vault.validate_and_init().await.unwrap();
1117 vault
1118 .create_note(&VaultPath::note_path_from("alpha"), "body")
1119 .await
1120 .unwrap();
1121 vault
1122 .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
1123 .await
1124 .unwrap();
1125 let settings = AppSettings::default();
1126 SidebarComponent::new(
1127 settings.key_bindings.clone(),
1128 vault,
1129 settings.icons(),
1130 &settings,
1131 )
1132 }
1133
1134 fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
1136 sidebar
1137 .list
1138 .as_ref()
1139 .unwrap()
1140 .visible_rows()
1141 .iter()
1142 .filter_map(|e| match e {
1143 FileListEntry::Note { .. } => Some("note"),
1144 FileListEntry::Directory { .. } => Some("dir"),
1145 _ => None,
1146 })
1147 .collect()
1148 }
1149
1150 #[tokio::test(flavor = "multi_thread")]
1151 async fn group_dirs_puts_directories_first() {
1152 let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
1153 let (tx, _rx) = unbounded_channel();
1154 navigate_to_root(&mut sidebar, &tx).await;
1155 assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
1156 sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
1157 poll_to_idle(&mut sidebar).await;
1158 assert_eq!(
1159 row_kinds(&sidebar),
1160 vec!["dir", "note"],
1161 "grouping must cluster directories first"
1162 );
1163 }
1164
1165 #[tokio::test(flavor = "multi_thread")]
1166 async fn apply_sort_updates_shared_state() {
1167 let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
1168 let (tx, _rx) = unbounded_channel();
1169 navigate_to_root(&mut sidebar, &tx).await;
1170 sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1171 poll_to_idle(&mut sidebar).await;
1172 assert_eq!(
1173 sidebar.current_sort(),
1174 (SortField::Title, SortOrder::Descending)
1175 );
1176 assert!(!sidebar.group_dirs());
1177 }
1178
1179 #[tokio::test(flavor = "multi_thread")]
1180 async fn set_open_note_stamps_matching_row() {
1181 let mut sb = sidebar_with_notes("sb-open", &["alpha", "beta"]).await;
1182 let (tx, _rx) = unbounded_channel();
1183 navigate_to_root(&mut sb, &tx).await;
1184
1185 sb.set_open_note(Some(VaultPath::note_path_from("alpha")));
1186 assert!(
1187 sb.note_row_is_open_for_test("alpha.md"),
1188 "open note is marked"
1189 );
1190 assert!(
1191 !sb.note_row_is_open_for_test("beta.md"),
1192 "other note is not marked"
1193 );
1194
1195 sb.set_open_note(Some(VaultPath::note_path_from("beta")));
1196 assert!(!sb.note_row_is_open_for_test("alpha.md"));
1197 assert!(sb.note_row_is_open_for_test("beta.md"));
1198
1199 sb.set_open_note(None);
1200 assert!(!sb.note_row_is_open_for_test("beta.md"));
1201 }
1202
1203 #[tokio::test(flavor = "multi_thread")]
1204 async fn update_note_row_changes_title_in_place() {
1205 let mut sb = sidebar_with_notes("sb-title", &["alpha"]).await;
1206 let (tx, _rx) = unbounded_channel();
1207 navigate_to_root(&mut sb, &tx).await;
1208
1209 sb.update_note_row(&VaultPath::note_path_from("alpha"), "Fresh Title");
1210 assert_eq!(
1211 sb.note_row_title_for_test("alpha.md").as_deref(),
1212 Some("Fresh Title")
1213 );
1214 }
1215
1216 #[tokio::test(flavor = "multi_thread")]
1217 async fn rename_note_row_updates_path_and_filename() {
1218 let mut sb = sidebar_with_notes("sb-rename", &["alpha"]).await;
1219 let (tx, _rx) = unbounded_channel();
1220 navigate_to_root(&mut sb, &tx).await;
1221
1222 let to = VaultPath::note_path_from("gamma");
1223 let expected_filename = to.get_parent_path().1;
1224 sb.rename_note_row(&VaultPath::note_path_from("alpha"), &to);
1225 assert!(
1226 sb.note_row_title_for_test("gamma.md").is_some(),
1227 "row now at new name"
1228 );
1229 assert!(
1230 sb.note_row_title_for_test("alpha.md").is_none(),
1231 "old name gone"
1232 );
1233 let renamed_filename = sb
1235 .list
1236 .as_ref()
1237 .unwrap()
1238 .rows()
1239 .iter()
1240 .find_map(|r| match r {
1241 FileListEntry::Note { path, filename, .. } if path.is_like(&to) => {
1242 Some(filename.clone())
1243 }
1244 _ => None,
1245 });
1246 assert_eq!(
1247 renamed_filename.as_deref(),
1248 Some(expected_filename.as_str()),
1249 "filename field must be updated to the new name"
1250 );
1251 }
1252
1253 #[tokio::test(flavor = "multi_thread")]
1256 async fn rename_note_row_clears_journal_date_when_renamed_away_from_date_name() {
1257 let vault = crate::test_support::temp_vault("sb-jdate").await;
1260 vault.validate_and_init().await.unwrap();
1261 let journal_path = vault.journal_path().clone();
1262 let date_name = "2026-06-09";
1263 let from = journal_path
1264 .append(&VaultPath::note_path_from(date_name))
1265 .absolute();
1266 vault.create_note(&from, "journal body").await.unwrap();
1267
1268 let settings = AppSettings::default();
1269 let mut sb = SidebarComponent::new(
1270 settings.key_bindings.clone(),
1271 vault,
1272 settings.icons(),
1273 &settings,
1274 );
1275 let (tx, _rx) = unbounded_channel();
1276 sb.navigate(journal_path.clone(), &tx);
1278 for _ in 0..50 {
1279 sb.poll_for_test();
1280 if !sb.is_loading_for_test() {
1281 break;
1282 }
1283 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1284 }
1285 sb.poll_for_test();
1286
1287 assert!(
1289 sb.note_row_journal_date_for_test(&from).is_some(),
1290 "journal note must have a journal_date before rename"
1291 );
1292
1293 let to = journal_path
1295 .append(&VaultPath::note_path_from("meeting"))
1296 .absolute();
1297 sb.rename_note_row(&from, &to);
1298
1299 assert_eq!(
1301 sb.note_row_journal_date_for_test(&to),
1302 None,
1303 "journal_date must be cleared after renaming to a non-date name"
1304 );
1305 }
1306
1307 #[tokio::test(flavor = "multi_thread")]
1312 async fn save_default_survives_navigation() {
1313 let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
1314 let (tx, _rx) = unbounded_channel();
1315 navigate_to_root(&mut sidebar, &tx).await;
1316
1317 sidebar.remember_default(SortField::Title, SortOrder::Descending);
1318 sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1319 poll_to_idle(&mut sidebar).await;
1320
1321 sidebar.navigate(VaultPath::root(), &tx);
1324 poll_to_idle(&mut sidebar).await;
1325 assert_eq!(
1326 sidebar.current_sort(),
1327 (SortField::Title, SortOrder::Descending),
1328 "saved default must persist across navigation"
1329 );
1330 }
1331
1332 async fn sidebar_with_bodies(prefix: &str, notes: &[(&str, &str)]) -> SidebarComponent {
1334 let vault = temp_vault(prefix).await;
1335 vault.validate_and_init().await.unwrap();
1336 for (name, body) in notes {
1337 vault
1338 .create_note(&VaultPath::note_path_from(name), body)
1339 .await
1340 .unwrap();
1341 }
1342 let settings = AppSettings::default();
1343 SidebarComponent::new(
1344 settings.key_bindings.clone(),
1345 vault,
1346 settings.icons(),
1347 &settings,
1348 )
1349 }
1350
1351 const RANKED: [(&str, &str); 3] = [
1352 ("alpha", "---\nrank: 3\n---\nbody"),
1353 ("bravo", "---\nrank: 1\n---\nbody"),
1354 ("charlie", "body"),
1355 ];
1356
1357 fn property_state(key: &str, order: SortOrder) -> SortState {
1358 SortState {
1359 field: SortField::Property(key.into()),
1360 order,
1361 group_dirs: Some(false),
1362 }
1363 }
1364
1365 async fn poll_property_values(sidebar: &mut SidebarComponent) {
1367 for _ in 0..100 {
1368 sidebar.poll_for_test();
1369 if !sidebar.property_sort.is_pending() {
1370 return;
1371 }
1372 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1373 }
1374 panic!("property values never arrived");
1375 }
1376
1377 #[tokio::test(flavor = "multi_thread")]
1380 async fn property_sort_reorders_once_values_arrive() {
1381 let mut sidebar = sidebar_with_bodies("sidebar-prop", &RANKED).await;
1382 let (tx, _rx) = unbounded_channel();
1383 navigate_to_root(&mut sidebar, &tx).await;
1384 assert!(SortableList::allows_property(&sidebar));
1385 assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1386
1387 SortableList::apply_sort(
1388 &mut sidebar,
1389 &property_state("Rank", SortOrder::Ascending),
1390 &tx,
1391 );
1392 assert_eq!(
1393 note_names(&sidebar),
1394 ["alpha.md", "bravo.md", "charlie.md"],
1395 "current order kept until the values arrive"
1396 );
1397 poll_property_values(&mut sidebar).await;
1398 assert_eq!(note_names(&sidebar), ["bravo.md", "alpha.md", "charlie.md"]);
1399
1400 SortableList::apply_sort(
1402 &mut sidebar,
1403 &property_state("Rank", SortOrder::Descending),
1404 &tx,
1405 );
1406 assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1407 }
1408
1409 #[tokio::test(flavor = "multi_thread")]
1411 async fn stale_property_values_are_ignored() {
1412 use kimun_core::PropertySortValue::Number;
1413 let mut sidebar = sidebar_with_bodies("sidebar-stale", &RANKED).await;
1414 let (tx, _rx) = unbounded_channel();
1415 navigate_to_root(&mut sidebar, &tx).await;
1416 SortableList::apply_sort(
1417 &mut sidebar,
1418 &property_state("rank", SortOrder::Ascending),
1419 &tx,
1420 );
1421 let stale = Arc::new(std::collections::HashMap::from([(
1422 VaultPath::note_path_from("/charlie"),
1423 Number(0.0),
1424 )]));
1425 assert!(!sidebar.on_property_sort_values("other", stale.clone()));
1426 assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1427 assert!(sidebar.on_property_sort_values("rank", stale));
1429 assert_eq!(note_names(&sidebar), ["charlie.md", "alpha.md", "bravo.md"]);
1430 }
1431
1432 #[tokio::test(flavor = "multi_thread")]
1435 async fn refresh_keeps_the_property_sort_and_refetches() {
1436 let mut sidebar = sidebar_with_bodies("sidebar-prop-refresh", &RANKED).await;
1437 let (tx, _rx) = unbounded_channel();
1438 navigate_to_root(&mut sidebar, &tx).await;
1439 SortableList::apply_sort(
1440 &mut sidebar,
1441 &property_state("rank", SortOrder::Ascending),
1442 &tx,
1443 );
1444 poll_property_values(&mut sidebar).await;
1445 assert_eq!(note_names(&sidebar), ["bravo.md", "alpha.md", "charlie.md"]);
1446
1447 sidebar
1448 .vault
1449 .save_note(
1450 &VaultPath::note_path_from("bravo"),
1451 "---\nrank: 9\n---\nbody",
1452 )
1453 .await
1454 .unwrap();
1455 sidebar.refresh_if_showing(&VaultPath::root(), &tx);
1456 poll_to_idle(&mut sidebar).await;
1457 poll_property_values(&mut sidebar).await;
1458 assert_eq!(
1459 sidebar.current_sort(),
1460 (SortField::Property("rank".into()), SortOrder::Ascending)
1461 );
1462 assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1463 }
1464
1465 #[tokio::test(flavor = "multi_thread")]
1468 async fn property_sort_toggles_reuse_the_cached_values() {
1469 let mut sidebar = sidebar_with_bodies("sidebar-prop-cache", &RANKED).await;
1470 let (tx, _rx) = unbounded_channel();
1471 navigate_to_root(&mut sidebar, &tx).await;
1472 SortableList::apply_sort(
1473 &mut sidebar,
1474 &property_state("rank", SortOrder::Ascending),
1475 &tx,
1476 );
1477 poll_property_values(&mut sidebar).await;
1478 let fetches = sidebar.property_sort.fetches;
1479 assert_eq!(fetches, 1);
1480
1481 for order in [SortOrder::Descending, SortOrder::Ascending] {
1482 SortableList::apply_sort(&mut sidebar, &property_state("Rank", order), &tx);
1483 }
1484 let mut grouped = property_state("rank", SortOrder::Ascending);
1485 grouped.group_dirs = Some(true);
1486 SortableList::apply_sort(&mut sidebar, &grouped, &tx);
1487 assert_eq!(
1488 sidebar.property_sort.fetches, fetches,
1489 "toggles: no refetch"
1490 );
1491 assert!(!sidebar.property_sort.is_pending());
1492 assert_eq!(note_names(&sidebar), ["bravo.md", "alpha.md", "charlie.md"]);
1493
1494 SortableList::apply_sort(
1495 &mut sidebar,
1496 &property_state("due", SortOrder::Ascending),
1497 &tx,
1498 );
1499 assert_eq!(
1500 sidebar.property_sort.fetches,
1501 fetches + 1,
1502 "new key fetches"
1503 );
1504 poll_property_values(&mut sidebar).await;
1505
1506 sidebar.refresh_if_showing(&VaultPath::root(), &tx);
1507 assert_eq!(
1508 sidebar.property_sort.fetches,
1509 fetches + 2,
1510 "refresh refetches"
1511 );
1512 poll_to_idle(&mut sidebar).await;
1513 poll_property_values(&mut sidebar).await;
1514 }
1515
1516 #[tokio::test(flavor = "multi_thread")]
1519 async fn returning_to_a_property_sort_refetches() {
1520 let mut sidebar = sidebar_with_bodies("sidebar-prop-return", &RANKED).await;
1521 let (tx, _rx) = unbounded_channel();
1522 navigate_to_root(&mut sidebar, &tx).await;
1523 SortableList::apply_sort(
1524 &mut sidebar,
1525 &property_state("rank", SortOrder::Ascending),
1526 &tx,
1527 );
1528 poll_property_values(&mut sidebar).await;
1529 assert_eq!(note_names(&sidebar), ["bravo.md", "alpha.md", "charlie.md"]);
1530 sidebar
1531 .vault
1532 .save_note(
1533 &VaultPath::note_path_from("bravo"),
1534 "---\nrank: 9\n---\nbody",
1535 )
1536 .await
1537 .unwrap();
1538 let mut name = property_state("rank", SortOrder::Ascending);
1539 name.field = SortField::Name;
1540 SortableList::apply_sort(&mut sidebar, &name, &tx);
1541 SortableList::apply_sort(
1542 &mut sidebar,
1543 &property_state("rank", SortOrder::Ascending),
1544 &tx,
1545 );
1546 assert_eq!(sidebar.property_sort.fetches, 2, "back to rank refetches");
1547 poll_property_values(&mut sidebar).await;
1548 assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1549 }
1550
1551 struct SlowListingSource {
1563 slot: Arc<std::sync::Mutex<Option<Emit<FileListEntry>>>>,
1564 }
1565
1566 #[async_trait]
1567 impl RowSource<FileListEntry> for SlowListingSource {
1568 async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
1569 *self.slot.lock().unwrap() = Some(emit);
1570 }
1571 fn reload_on_query(&self) -> bool {
1572 false
1573 }
1574 }
1575
1576 async fn slow_listing(
1579 field: SortField,
1580 order: SortOrder,
1581 group_dirs: bool,
1582 ) -> (SearchList<FileListEntry>, Emit<FileListEntry>) {
1583 let slot = Arc::new(std::sync::Mutex::new(None));
1584 let list = SearchList::builder(
1585 SlowListingSource { slot: slot.clone() },
1586 Arc::new(|| {}) as Arc<dyn Fn() + Send + Sync>,
1587 )
1588 .filter(Filter::Fuzzy)
1589 .order_by(entry_order(field, order, group_dirs, None))
1590 .build();
1591 let emit = loop {
1592 if let Some(e) = slot.lock().unwrap().clone() {
1593 break e;
1594 }
1595 tokio::task::yield_now().await;
1596 };
1597 (list, emit)
1598 }
1599
1600 fn note_row(title: &str) -> FileListEntry {
1601 let path = VaultPath::note_path_from(title);
1602 let filename = path.get_parent_path().1;
1603 FileListEntry::Note {
1604 path,
1605 title: title.to_string(),
1606 filename,
1607 journal_date: None,
1608 is_open: false,
1609 }
1610 }
1611
1612 fn dir_row(name: &str) -> FileListEntry {
1613 FileListEntry::Directory {
1614 path: VaultPath::new(name),
1615 name: name.to_string(),
1616 }
1617 }
1618
1619 fn labels(list: &SearchList<FileListEntry>) -> Vec<String> {
1622 list.visible_rows().iter().map(|r| label(r)).collect()
1623 }
1624
1625 fn label(row: &FileListEntry) -> String {
1626 match row {
1627 FileListEntry::Up { .. } => "..".to_string(),
1628 FileListEntry::Directory { name, .. } => format!("{name}/"),
1629 FileListEntry::Note { title, .. } => title.clone(),
1630 FileListEntry::Attachment { filename, .. } => filename.clone(),
1631 FileListEntry::CreateNote { filename, .. } => format!("create:{filename}"),
1632 }
1633 }
1634
1635 fn highlighted(list: &SearchList<FileListEntry>) -> Option<String> {
1636 list.selected_row().map(label)
1637 }
1638
1639 #[tokio::test]
1643 async fn a_slow_listing_keeps_the_untouched_highlight_on_the_top_row() {
1644 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1645
1646 emit.push(note_row("mike"));
1647 list.poll();
1648 assert_eq!(highlighted(&list).as_deref(), Some("mike"));
1649
1650 emit.push(note_row("delta"));
1651 list.poll();
1652 assert_eq!(labels(&list), ["delta", "mike"]);
1653 assert_eq!(highlighted(&list).as_deref(), Some("delta"));
1654
1655 emit.push(note_row("alpha"));
1656 list.poll();
1657 assert_eq!(labels(&list), ["alpha", "delta", "mike"]);
1658 assert_eq!(
1659 highlighted(&list).as_deref(),
1660 Some("alpha"),
1661 "the highlight follows the top of the sort, not the first row to arrive"
1662 );
1663 emit.done();
1664 }
1665
1666 #[tokio::test]
1671 async fn a_slow_subdirectory_listing_keeps_the_highlight_on_the_up_row() {
1672 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1673
1674 emit.push(FileListEntry::Up {
1675 parent: VaultPath::root(),
1676 });
1677 list.poll();
1678 assert_eq!(highlighted(&list).as_deref(), Some(".."));
1679
1680 emit.push(note_row("alpha"));
1681 list.poll();
1682 emit.push(note_row("aaron")); list.poll();
1684 assert_eq!(labels(&list), ["..", "aaron", "alpha"]);
1685 assert_eq!(
1686 highlighted(&list).as_deref(),
1687 Some(".."),
1688 "`Up` outranks every arriving entry, so the seed never leaves it"
1689 );
1690 emit.done();
1691 }
1692
1693 #[tokio::test]
1698 async fn a_sort_change_mid_stream_keeps_the_untouched_highlight_on_top() {
1699 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1700
1701 emit.push(note_row("bravo"));
1702 emit.push(note_row("delta"));
1703 list.poll();
1704 assert_eq!(labels(&list), ["bravo", "delta"]);
1705 assert_eq!(highlighted(&list).as_deref(), Some("bravo"));
1706
1707 list.set_order(Some(entry_order(
1709 SortField::Name,
1710 SortOrder::Descending,
1711 false,
1712 None,
1713 )));
1714 assert_eq!(labels(&list), ["delta", "bravo"]);
1715 assert_eq!(
1716 highlighted(&list).as_deref(),
1717 Some("delta"),
1718 "a re-sort moves the untouched highlight to the new top row"
1719 );
1720
1721 emit.push(note_row("echo"));
1724 list.poll();
1725 assert_eq!(labels(&list), ["echo", "delta", "bravo"]);
1726 assert_eq!(
1727 highlighted(&list).as_deref(),
1728 Some("echo"),
1729 "the sort change must not turn the seed into a user choice"
1730 );
1731 emit.done();
1732 }
1733
1734 #[tokio::test]
1738 async fn a_row_chosen_mid_stream_survives_a_sort_change_and_later_arrivals() {
1739 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1740
1741 emit.push(note_row("bravo"));
1742 emit.push(note_row("delta"));
1743 list.poll();
1744 list.select_next(); assert_eq!(highlighted(&list).as_deref(), Some("delta"));
1746
1747 list.set_order(Some(entry_order(
1748 SortField::Name,
1749 SortOrder::Descending,
1750 false,
1751 None,
1752 )));
1753 assert_eq!(labels(&list), ["delta", "bravo"]);
1754 assert_eq!(highlighted(&list).as_deref(), Some("delta"));
1755
1756 emit.push(note_row("echo")); list.poll();
1758 assert_eq!(labels(&list), ["echo", "delta", "bravo"]);
1759 assert_eq!(
1760 highlighted(&list).as_deref(),
1761 Some("delta"),
1762 "a chosen row keeps the highlight through a re-sort and later arrivals"
1763 );
1764 emit.done();
1765 }
1766
1767 #[tokio::test]
1771 async fn a_late_directory_takes_the_top_when_directories_are_grouped() {
1772 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, true).await;
1773
1774 emit.push(note_row("alpha"));
1775 emit.push(note_row("bravo"));
1776 list.poll();
1777 assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
1778
1779 emit.push(dir_row("zeta"));
1780 list.poll();
1781 assert_eq!(labels(&list), ["zeta/", "alpha", "bravo"]);
1782 assert_eq!(
1783 highlighted(&list).as_deref(),
1784 Some("zeta/"),
1785 "grouped directories outrank notes whenever they arrive"
1786 );
1787 emit.done();
1788 }
1789
1790 #[tokio::test]
1794 async fn filtering_mid_stream_keeps_a_chosen_row_while_more_rows_arrive() {
1795 let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1796
1797 emit.push(note_row("alpha"));
1798 emit.push(note_row("albatross"));
1799 emit.push(note_row("zebra"));
1800 list.poll();
1801 list.set_query("al");
1802 assert_eq!(labels(&list), ["albatross", "alpha"]);
1803 list.select_next(); assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
1805
1806 emit.push(note_row("alabama"));
1809 emit.push(note_row("yankee"));
1810 list.poll();
1811 assert_eq!(labels(&list), ["alabama", "albatross", "alpha"]);
1812 assert_eq!(
1813 highlighted(&list).as_deref(),
1814 Some("alpha"),
1815 "the chosen row keeps the highlight behind an active filter"
1816 );
1817
1818 list.set_query("");
1820 assert_eq!(
1821 labels(&list),
1822 ["alabama", "albatross", "alpha", "yankee", "zebra"]
1823 );
1824 assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
1825 emit.done();
1826 }
1827
1828 #[tokio::test]
1833 async fn an_in_place_update_that_re_sorts_carries_the_highlight_with_the_row() {
1834 let (mut list, emit) = slow_listing(SortField::Title, SortOrder::Ascending, false).await;
1835 emit.push(note_row("alpha"));
1836 emit.push(note_row("bravo"));
1837 emit.push(note_row("charlie"));
1838 list.poll();
1839 list.select_next();
1840 list.select_next();
1841 assert_eq!(highlighted(&list).as_deref(), Some("charlie"));
1842
1843 list.update_rows(|row| {
1845 if let FileListEntry::Note { title, .. } = row
1846 && title == "charlie"
1847 {
1848 *title = "aaa".to_string();
1849 return true;
1850 }
1851 false
1852 });
1853
1854 assert_eq!(labels(&list), ["aaa", "alpha", "bravo"]);
1855 assert_eq!(
1856 highlighted(&list).as_deref(),
1857 Some("aaa"),
1858 "the highlight belongs to the row, not to the position it used to hold"
1859 );
1860 emit.done();
1861 }
1862
1863 #[tokio::test]
1866 async fn an_in_place_update_that_re_sorts_leaves_a_seed_on_the_top_row() {
1867 let (mut list, emit) = slow_listing(SortField::Title, SortOrder::Ascending, false).await;
1868 emit.push(note_row("bravo"));
1869 emit.push(note_row("charlie"));
1870 list.poll();
1871 assert_eq!(highlighted(&list).as_deref(), Some("bravo"));
1872
1873 list.update_rows(|row| {
1874 if let FileListEntry::Note { title, .. } = row
1875 && title == "charlie"
1876 {
1877 *title = "aaa".to_string();
1878 return true;
1879 }
1880 false
1881 });
1882
1883 assert_eq!(labels(&list), ["aaa", "bravo"]);
1884 assert_eq!(
1885 highlighted(&list).as_deref(),
1886 Some("aaa"),
1887 "an untouched highlight belongs to the top of the order after any recompute"
1888 );
1889 emit.done();
1890 }
1891}