Skip to main content

kimun_notes/components/
sidebar.rs

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::crossterm::event::{MouseButton, MouseEventKind};
10use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
11use ratatui::style::Style;
12use ratatui::widgets::{Block, Borders, Paragraph};
13
14use crate::components::Component;
15use crate::components::event_state::EventState;
16use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
17use crate::components::file_list::{FileListEntry, SortField, SortOrder};
18use crate::components::search_list::{
19    Emit, Filter, KeyReaction, RowSource, SearchList, SearchMouse,
20};
21use crate::keys::KeyBindings;
22use crate::settings::AppSettings;
23use crate::settings::icons::Icons;
24
25/// Streamed `RowSource` over one directory's listing. Pushes an `Up` row first
26/// (when not at root) so it is always present, then forwards each
27/// `browse_vault` result. Loads once; a local `Filter::Fuzzy` narrows the set
28/// and `leading_row` provides the "Create: …" affordance.
29struct DirListingSource {
30    vault: Arc<NoteVault>,
31    dir: VaultPath,
32    /// Shared sort field/order. `load` reads it so the sidebar's interactive
33    /// sort shortcuts (cycle field / reverse order) re-order the listing on
34    /// reload; initialised per-directory from the default/journal settings.
35    sort: Arc<Mutex<(SortField, SortOrder)>>,
36    /// Shared "group directories first" flag, read by `load`.
37    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        // Up row first (if not root) — pushed so it's always present.
44        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        // browse_vault fills `rx`; spawn it so we can drain concurrently.
57        let browse = tokio::spawn(async move { vault.browse_vault(options).await });
58
59        // `rx` is a std mpsc Receiver; `recv` blocks, so drain it on a blocking
60        // thread, sort the gathered entries, then push them in display order.
61        let vault = self.vault.clone();
62        let dir = self.dir.clone();
63        // Read the active sort out of the lock, then drop the guard before the
64        // await on the blocking task.
65        let (field, order) = *self.sort.lock().unwrap();
66        let group_dirs = *self.group_dirs.lock().unwrap();
67        let drain = tokio::task::spawn_blocking(move || {
68            let mut entries: Vec<FileListEntry> = Vec::new();
69            while let Ok(result) = rx.recv() {
70                if matches!(result.rtype, ResultType::Directory) && result.path == dir {
71                    continue;
72                }
73                let journal_date = vault.journal_date(&result.path).map(format_journal_date);
74                entries.push(FileListEntry::from_result(result, journal_date));
75            }
76            let cmp = |a: &FileListEntry, b: &FileListEntry| {
77                let ka = a.sort_key(field);
78                let kb = b.sort_key(field);
79                match order {
80                    SortOrder::Ascending => ka.cmp(&kb),
81                    SortOrder::Descending => kb.cmp(&ka),
82                }
83            };
84            if group_dirs {
85                let (mut dirs, mut rest): (Vec<_>, Vec<_>) = entries
86                    .into_iter()
87                    .partition(|e| matches!(e, FileListEntry::Directory { .. }));
88                dirs.sort_by(&cmp);
89                rest.sort_by(&cmp);
90                dirs.extend(rest);
91                dirs
92            } else {
93                entries.sort_by(&cmp);
94                entries
95            }
96        });
97
98        match drain.await {
99            Ok(entries) => {
100                for entry in entries {
101                    emit.push(entry);
102                }
103            }
104            Err(e) => tracing::warn!("sidebar directory listing drain failed: {e}"),
105        }
106        if let Err(e) = browse.await {
107            tracing::warn!("sidebar browse_vault task failed: {e}");
108        }
109        emit.done();
110    }
111
112    fn leading_row(&self, query: &str) -> Option<FileListEntry> {
113        if query.is_empty() {
114            None
115        } else {
116            let path = self.dir.append(&VaultPath::note_path_from(query)).flatten();
117            Some(FileListEntry::CreateNote {
118                filename: path.to_string(),
119                path,
120            })
121        }
122    }
123
124    fn reload_on_query(&self) -> bool {
125        // Load the directory once; the local fuzzy filter narrows it and
126        // `leading_row` keeps the create affordance in sync per keystroke.
127        false
128    }
129}
130
131pub struct SidebarComponent {
132    current_dir: VaultPath,
133    list: Option<SearchList<FileListEntry>>,
134    vault: Arc<NoteVault>,
135    icons: Icons,
136    default_sort_field: SortField,
137    default_sort_order: SortOrder,
138    journal_sort_field: SortField,
139    journal_sort_order: SortOrder,
140    /// Shared sort field/order for the active listing. `DirListingSource::load`
141    /// reads it; the sort shortcuts mutate it then reload. Re-created per
142    /// `navigate` from the per-dir defaults.
143    sort: Arc<Mutex<(SortField, SortOrder)>>,
144    /// Shared "group directories first" flag. `DirListingSource::load` reads it;
145    /// the sort dialog mutates it via `apply_sort`, then the listing reloads.
146    group_dirs: Arc<Mutex<bool>>,
147    rendered_rect: Rect,
148    key_bindings: KeyBindings,
149}
150
151impl SidebarComponent {
152    /// Build a sidebar from the application settings, pulling its key bindings
153    /// and icons from `settings`. The shared constructor for the screens that
154    /// host a sidebar (Editor and Browse), so the kb/icons wiring lives once.
155    pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
156        Self::new(
157            settings.key_bindings.clone(),
158            vault,
159            settings.icons(),
160            settings,
161        )
162    }
163
164    pub fn new(
165        key_bindings: KeyBindings,
166        vault: Arc<NoteVault>,
167        icons: Icons,
168        settings: &AppSettings,
169    ) -> Self {
170        let default_sort_field = SortField::from(settings.default_sort_field);
171        let default_sort_order = SortOrder::from(settings.default_sort_order);
172        Self {
173            current_dir: VaultPath::root(),
174            list: None,
175            vault,
176            icons,
177            default_sort_field,
178            default_sort_order,
179            journal_sort_field: SortField::from(settings.journal_sort_field),
180            journal_sort_order: SortOrder::from(settings.journal_sort_order),
181            sort: Arc::new(Mutex::new((default_sort_field, default_sort_order))),
182            group_dirs: Arc::new(Mutex::new(settings.group_directories)),
183            rendered_rect: Rect::default(),
184            key_bindings,
185        }
186    }
187
188    pub fn current_dir(&self) -> &VaultPath {
189        &self.current_dir
190    }
191
192    /// `true` until a directory has been loaded (no engine yet). The editor
193    /// uses this to decide whether to issue the first-open navigation.
194    pub fn is_empty(&self) -> bool {
195        self.list.is_none()
196    }
197
198    /// Sort field/order to apply for `dir` (journal dirs get their own).
199    fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
200        if dir == self.vault.journal_path() {
201            (self.journal_sort_field, self.journal_sort_order)
202        } else {
203            (self.default_sort_field, self.default_sort_order)
204        }
205    }
206
207    /// (Re)build the engine for `dir`, replacing any prior listing. This is the
208    /// single directory-navigation entry point: changing directory = rebuild
209    /// the engine with a fresh `DirListingSource` for the new dir.
210    pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
211        self.current_dir = dir.clone();
212        let (sort_field, sort_order) = self.sort_for(&dir);
213        self.sort = Arc::new(Mutex::new((sort_field, sort_order)));
214        let source = DirListingSource {
215            vault: self.vault.clone(),
216            dir,
217            sort: self.sort.clone(),
218            group_dirs: self.group_dirs.clone(),
219        };
220        self.list = Some(
221            SearchList::builder(source, redraw_callback(tx.clone()))
222                .filter(Filter::Fuzzy)
223                .icons(self.icons.clone())
224                .build(),
225        );
226    }
227
228    /// Current sort field/order for the active listing.
229    pub fn current_sort(&self) -> (SortField, SortOrder) {
230        *self.sort.lock().unwrap()
231    }
232
233    /// Current "group directories first" flag.
234    pub fn group_dirs(&self) -> bool {
235        *self.group_dirs.lock().unwrap()
236    }
237
238    /// Apply a sort selection from the sort dialog and reload so the source
239    /// re-orders the listing.
240    pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
241        *self.sort.lock().unwrap() = (field, order);
242        *self.group_dirs.lock().unwrap() = group_dirs;
243        if let Some(list) = &mut self.list {
244            list.reload();
245        }
246    }
247
248    /// `true` when the active directory is the journal (so its sort default is
249    /// the journal one). Lets the caller persist to the matching settings.
250    pub fn is_current_journal(&self) -> bool {
251        &self.current_dir == self.vault.journal_path()
252    }
253
254    /// Save the dialog's selection as the in-session default for the active
255    /// context (journal vs. normal), then apply it live. Without this, the
256    /// cached per-context defaults that `sort_for`/`navigate` read stay at their
257    /// construction-time values, so a saved default would have no effect until
258    /// restart. The caller is responsible for persisting to the settings file.
259    pub fn save_default(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
260        if self.is_current_journal() {
261            self.journal_sort_field = field;
262            self.journal_sort_order = order;
263        } else {
264            self.default_sort_field = field;
265            self.default_sort_order = order;
266        }
267        self.apply_sort(field, order, group_dirs);
268    }
269
270    /// Number of note rows currently visible (excludes Up / dirs / create).
271    fn note_count(&self) -> usize {
272        match &self.list {
273            None => 0,
274            Some(list) => list
275                .visible_rows()
276                .iter()
277                .filter(|e| matches!(e, FileListEntry::Note { .. }))
278                .count(),
279        }
280    }
281
282    /// Act on the selected row: Up/Note/Directory → `OpenPath` (directories and
283    /// Up route back through the editor's navigate, rebuilding the engine);
284    /// CreateNote → materialise the note, then open it.
285    fn activate_selected_entry(&self, tx: &AppTx) {
286        let Some(list) = &self.list else { return };
287        let Some(entry) = list.selected_row() else {
288            return;
289        };
290        match entry {
291            FileListEntry::CreateNote { path, .. } => {
292                let path = path.clone();
293                let vault = Arc::clone(&self.vault);
294                let tx2 = tx.clone();
295                tokio::spawn(async move {
296                    if let Err(e) = vault.load_or_create_note(&path, None).await {
297                        tracing::warn!("create note failed for {path}: {e}");
298                        return;
299                    }
300                    tx2.send(AppEvent::OpenPath(path)).ok();
301                });
302            }
303            other => {
304                tx.send(AppEvent::OpenPath(other.path().clone())).ok();
305            }
306        }
307    }
308}
309
310/// Format a `NaiveDate` as a human-readable string with day-of-week.
311/// Example: "Wednesday, March 17, 2026"
312fn format_journal_date(date: NaiveDate) -> String {
313    date.format("%A, %B %-d, %Y").to_string()
314}
315
316impl Component for SidebarComponent {
317    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
318        if let InputEvent::Mouse(mouse) = event {
319            let pos = Position {
320                x: mouse.column,
321                y: mouse.row,
322            };
323            if !self.rendered_rect.contains(pos) {
324                return EventState::NotConsumed;
325            }
326            // Any click in the sidebar focuses it.
327            if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
328                tx.send(AppEvent::FocusSidebar).ok();
329            }
330            if let Some(list) = &mut self.list {
331                match mouse.kind {
332                    // Scroll anywhere within the sidebar bounds moves the list
333                    // selection — even when the cursor is over the header or
334                    // search box (the engine only hit-tests scroll inside its
335                    // own list rect, so handle it here).
336                    MouseEventKind::ScrollUp => list.select_prev(),
337                    MouseEventKind::ScrollDown => list.select_next(),
338                    _ => match list.handle_mouse(mouse) {
339                        SearchMouse::Activated(_) => self.activate_selected_entry(tx),
340                        SearchMouse::Selected(_) | SearchMouse::Scrolled | SearchMouse::None => {}
341                    },
342                }
343            }
344            return EventState::Consumed;
345        }
346
347        if let InputEvent::Key(key) = event {
348            if self.list.is_none() {
349                return EventState::NotConsumed;
350            }
351            let reaction = self.list.as_mut().unwrap().handle_key(key);
352            match reaction {
353                KeyReaction::Submit => {
354                    self.activate_selected_entry(tx);
355                    EventState::Consumed
356                }
357                KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
358                KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
359            }
360        } else {
361            EventState::NotConsumed
362        }
363    }
364
365    fn hint_shortcuts(&self) -> Vec<(String, String)> {
366        use crate::keys::action_shortcuts::ActionShortcuts;
367
368        [
369            (ActionShortcuts::FocusEditor, "editor \u{2192}"),
370            (ActionShortcuts::OpenSortDialog, "sort"),
371        ]
372        .iter()
373        .filter_map(|(action, label)| {
374            self.key_bindings
375                .first_combo_for(action)
376                .map(|k| (k, label.to_string()))
377        })
378        .collect()
379    }
380
381    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
382        self.rendered_rect = rect;
383
384        let rows = Layout::default()
385            .direction(Direction::Vertical)
386            .constraints([
387                Constraint::Length(3),
388                Constraint::Length(3),
389                Constraint::Min(0),
390            ])
391            .split(rect);
392
393        let border_style = theme.border_style(focused);
394
395        let header = Block::default()
396            .title(self.current_dir.to_string())
397            .borders(Borders::ALL)
398            .border_style(border_style)
399            .style(theme.panel_style());
400        let header_inner = header.inner(rows[0]);
401        f.render_widget(header, rows[0]);
402        f.render_widget(
403            Paragraph::new(format!("{} notes", self.note_count())).style(
404                Style::default()
405                    .fg(theme.fg_muted.to_ratatui())
406                    .bg(theme.bg_panel.to_ratatui()),
407            ),
408            header_inner,
409        );
410
411        let search_block = Block::default()
412            .title(" Search")
413            .borders(Borders::ALL)
414            .border_style(border_style)
415            .style(theme.panel_style());
416        let search_inner = search_block.inner(rows[1]);
417        f.render_widget(search_block, rows[1]);
418
419        let list_block = Block::default()
420            .borders(Borders::ALL)
421            .border_style(border_style)
422            .style(theme.panel_style());
423        let list_inner = list_block.inner(rows[2]);
424        f.render_widget(list_block, rows[2]);
425
426        if let Some(list) = &mut self.list {
427            list.render_query(f, search_inner, theme, focused);
428            list.render(f, list_inner, theme, focused);
429            // Record the rendered-items rect (the block's inner area) for mouse
430            // hit-testing: the engine maps a click to `row - rect.y`, row 0 being
431            // the first item.
432            list.set_list_rect(list_inner);
433        }
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::settings::AppSettings;
441    use crate::test_support::{mouse_down_at, temp_vault};
442    use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
443    use tokio::sync::mpsc::unbounded_channel;
444
445    async fn make_sidebar() -> SidebarComponent {
446        let vault = temp_vault("sidebar").await;
447        vault.validate_and_init().await.unwrap();
448        let settings = AppSettings::default();
449        SidebarComponent::new(
450            settings.key_bindings.clone(),
451            vault,
452            settings.icons(),
453            &settings,
454        )
455    }
456
457    /// Build a sidebar over `vault` after creating each named note at root.
458    async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
459        let vault = temp_vault(prefix).await;
460        vault.validate_and_init().await.unwrap();
461        for name in names {
462            vault
463                .create_note(&VaultPath::note_path_from(name), "body")
464                .await
465                .unwrap();
466        }
467        let settings = AppSettings::default();
468        SidebarComponent::new(
469            settings.key_bindings.clone(),
470            vault,
471            settings.icons(),
472            &settings,
473        )
474    }
475
476    /// Regression: clicking on the sidebar header (directory name + note count)
477    /// or the search box must focus the sidebar, not just clicks on the file list.
478    #[tokio::test]
479    async fn mouse_down_on_header_focuses_sidebar() {
480        let mut sidebar = make_sidebar().await;
481        sidebar.rendered_rect = Rect {
482            x: 0,
483            y: 3,
484            width: 30,
485            height: 20,
486        };
487        let (tx, mut rx) = unbounded_channel();
488
489        // Click inside the header (top-of-sidebar) area.
490        let result = sidebar.handle_input(&mouse_down_at(5, 4), &tx);
491        assert_eq!(result, EventState::Consumed);
492        let evt = rx.try_recv().expect("should send a focus event");
493        assert!(matches!(evt, AppEvent::FocusSidebar));
494    }
495
496    #[tokio::test]
497    async fn mouse_down_on_search_box_focuses_sidebar() {
498        let mut sidebar = make_sidebar().await;
499        sidebar.rendered_rect = Rect {
500            x: 0,
501            y: 3,
502            width: 30,
503            height: 20,
504        };
505        let (tx, mut rx) = unbounded_channel();
506
507        // Click inside the search-box area (rows 6..9 within the sidebar layout).
508        let result = sidebar.handle_input(&mouse_down_at(5, 7), &tx);
509        assert_eq!(result, EventState::Consumed);
510        let evt = rx.try_recv().expect("should send a focus event");
511        assert!(matches!(evt, AppEvent::FocusSidebar));
512    }
513
514    fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
515        InputEvent::Mouse(MouseEvent {
516            kind,
517            column: col,
518            row,
519            modifiers: KeyModifiers::NONE,
520        })
521    }
522
523    /// Load the sidebar at the vault root, then poll the engine to idle so the
524    /// streamed rows have arrived.
525    async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
526        sidebar.navigate(VaultPath::root(), tx);
527        // The streamed source spawns `browse_vault` + a blocking drain; give the
528        // background work real time to land, polling the engine between waits.
529        for _ in 0..50 {
530            if let Some(list) = &mut sidebar.list {
531                list.poll();
532                if !list.is_loading() {
533                    break;
534                }
535            }
536            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
537        }
538        if let Some(list) = &mut sidebar.list {
539            list.poll();
540        }
541    }
542
543    /// Two clicks on the same list row activate it: first selects, second sends
544    /// `OpenPath` (or, for `CreateNote`, materialises the note then opens it).
545    #[tokio::test(flavor = "multi_thread")]
546    async fn mouse_double_click_on_list_row_sends_open_path() {
547        let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
548        let (tx, mut rx) = unbounded_channel();
549        navigate_to_root(&mut sidebar, &tx).await;
550
551        sidebar.rendered_rect = Rect {
552            x: 0,
553            y: 3,
554            width: 30,
555            height: 20,
556        };
557        // The engine records the rendered-items rect; clicks hit-test as
558        // `row - rect.y`, so row 0 (y=9) is the first item.
559        if let Some(list) = &mut sidebar.list {
560            list.set_list_rect(Rect {
561                x: 0,
562                y: 9,
563                width: 30,
564                height: 14,
565            });
566        }
567
568        // First click: in the list area, on the first row (rect.y).
569        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
570        // Drain the FocusSidebar event from the first click.
571        let _ = rx.try_recv();
572
573        // Second click on the same row activates the entry.
574        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
575        let mut events = Vec::new();
576        while let Ok(evt) = rx.try_recv() {
577            events.push(evt);
578        }
579        assert!(
580            events
581                .iter()
582                .any(|e| matches!(e, AppEvent::OpenPath(p) if p.to_string().contains("alpha"))),
583            "expected OpenPath for the activated note, got {events:?}"
584        );
585    }
586
587    /// Scroll wheel anywhere in the sidebar bounds scrolls the file list — even
588    /// when the cursor is over the header or search box.
589    #[tokio::test(flavor = "multi_thread")]
590    async fn scroll_down_in_sidebar_bounds_scrolls_list() {
591        let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
592        let (tx, _rx) = unbounded_channel();
593        navigate_to_root(&mut sidebar, &tx).await;
594
595        sidebar.rendered_rect = Rect {
596            x: 0,
597            y: 3,
598            width: 30,
599            height: 20,
600        };
601        if let Some(list) = &mut sidebar.list {
602            list.set_list_rect(Rect {
603                x: 0,
604                y: 9,
605                width: 30,
606                height: 14,
607            });
608        }
609
610        let first = sidebar
611            .list
612            .as_ref()
613            .unwrap()
614            .selected_row()
615            .map(|e| e.path().to_string());
616
617        // Scroll down with the cursor inside the sidebar header (not the list).
618        let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
619        assert_eq!(result, EventState::Consumed);
620        let after = sidebar
621            .list
622            .as_ref()
623            .unwrap()
624            .selected_row()
625            .map(|e| e.path().to_string());
626        assert_ne!(first, after, "scroll-from-header should move the selection");
627    }
628
629    #[tokio::test]
630    async fn mouse_down_outside_sidebar_is_not_consumed() {
631        let mut sidebar = make_sidebar().await;
632        sidebar.rendered_rect = Rect {
633            x: 0,
634            y: 3,
635            width: 30,
636            height: 20,
637        };
638        let (tx, mut rx) = unbounded_channel();
639
640        // Click to the right of the sidebar (in the editor area).
641        let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
642        assert_eq!(result, EventState::NotConsumed);
643        assert!(rx.try_recv().is_err());
644    }
645
646    /// Navigating loads the directory's notes via the streamed source.
647    #[tokio::test(flavor = "multi_thread")]
648    async fn navigate_loads_directory_notes() {
649        let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
650        assert!(sidebar.is_empty());
651        let (tx, _rx) = unbounded_channel();
652        navigate_to_root(&mut sidebar, &tx).await;
653        assert!(!sidebar.is_empty());
654        assert_eq!(sidebar.note_count(), 1);
655    }
656
657    /// Poll the (already-navigated) engine to idle so a reload's streamed rows
658    /// have arrived.
659    async fn poll_to_idle(sidebar: &mut SidebarComponent) {
660        for _ in 0..50 {
661            if let Some(list) = &mut sidebar.list {
662                list.poll();
663                if !list.is_loading() {
664                    break;
665                }
666            }
667            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
668        }
669        if let Some(list) = &mut sidebar.list {
670            list.poll();
671        }
672    }
673
674    /// Names of the visible note rows, in display order.
675    fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
676        sidebar
677            .list
678            .as_ref()
679            .unwrap()
680            .visible_rows()
681            .iter()
682            .filter_map(|e| match e {
683                FileListEntry::Note { filename, .. } => Some(filename.clone()),
684                _ => None,
685            })
686            .collect()
687    }
688
689    #[tokio::test(flavor = "multi_thread")]
690    async fn apply_sort_reverse_flips_listing_order() {
691        let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
692        let (tx, _rx) = unbounded_channel();
693        navigate_to_root(&mut sidebar, &tx).await;
694        let before = note_names(&sidebar);
695        assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
696        sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
697        poll_to_idle(&mut sidebar).await;
698        let after = note_names(&sidebar);
699        assert_eq!(
700            after,
701            before.iter().rev().cloned().collect::<Vec<_>>(),
702            "descending order should reverse the listing"
703        );
704    }
705
706    #[tokio::test(flavor = "multi_thread")]
707    async fn apply_sort_changes_field() {
708        let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
709        let (tx, _rx) = unbounded_channel();
710        navigate_to_root(&mut sidebar, &tx).await;
711        sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
712        poll_to_idle(&mut sidebar).await;
713        assert_eq!(sidebar.current_sort().0, SortField::Title);
714        assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
715    }
716
717    /// Build a sidebar over a vault with both notes and a subdirectory.
718    async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
719        let vault = temp_vault(prefix).await;
720        vault.validate_and_init().await.unwrap();
721        vault
722            .create_note(&VaultPath::note_path_from("alpha"), "body")
723            .await
724            .unwrap();
725        vault
726            .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
727            .await
728            .unwrap();
729        let settings = AppSettings::default();
730        SidebarComponent::new(
731            settings.key_bindings.clone(),
732            vault,
733            settings.icons(),
734            &settings,
735        )
736    }
737
738    /// Kinds of the visible rows, in display order (excluding the Up row).
739    fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
740        sidebar
741            .list
742            .as_ref()
743            .unwrap()
744            .visible_rows()
745            .iter()
746            .filter_map(|e| match e {
747                FileListEntry::Note { .. } => Some("note"),
748                FileListEntry::Directory { .. } => Some("dir"),
749                _ => None,
750            })
751            .collect()
752    }
753
754    #[tokio::test(flavor = "multi_thread")]
755    async fn group_dirs_puts_directories_first() {
756        let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
757        let (tx, _rx) = unbounded_channel();
758        navigate_to_root(&mut sidebar, &tx).await;
759        assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
760        sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
761        poll_to_idle(&mut sidebar).await;
762        assert_eq!(
763            row_kinds(&sidebar),
764            vec!["dir", "note"],
765            "grouping must cluster directories first"
766        );
767    }
768
769    #[tokio::test(flavor = "multi_thread")]
770    async fn apply_sort_updates_shared_state() {
771        let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
772        let (tx, _rx) = unbounded_channel();
773        navigate_to_root(&mut sidebar, &tx).await;
774        sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
775        poll_to_idle(&mut sidebar).await;
776        assert_eq!(
777            sidebar.current_sort(),
778            (SortField::Title, SortOrder::Descending)
779        );
780        assert!(!sidebar.group_dirs());
781    }
782
783    /// Regression: saving a default must survive navigation. `save_default`
784    /// updates the cached per-context default that `sort_for`/`navigate` read;
785    /// without it, navigating re-derives the construction-time default and the
786    /// saved choice is silently lost.
787    #[tokio::test(flavor = "multi_thread")]
788    async fn save_default_survives_navigation() {
789        let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
790        let (tx, _rx) = unbounded_channel();
791        navigate_to_root(&mut sidebar, &tx).await;
792
793        sidebar.save_default(SortField::Title, SortOrder::Descending, false);
794        poll_to_idle(&mut sidebar).await;
795
796        // Re-navigate (root is non-journal) — sort_for must now yield the saved
797        // default, not the constructor-time (Name, Ascending).
798        sidebar.navigate(VaultPath::root(), &tx);
799        poll_to_idle(&mut sidebar).await;
800        assert_eq!(
801            sidebar.current_sort(),
802            (SortField::Title, SortOrder::Descending),
803            "saved default must persist across navigation"
804        );
805    }
806}