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                    SearchMouse::Selected(_) | SearchMouse::Scrolled | SearchMouse::None => {}
334                }
335            }
336            return EventState::Consumed;
337        }
338
339        if let InputEvent::Key(key) = event {
340            if self.list.is_none() {
341                return EventState::NotConsumed;
342            }
343            let reaction = self.list.as_mut().unwrap().handle_key(key);
344            match reaction {
345                KeyReaction::Submit => {
346                    self.activate_selected_entry(tx);
347                    EventState::Consumed
348                }
349                KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
350                KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
351            }
352        } else {
353            EventState::NotConsumed
354        }
355    }
356
357    fn hint_shortcuts(&self) -> Vec<(String, String)> {
358        use crate::keys::action_shortcuts::ActionShortcuts;
359
360        [
361            (ActionShortcuts::FocusEditor, "editor \u{2192}"),
362            (ActionShortcuts::OpenSortDialog, "sort"),
363        ]
364        .iter()
365        .filter_map(|(action, label)| {
366            self.key_bindings
367                .first_combo_for(action)
368                .map(|k| (k, label.to_string()))
369        })
370        .collect()
371    }
372
373    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
374        self.rendered_rect = rect;
375
376        let rows = Layout::default()
377            .direction(Direction::Vertical)
378            .constraints([
379                Constraint::Length(3),
380                Constraint::Length(3),
381                Constraint::Min(0),
382            ])
383            .split(rect);
384
385        let border_style = theme.border_style(focused);
386
387        let header = Block::default()
388            .title(self.current_dir.to_string())
389            .borders(Borders::ALL)
390            .border_style(border_style)
391            .style(theme.panel_style());
392        let header_inner = header.inner(rows[0]);
393        f.render_widget(header, rows[0]);
394        f.render_widget(
395            Paragraph::new(format!("{} notes", self.note_count())).style(
396                Style::default()
397                    .fg(theme.fg_muted.to_ratatui())
398                    .bg(theme.bg_panel.to_ratatui()),
399            ),
400            header_inner,
401        );
402
403        let search_block = Block::default()
404            .title(" Search")
405            .borders(Borders::ALL)
406            .border_style(border_style)
407            .style(theme.panel_style());
408        let search_inner = search_block.inner(rows[1]);
409        f.render_widget(search_block, rows[1]);
410
411        let list_block = Block::default()
412            .borders(Borders::ALL)
413            .border_style(border_style)
414            .style(theme.panel_style());
415        let list_inner = list_block.inner(rows[2]);
416        f.render_widget(list_block, rows[2]);
417
418        if let Some(list) = &mut self.list {
419            list.render_query(f, search_inner, theme, focused);
420            list.render(f, list_inner, theme, focused);
421            // Record the rendered-items rect (the block's inner area) for mouse
422            // hit-testing: the engine maps a click to `row - rect.y`, row 0 being
423            // the first item. The panel rect makes the wheel scroll from anywhere
424            // within the sidebar.
425            list.set_list_rect(list_inner);
426            list.set_panel_rect(rect);
427        }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::settings::AppSettings;
435    use crate::test_support::{mouse_down_at, temp_vault};
436    use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
437    use tokio::sync::mpsc::unbounded_channel;
438
439    async fn make_sidebar() -> SidebarComponent {
440        let vault = temp_vault("sidebar").await;
441        vault.validate_and_init().await.unwrap();
442        let settings = AppSettings::default();
443        SidebarComponent::new(
444            settings.key_bindings.clone(),
445            vault,
446            settings.icons(),
447            &settings,
448        )
449    }
450
451    /// Build a sidebar over `vault` after creating each named note at root.
452    async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
453        let vault = temp_vault(prefix).await;
454        vault.validate_and_init().await.unwrap();
455        for name in names {
456            vault
457                .create_note(&VaultPath::note_path_from(name), "body")
458                .await
459                .unwrap();
460        }
461        let settings = AppSettings::default();
462        SidebarComponent::new(
463            settings.key_bindings.clone(),
464            vault,
465            settings.icons(),
466            &settings,
467        )
468    }
469
470    /// Clicks anywhere in the sidebar bounds — header, search box, list — are
471    /// consumed by the sidebar. (Click-to-focus itself is handled centrally by
472    /// `PanelSet::handle_mouse`, not here.)
473    #[tokio::test]
474    async fn mouse_down_in_sidebar_bounds_is_consumed() {
475        let mut sidebar = make_sidebar().await;
476        sidebar.rendered_rect = Rect {
477            x: 0,
478            y: 3,
479            width: 30,
480            height: 20,
481        };
482        let (tx, _rx) = unbounded_channel();
483
484        // Header (top-of-sidebar) area.
485        assert_eq!(
486            sidebar.handle_input(&mouse_down_at(5, 4), &tx),
487            EventState::Consumed
488        );
489        // Search-box area (rows 6..9 within the sidebar layout).
490        assert_eq!(
491            sidebar.handle_input(&mouse_down_at(5, 7), &tx),
492            EventState::Consumed
493        );
494        // Outside the sidebar bounds.
495        assert_eq!(
496            sidebar.handle_input(&mouse_down_at(40, 7), &tx),
497            EventState::NotConsumed
498        );
499    }
500
501    fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
502        InputEvent::Mouse(MouseEvent {
503            kind,
504            column: col,
505            row,
506            modifiers: KeyModifiers::NONE,
507        })
508    }
509
510    /// Load the sidebar at the vault root, then poll the engine to idle so the
511    /// streamed rows have arrived.
512    async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
513        sidebar.navigate(VaultPath::root(), tx);
514        // The streamed source spawns `browse_vault` + a blocking drain; give the
515        // background work real time to land, polling the engine between waits.
516        for _ in 0..50 {
517            if let Some(list) = &mut sidebar.list {
518                list.poll();
519                if !list.is_loading() {
520                    break;
521                }
522            }
523            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
524        }
525        if let Some(list) = &mut sidebar.list {
526            list.poll();
527        }
528    }
529
530    /// Two clicks on the same list row activate it: first selects, second sends
531    /// `OpenPath` (or, for `CreateNote`, materialises the note then opens it).
532    #[tokio::test(flavor = "multi_thread")]
533    async fn mouse_double_click_on_list_row_sends_open_path() {
534        let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
535        let (tx, mut rx) = unbounded_channel();
536        navigate_to_root(&mut sidebar, &tx).await;
537
538        sidebar.rendered_rect = Rect {
539            x: 0,
540            y: 3,
541            width: 30,
542            height: 20,
543        };
544        // The engine records the rendered-items rect; clicks hit-test as
545        // `row - rect.y`, so row 0 (y=9) is the first item.
546        if let Some(list) = &mut sidebar.list {
547            list.set_list_rect(Rect {
548                x: 0,
549                y: 9,
550                width: 30,
551                height: 14,
552            });
553        }
554
555        // First click: in the list area, on the first row (rect.y) — selects.
556        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
557
558        // Second click on the same row activates the entry.
559        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
560        let mut events = Vec::new();
561        while let Ok(evt) = rx.try_recv() {
562            events.push(evt);
563        }
564        assert!(
565            events
566                .iter()
567                .any(|e| matches!(e, AppEvent::OpenPath(p) if p.to_string().contains("alpha"))),
568            "expected OpenPath for the activated note, got {events:?}"
569        );
570    }
571
572    /// Scroll wheel anywhere in the sidebar bounds scrolls the file list — even
573    /// when the cursor is over the header or search box. The viewport moves and
574    /// the selection is carried along (keeping its screen position), so with a
575    /// 1-row viewport the selected row changes on the first scroll.
576    #[tokio::test(flavor = "multi_thread")]
577    async fn scroll_down_in_sidebar_bounds_scrolls_list() {
578        let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
579        let (tx, _rx) = unbounded_channel();
580        navigate_to_root(&mut sidebar, &tx).await;
581
582        sidebar.rendered_rect = Rect {
583            x: 0,
584            y: 3,
585            width: 30,
586            height: 20,
587        };
588        // A 1-row viewport over 2 notes, so the list overflows and can scroll.
589        // The panel rect covers the whole sidebar, so the wheel works from the
590        // header/search box too.
591        if let Some(list) = &mut sidebar.list {
592            list.set_list_rect(Rect {
593                x: 0,
594                y: 9,
595                width: 30,
596                height: 1,
597            });
598            list.set_panel_rect(Rect {
599                x: 0,
600                y: 3,
601                width: 30,
602                height: 20,
603            });
604        }
605
606        let first = sidebar
607            .list
608            .as_ref()
609            .unwrap()
610            .selected_row()
611            .map(|e| e.path().to_string());
612
613        // Scroll down with the cursor inside the sidebar header (not the list).
614        let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
615        assert_eq!(result, EventState::Consumed);
616        let after = sidebar
617            .list
618            .as_ref()
619            .unwrap()
620            .selected_row()
621            .map(|e| e.path().to_string());
622        assert_ne!(
623            first, after,
624            "scroll-from-header should scroll the list, carrying the selection"
625        );
626    }
627
628    #[tokio::test]
629    async fn mouse_down_outside_sidebar_is_not_consumed() {
630        let mut sidebar = make_sidebar().await;
631        sidebar.rendered_rect = Rect {
632            x: 0,
633            y: 3,
634            width: 30,
635            height: 20,
636        };
637        let (tx, mut rx) = unbounded_channel();
638
639        // Click to the right of the sidebar (in the editor area).
640        let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
641        assert_eq!(result, EventState::NotConsumed);
642        assert!(rx.try_recv().is_err());
643    }
644
645    /// Navigating loads the directory's notes via the streamed source.
646    #[tokio::test(flavor = "multi_thread")]
647    async fn navigate_loads_directory_notes() {
648        let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
649        assert!(sidebar.is_empty());
650        let (tx, _rx) = unbounded_channel();
651        navigate_to_root(&mut sidebar, &tx).await;
652        assert!(!sidebar.is_empty());
653        assert_eq!(sidebar.note_count(), 1);
654    }
655
656    /// Poll the (already-navigated) engine to idle so a reload's streamed rows
657    /// have arrived.
658    async fn poll_to_idle(sidebar: &mut SidebarComponent) {
659        for _ in 0..50 {
660            if let Some(list) = &mut sidebar.list {
661                list.poll();
662                if !list.is_loading() {
663                    break;
664                }
665            }
666            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
667        }
668        if let Some(list) = &mut sidebar.list {
669            list.poll();
670        }
671    }
672
673    /// Names of the visible note rows, in display order.
674    fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
675        sidebar
676            .list
677            .as_ref()
678            .unwrap()
679            .visible_rows()
680            .iter()
681            .filter_map(|e| match e {
682                FileListEntry::Note { filename, .. } => Some(filename.clone()),
683                _ => None,
684            })
685            .collect()
686    }
687
688    #[tokio::test(flavor = "multi_thread")]
689    async fn apply_sort_reverse_flips_listing_order() {
690        let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
691        let (tx, _rx) = unbounded_channel();
692        navigate_to_root(&mut sidebar, &tx).await;
693        let before = note_names(&sidebar);
694        assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
695        sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
696        poll_to_idle(&mut sidebar).await;
697        let after = note_names(&sidebar);
698        assert_eq!(
699            after,
700            before.iter().rev().cloned().collect::<Vec<_>>(),
701            "descending order should reverse the listing"
702        );
703    }
704
705    #[tokio::test(flavor = "multi_thread")]
706    async fn apply_sort_changes_field() {
707        let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
708        let (tx, _rx) = unbounded_channel();
709        navigate_to_root(&mut sidebar, &tx).await;
710        sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
711        poll_to_idle(&mut sidebar).await;
712        assert_eq!(sidebar.current_sort().0, SortField::Title);
713        assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
714    }
715
716    /// Build a sidebar over a vault with both notes and a subdirectory.
717    async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
718        let vault = temp_vault(prefix).await;
719        vault.validate_and_init().await.unwrap();
720        vault
721            .create_note(&VaultPath::note_path_from("alpha"), "body")
722            .await
723            .unwrap();
724        vault
725            .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
726            .await
727            .unwrap();
728        let settings = AppSettings::default();
729        SidebarComponent::new(
730            settings.key_bindings.clone(),
731            vault,
732            settings.icons(),
733            &settings,
734        )
735    }
736
737    /// Kinds of the visible rows, in display order (excluding the Up row).
738    fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
739        sidebar
740            .list
741            .as_ref()
742            .unwrap()
743            .visible_rows()
744            .iter()
745            .filter_map(|e| match e {
746                FileListEntry::Note { .. } => Some("note"),
747                FileListEntry::Directory { .. } => Some("dir"),
748                _ => None,
749            })
750            .collect()
751    }
752
753    #[tokio::test(flavor = "multi_thread")]
754    async fn group_dirs_puts_directories_first() {
755        let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
756        let (tx, _rx) = unbounded_channel();
757        navigate_to_root(&mut sidebar, &tx).await;
758        assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
759        sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
760        poll_to_idle(&mut sidebar).await;
761        assert_eq!(
762            row_kinds(&sidebar),
763            vec!["dir", "note"],
764            "grouping must cluster directories first"
765        );
766    }
767
768    #[tokio::test(flavor = "multi_thread")]
769    async fn apply_sort_updates_shared_state() {
770        let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
771        let (tx, _rx) = unbounded_channel();
772        navigate_to_root(&mut sidebar, &tx).await;
773        sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
774        poll_to_idle(&mut sidebar).await;
775        assert_eq!(
776            sidebar.current_sort(),
777            (SortField::Title, SortOrder::Descending)
778        );
779        assert!(!sidebar.group_dirs());
780    }
781
782    /// Regression: saving a default must survive navigation. `save_default`
783    /// updates the cached per-context default that `sort_for`/`navigate` read;
784    /// without it, navigating re-derives the construction-time default and the
785    /// saved choice is silently lost.
786    #[tokio::test(flavor = "multi_thread")]
787    async fn save_default_survives_navigation() {
788        let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
789        let (tx, _rx) = unbounded_channel();
790        navigate_to_root(&mut sidebar, &tx).await;
791
792        sidebar.save_default(SortField::Title, SortOrder::Descending, false);
793        poll_to_idle(&mut sidebar).await;
794
795        // Re-navigate (root is non-journal) — sort_for must now yield the saved
796        // default, not the constructor-time (Name, Ascending).
797        sidebar.navigate(VaultPath::root(), &tx);
798        poll_to_idle(&mut sidebar).await;
799        assert_eq!(
800            sidebar.current_sort(),
801            (SortField::Title, SortOrder::Descending),
802            "saved default must persist across navigation"
803        );
804    }
805}