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