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::text::{Line, Span};
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    /// Screen cell each breadcrumb segment was drawn into on the last render,
149    /// with the directory it navigates to — clickable breadcrumb hit-test.
150    breadcrumb_cells: Vec<(Rect, VaultPath)>,
151    key_bindings: KeyBindings,
152}
153
154impl SidebarComponent {
155    /// Build a sidebar from the application settings, pulling its key bindings
156    /// and icons from `settings`. The shared constructor for the screens that
157    /// host a sidebar (Editor and Browse), so the kb/icons wiring lives once.
158    pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
159        Self::new(
160            settings.key_bindings.clone(),
161            vault,
162            settings.icons(),
163            settings,
164        )
165    }
166
167    pub fn new(
168        key_bindings: KeyBindings,
169        vault: Arc<NoteVault>,
170        icons: Icons,
171        settings: &AppSettings,
172    ) -> Self {
173        let default_sort_field = SortField::from(settings.default_sort_field);
174        let default_sort_order = SortOrder::from(settings.default_sort_order);
175        Self {
176            current_dir: VaultPath::root(),
177            list: None,
178            vault,
179            icons,
180            default_sort_field,
181            default_sort_order,
182            journal_sort_field: SortField::from(settings.journal_sort_field),
183            journal_sort_order: SortOrder::from(settings.journal_sort_order),
184            sort: Arc::new(Mutex::new((default_sort_field, default_sort_order))),
185            group_dirs: Arc::new(Mutex::new(settings.group_directories)),
186            rendered_rect: Rect::default(),
187            breadcrumb_cells: Vec::new(),
188            key_bindings,
189        }
190    }
191
192    /// The breadcrumb segment under the given screen cell, if any.
193    fn breadcrumb_at(&self, column: u16, row: u16) -> Option<&VaultPath> {
194        self.breadcrumb_cells
195            .iter()
196            .find(|(rect, _)| rect.contains(Position { x: column, y: row }))
197            .map(|(_, dir)| dir)
198    }
199
200    pub fn current_dir(&self) -> &VaultPath {
201        &self.current_dir
202    }
203
204    /// `true` until a directory has been loaded (no engine yet). The editor
205    /// uses this to decide whether to issue the first-open navigation.
206    pub fn is_empty(&self) -> bool {
207        self.list.is_none()
208    }
209
210    /// Sort field/order to apply for `dir` (journal dirs get their own).
211    fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
212        if dir == self.vault.journal_path() {
213            (self.journal_sort_field, self.journal_sort_order)
214        } else {
215            (self.default_sort_field, self.default_sort_order)
216        }
217    }
218
219    /// (Re)build the engine for `dir`, replacing any prior listing. This is the
220    /// single directory-navigation entry point: changing directory = rebuild
221    /// the engine with a fresh `DirListingSource` for the new dir.
222    pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
223        self.current_dir = dir.clone();
224        let (sort_field, sort_order) = self.sort_for(&dir);
225        self.sort = Arc::new(Mutex::new((sort_field, sort_order)));
226        let source = DirListingSource {
227            vault: self.vault.clone(),
228            dir,
229            sort: self.sort.clone(),
230            group_dirs: self.group_dirs.clone(),
231        };
232        self.list = Some(
233            SearchList::builder(source, redraw_callback(tx.clone()))
234                .filter(Filter::Fuzzy)
235                .icons(self.icons.clone())
236                .build(),
237        );
238    }
239
240    /// Current sort field/order for the active listing.
241    pub fn current_sort(&self) -> (SortField, SortOrder) {
242        *self.sort.lock().unwrap()
243    }
244
245    /// Current "group directories first" flag.
246    pub fn group_dirs(&self) -> bool {
247        *self.group_dirs.lock().unwrap()
248    }
249
250    /// Apply a sort selection from the sort dialog and reload so the source
251    /// re-orders the listing.
252    pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
253        *self.sort.lock().unwrap() = (field, order);
254        *self.group_dirs.lock().unwrap() = group_dirs;
255        if let Some(list) = &mut self.list {
256            list.reload();
257        }
258    }
259
260    /// `true` when the active directory is the journal (so its sort default is
261    /// the journal one). Lets the caller persist to the matching settings.
262    pub fn is_current_journal(&self) -> bool {
263        &self.current_dir == self.vault.journal_path()
264    }
265
266    /// Save the dialog's selection as the in-session default for the active
267    /// context (journal vs. normal), then apply it live. Without this, the
268    /// cached per-context defaults that `sort_for`/`navigate` read stay at their
269    /// construction-time values, so a saved default would have no effect until
270    /// restart. The caller is responsible for persisting to the settings file.
271    pub fn save_default(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
272        if self.is_current_journal() {
273            self.journal_sort_field = field;
274            self.journal_sort_order = order;
275        } else {
276            self.default_sort_field = field;
277            self.default_sort_order = order;
278        }
279        self.apply_sort(field, order, group_dirs);
280    }
281
282    /// Number of note rows currently visible (excludes Up / dirs / create).
283    fn note_count(&self) -> usize {
284        match &self.list {
285            None => 0,
286            Some(list) => list
287                .visible_rows()
288                .iter()
289                .filter(|e| matches!(e, FileListEntry::Note { .. }))
290                .count(),
291        }
292    }
293
294    /// Act on the selected row: Up/Note/Directory → `OpenPath` (directories and
295    /// Up route back through the editor's navigate, rebuilding the engine);
296    /// CreateNote → materialise the note, then open it.
297    fn activate_selected_entry(&self, tx: &AppTx) {
298        let Some(list) = &self.list else { return };
299        let Some(entry) = list.selected_row() else {
300            return;
301        };
302        match entry {
303            FileListEntry::CreateNote { path, .. } => {
304                let path = path.clone();
305                let vault = Arc::clone(&self.vault);
306                let tx2 = tx.clone();
307                tokio::spawn(async move {
308                    if let Err(e) = vault.load_or_create_note(&path, None).await {
309                        tracing::warn!("create note failed for {path}: {e}");
310                        return;
311                    }
312                    tx2.send(AppEvent::open(path)).ok();
313                });
314            }
315            other => {
316                tx.send(AppEvent::open(other.path().clone())).ok();
317            }
318        }
319    }
320}
321
322/// Format a `NaiveDate` as a human-readable string with day-of-week.
323/// Example: "Wednesday, March 17, 2026"
324fn format_journal_date(date: NaiveDate) -> String {
325    date.format("%A, %B %-d, %Y").to_string()
326}
327
328impl Component for SidebarComponent {
329    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
330        if let InputEvent::Mouse(mouse) = event {
331            let pos = Position {
332                x: mouse.column,
333                y: mouse.row,
334            };
335            if !self.rendered_rect.contains(pos) {
336                return EventState::NotConsumed;
337            }
338            // A click on a breadcrumb segment jumps up the tree.
339            if matches!(
340                mouse.kind,
341                ratatui::crossterm::event::MouseEventKind::Down(
342                    ratatui::crossterm::event::MouseButton::Left
343                )
344            ) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
345            {
346                tx.send(AppEvent::open(dir.clone())).ok();
347                return EventState::Consumed;
348            }
349            // Click-to-focus is handled centrally by `PanelSet::handle_mouse`;
350            // only the sidebar's internal behavior lives here. The engine
351            // hit-tests the wheel against the recorded panel rect (the whole
352            // sidebar — header and search box included) and clicks against
353            // the list rect.
354            if let Some(list) = &mut self.list {
355                match list.handle_mouse(mouse) {
356                    SearchMouse::Activated(_) => self.activate_selected_entry(tx),
357                    // Right-click on a file/dir row → context menu (spec §10).
358                    SearchMouse::Context(_) => {
359                        if let Some(entry) = list.selected_row()
360                            && !matches!(
361                                entry,
362                                FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
363                            )
364                        {
365                            tx.send(AppEvent::ShowFileOpsMenu(entry.path().clone()))
366                                .ok();
367                        }
368                    }
369                    // ContentScroll* are unreachable: this host records no
370                    // content sub-region.
371                    SearchMouse::Selected(_)
372                    | SearchMouse::Scrolled
373                    | SearchMouse::ContentScrollUp
374                    | SearchMouse::ContentScrollDown
375                    | SearchMouse::None => {}
376                }
377            }
378            return EventState::Consumed;
379        }
380
381        if let InputEvent::Key(key) = event {
382            if self.list.is_none() {
383                return EventState::NotConsumed;
384            }
385            let reaction = self.list.as_mut().unwrap().handle_key(key);
386            match reaction {
387                KeyReaction::Submit => {
388                    self.activate_selected_entry(tx);
389                    EventState::Consumed
390                }
391                KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
392                KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
393            }
394        } else {
395            EventState::NotConsumed
396        }
397    }
398
399    fn hint_shortcuts(&self) -> Vec<(String, String)> {
400        use crate::keys::action_shortcuts::ActionShortcuts;
401
402        crate::components::hints::hints_for(
403            &self.key_bindings,
404            &[
405                (ActionShortcuts::FocusEditor, "editor \u{2192}"),
406                (ActionShortcuts::OpenSortDialog, "sort"),
407            ],
408        )
409    }
410
411    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
412        self.rendered_rect = rect;
413
414        let rows = Layout::default()
415            .direction(Direction::Vertical)
416            .constraints([
417                Constraint::Length(3),
418                Constraint::Length(3),
419                Constraint::Min(0),
420            ])
421            .split(rect);
422
423        let border_style = theme.border_style(focused);
424
425        let header = Block::default()
426            .title(format!("─ Files · {} ", self.current_dir))
427            .borders(Borders::ALL)
428            .border_style(border_style)
429            .style(theme.panel_style());
430        let header_inner = header.inner(rows[0]);
431        f.render_widget(header, rows[0]);
432
433        // Clickable breadcrumb: one span per ancestor directory, separated by
434        // " / ", with the note count right-aligned. Each segment's cell is
435        // recorded for the click hit-test.
436        self.breadcrumb_cells.clear();
437        let seg_style = Style::default()
438            .fg(theme.fg_secondary.to_ratatui())
439            .bg(theme.bg_panel.to_ratatui());
440        let sep_style = Style::default()
441            .fg(theme.gray.to_ratatui())
442            .bg(theme.bg_panel.to_ratatui());
443        let mut spans: Vec<Span> = Vec::new();
444        let mut x = header_inner.x;
445        let mut push_segment =
446            |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
447                let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
448                // Only record cells that are (at least partly) visible — the
449                // Paragraph clips at the header edge, so fully clipped
450                // segments must not be clickable.
451                if *x < header_inner.right() {
452                    let visible = w.min(header_inner.right() - *x);
453                    self.breadcrumb_cells
454                        .push((Rect::new(*x, header_inner.y, visible, 1), dir));
455                }
456                spans.push(Span::styled(label, seg_style));
457                *x += w;
458            };
459        push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
460        let slices = self.current_dir.get_slices();
461        let mut acc = String::new();
462        for slice in &slices {
463            spans.push(Span::styled(" / ", sep_style));
464            x += 3;
465            acc.push('/');
466            acc.push_str(slice);
467            push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
468        }
469        let count = format!("{} notes", self.note_count());
470        let used: u16 = x - header_inner.x;
471        let pad = header_inner
472            .width
473            .saturating_sub(used)
474            .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
475        spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
476        spans.push(Span::styled(count, sep_style));
477        f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
478
479        let search_block = Block::default()
480            .title(" Search")
481            .borders(Borders::ALL)
482            .border_style(border_style)
483            .style(theme.panel_style());
484        let search_inner = search_block.inner(rows[1]);
485        f.render_widget(search_block, rows[1]);
486
487        let list_block = Block::default()
488            .borders(Borders::ALL)
489            .border_style(border_style)
490            .style(theme.panel_style());
491        let list_inner = list_block.inner(rows[2]);
492        f.render_widget(list_block, rows[2]);
493
494        if let Some(list) = &mut self.list {
495            list.render_query(f, search_inner, theme, focused);
496            list.render(f, list_inner, theme, focused);
497            // Record the rendered-items rect (the block's inner area) for mouse
498            // hit-testing: the engine maps a click to `row - rect.y`, row 0 being
499            // the first item. The panel rect makes the wheel scroll from anywhere
500            // within the sidebar.
501            list.set_list_rect(list_inner);
502            list.set_panel_rect(rect);
503        }
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::settings::AppSettings;
511    use crate::test_support::{mouse_down_at, temp_vault};
512    use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
513    use tokio::sync::mpsc::unbounded_channel;
514
515    async fn make_sidebar() -> SidebarComponent {
516        let vault = temp_vault("sidebar").await;
517        vault.validate_and_init().await.unwrap();
518        let settings = AppSettings::default();
519        SidebarComponent::new(
520            settings.key_bindings.clone(),
521            vault,
522            settings.icons(),
523            &settings,
524        )
525    }
526
527    /// Build a sidebar over `vault` after creating each named note at root.
528    async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
529        let vault = temp_vault(prefix).await;
530        vault.validate_and_init().await.unwrap();
531        for name in names {
532            vault
533                .create_note(&VaultPath::note_path_from(name), "body")
534                .await
535                .unwrap();
536        }
537        let settings = AppSettings::default();
538        SidebarComponent::new(
539            settings.key_bindings.clone(),
540            vault,
541            settings.icons(),
542            &settings,
543        )
544    }
545
546    /// Clicks anywhere in the sidebar bounds — header, search box, list — are
547    /// consumed by the sidebar. (Click-to-focus itself is handled centrally by
548    /// `PanelSet::handle_mouse`, not here.)
549    #[tokio::test]
550    async fn mouse_down_in_sidebar_bounds_is_consumed() {
551        let mut sidebar = make_sidebar().await;
552        sidebar.rendered_rect = Rect {
553            x: 0,
554            y: 3,
555            width: 30,
556            height: 20,
557        };
558        let (tx, _rx) = unbounded_channel();
559
560        // Header (top-of-sidebar) area.
561        assert_eq!(
562            sidebar.handle_input(&mouse_down_at(5, 4), &tx),
563            EventState::Consumed
564        );
565        // Search-box area (rows 6..9 within the sidebar layout).
566        assert_eq!(
567            sidebar.handle_input(&mouse_down_at(5, 7), &tx),
568            EventState::Consumed
569        );
570        // Outside the sidebar bounds.
571        assert_eq!(
572            sidebar.handle_input(&mouse_down_at(40, 7), &tx),
573            EventState::NotConsumed
574        );
575    }
576
577    fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
578        InputEvent::Mouse(MouseEvent {
579            kind,
580            column: col,
581            row,
582            modifiers: KeyModifiers::NONE,
583        })
584    }
585
586    /// Load the sidebar at the vault root, then poll the engine to idle so the
587    /// streamed rows have arrived.
588    async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
589        sidebar.navigate(VaultPath::root(), tx);
590        // The streamed source spawns `browse_vault` + a blocking drain; give the
591        // background work real time to land, polling the engine between waits.
592        for _ in 0..50 {
593            if let Some(list) = &mut sidebar.list {
594                list.poll();
595                if !list.is_loading() {
596                    break;
597                }
598            }
599            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
600        }
601        if let Some(list) = &mut sidebar.list {
602            list.poll();
603        }
604    }
605
606    /// Two clicks on the same list row activate it: first selects, second sends
607    /// `OpenPath` (or, for `CreateNote`, materialises the note then opens it).
608    #[tokio::test(flavor = "multi_thread")]
609    async fn mouse_double_click_on_list_row_sends_open_path() {
610        let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
611        let (tx, mut rx) = unbounded_channel();
612        navigate_to_root(&mut sidebar, &tx).await;
613
614        sidebar.rendered_rect = Rect {
615            x: 0,
616            y: 3,
617            width: 30,
618            height: 20,
619        };
620        // The engine records the rendered-items rect; clicks hit-test as
621        // `row - rect.y`, so row 0 (y=9) is the first item.
622        if let Some(list) = &mut sidebar.list {
623            list.set_list_rect(Rect {
624                x: 0,
625                y: 9,
626                width: 30,
627                height: 14,
628            });
629        }
630
631        // First click: in the list area, on the first row (rect.y) — selects.
632        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
633
634        // Second click on the same row activates the entry.
635        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
636        let mut events = Vec::new();
637        while let Ok(evt) = rx.try_recv() {
638            events.push(evt);
639        }
640        assert!(
641            events
642                .iter()
643                .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
644            "expected OpenPath for the activated note, got {events:?}"
645        );
646    }
647
648    /// Scroll wheel anywhere in the sidebar bounds scrolls the file list — even
649    /// when the cursor is over the header or search box. The viewport moves and
650    /// the selection is carried along (keeping its screen position), so with a
651    /// 1-row viewport the selected row changes on the first scroll.
652    #[tokio::test(flavor = "multi_thread")]
653    async fn scroll_down_in_sidebar_bounds_scrolls_list() {
654        let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
655        let (tx, _rx) = unbounded_channel();
656        navigate_to_root(&mut sidebar, &tx).await;
657
658        sidebar.rendered_rect = Rect {
659            x: 0,
660            y: 3,
661            width: 30,
662            height: 20,
663        };
664        // A 1-row viewport over 2 notes, so the list overflows and can scroll.
665        // The panel rect covers the whole sidebar, so the wheel works from the
666        // header/search box too.
667        if let Some(list) = &mut sidebar.list {
668            list.set_list_rect(Rect {
669                x: 0,
670                y: 9,
671                width: 30,
672                height: 1,
673            });
674            list.set_panel_rect(Rect {
675                x: 0,
676                y: 3,
677                width: 30,
678                height: 20,
679            });
680        }
681
682        let first = sidebar
683            .list
684            .as_ref()
685            .unwrap()
686            .selected_row()
687            .map(|e| e.path().to_string());
688
689        // Scroll down with the cursor inside the sidebar header (not the list).
690        let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
691        assert_eq!(result, EventState::Consumed);
692        let after = sidebar
693            .list
694            .as_ref()
695            .unwrap()
696            .selected_row()
697            .map(|e| e.path().to_string());
698        assert_ne!(
699            first, after,
700            "scroll-from-header should scroll the list, carrying the selection"
701        );
702    }
703
704    #[tokio::test]
705    async fn mouse_down_outside_sidebar_is_not_consumed() {
706        let mut sidebar = make_sidebar().await;
707        sidebar.rendered_rect = Rect {
708            x: 0,
709            y: 3,
710            width: 30,
711            height: 20,
712        };
713        let (tx, mut rx) = unbounded_channel();
714
715        // Click to the right of the sidebar (in the editor area).
716        let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
717        assert_eq!(result, EventState::NotConsumed);
718        assert!(rx.try_recv().is_err());
719    }
720
721    /// Navigating loads the directory's notes via the streamed source.
722    #[tokio::test(flavor = "multi_thread")]
723    async fn navigate_loads_directory_notes() {
724        let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
725        assert!(sidebar.is_empty());
726        let (tx, _rx) = unbounded_channel();
727        navigate_to_root(&mut sidebar, &tx).await;
728        assert!(!sidebar.is_empty());
729        assert_eq!(sidebar.note_count(), 1);
730    }
731
732    /// Poll the (already-navigated) engine to idle so a reload's streamed rows
733    /// have arrived.
734    async fn poll_to_idle(sidebar: &mut SidebarComponent) {
735        for _ in 0..50 {
736            if let Some(list) = &mut sidebar.list {
737                list.poll();
738                if !list.is_loading() {
739                    break;
740                }
741            }
742            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
743        }
744        if let Some(list) = &mut sidebar.list {
745            list.poll();
746        }
747    }
748
749    /// Names of the visible note rows, in display order.
750    fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
751        sidebar
752            .list
753            .as_ref()
754            .unwrap()
755            .visible_rows()
756            .iter()
757            .filter_map(|e| match e {
758                FileListEntry::Note { filename, .. } => Some(filename.clone()),
759                _ => None,
760            })
761            .collect()
762    }
763
764    #[tokio::test(flavor = "multi_thread")]
765    async fn apply_sort_reverse_flips_listing_order() {
766        let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
767        let (tx, _rx) = unbounded_channel();
768        navigate_to_root(&mut sidebar, &tx).await;
769        let before = note_names(&sidebar);
770        assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
771        sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
772        poll_to_idle(&mut sidebar).await;
773        let after = note_names(&sidebar);
774        assert_eq!(
775            after,
776            before.iter().rev().cloned().collect::<Vec<_>>(),
777            "descending order should reverse the listing"
778        );
779    }
780
781    #[tokio::test(flavor = "multi_thread")]
782    async fn apply_sort_changes_field() {
783        let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
784        let (tx, _rx) = unbounded_channel();
785        navigate_to_root(&mut sidebar, &tx).await;
786        sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
787        poll_to_idle(&mut sidebar).await;
788        assert_eq!(sidebar.current_sort().0, SortField::Title);
789        assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
790    }
791
792    /// Build a sidebar over a vault with both notes and a subdirectory.
793    async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
794        let vault = temp_vault(prefix).await;
795        vault.validate_and_init().await.unwrap();
796        vault
797            .create_note(&VaultPath::note_path_from("alpha"), "body")
798            .await
799            .unwrap();
800        vault
801            .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
802            .await
803            .unwrap();
804        let settings = AppSettings::default();
805        SidebarComponent::new(
806            settings.key_bindings.clone(),
807            vault,
808            settings.icons(),
809            &settings,
810        )
811    }
812
813    /// Kinds of the visible rows, in display order (excluding the Up row).
814    fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
815        sidebar
816            .list
817            .as_ref()
818            .unwrap()
819            .visible_rows()
820            .iter()
821            .filter_map(|e| match e {
822                FileListEntry::Note { .. } => Some("note"),
823                FileListEntry::Directory { .. } => Some("dir"),
824                _ => None,
825            })
826            .collect()
827    }
828
829    #[tokio::test(flavor = "multi_thread")]
830    async fn group_dirs_puts_directories_first() {
831        let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
832        let (tx, _rx) = unbounded_channel();
833        navigate_to_root(&mut sidebar, &tx).await;
834        assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
835        sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
836        poll_to_idle(&mut sidebar).await;
837        assert_eq!(
838            row_kinds(&sidebar),
839            vec!["dir", "note"],
840            "grouping must cluster directories first"
841        );
842    }
843
844    #[tokio::test(flavor = "multi_thread")]
845    async fn apply_sort_updates_shared_state() {
846        let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
847        let (tx, _rx) = unbounded_channel();
848        navigate_to_root(&mut sidebar, &tx).await;
849        sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
850        poll_to_idle(&mut sidebar).await;
851        assert_eq!(
852            sidebar.current_sort(),
853            (SortField::Title, SortOrder::Descending)
854        );
855        assert!(!sidebar.group_dirs());
856    }
857
858    /// Regression: saving a default must survive navigation. `save_default`
859    /// updates the cached per-context default that `sort_for`/`navigate` read;
860    /// without it, navigating re-derives the construction-time default and the
861    /// saved choice is silently lost.
862    #[tokio::test(flavor = "multi_thread")]
863    async fn save_default_survives_navigation() {
864        let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
865        let (tx, _rx) = unbounded_channel();
866        navigate_to_root(&mut sidebar, &tx).await;
867
868        sidebar.save_default(SortField::Title, SortOrder::Descending, false);
869        poll_to_idle(&mut sidebar).await;
870
871        // Re-navigate (root is non-journal) — sort_for must now yield the saved
872        // default, not the constructor-time (Name, Ascending).
873        sidebar.navigate(VaultPath::root(), &tx);
874        poll_to_idle(&mut sidebar).await;
875        assert_eq!(
876            sidebar.current_sort(),
877            (SortField::Title, SortOrder::Descending),
878            "saved default must persist across navigation"
879        );
880    }
881}