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, AppTxExt, 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    /// The note currently open in the editor, if any — drives the open-note
134    /// marker. `None` on the Browse screen (it never opens notes). Matched
135    /// against `FileListEntry::Note` rows by `is_like`.
136    open_note: Option<VaultPath>,
137    list: Option<SearchList<FileListEntry>>,
138    vault: Arc<NoteVault>,
139    icons: Icons,
140    default_sort_field: SortField,
141    default_sort_order: SortOrder,
142    journal_sort_field: SortField,
143    journal_sort_order: SortOrder,
144    /// Shared sort field/order for the active listing. `DirListingSource::load`
145    /// reads it; the sort shortcuts mutate it then reload. Re-created per
146    /// `navigate` from the per-dir defaults.
147    sort: Arc<Mutex<(SortField, SortOrder)>>,
148    /// Shared "group directories first" flag. `DirListingSource::load` reads it;
149    /// the sort dialog mutates it via `apply_sort`, then the listing reloads.
150    group_dirs: Arc<Mutex<bool>>,
151    rendered_rect: Rect,
152    /// Screen cell each breadcrumb segment was drawn into on the last render,
153    /// with the directory it navigates to — clickable breadcrumb hit-test.
154    breadcrumb_cells: Vec<(Rect, VaultPath)>,
155    key_bindings: KeyBindings,
156}
157
158impl SidebarComponent {
159    /// Build a sidebar from the application settings, pulling its key bindings
160    /// and icons from `settings`. The shared constructor for the screens that
161    /// host a sidebar (Editor and Browse), so the kb/icons wiring lives once.
162    pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
163        Self::new(
164            settings.key_bindings.clone(),
165            vault,
166            settings.icons(),
167            settings,
168        )
169    }
170
171    pub fn new(
172        key_bindings: KeyBindings,
173        vault: Arc<NoteVault>,
174        icons: Icons,
175        settings: &AppSettings,
176    ) -> Self {
177        let default_sort_field = SortField::from(settings.default_sort_field);
178        let default_sort_order = SortOrder::from(settings.default_sort_order);
179        Self {
180            current_dir: VaultPath::root(),
181            open_note: None,
182            list: None,
183            vault,
184            icons,
185            default_sort_field,
186            default_sort_order,
187            journal_sort_field: SortField::from(settings.journal_sort_field),
188            journal_sort_order: SortOrder::from(settings.journal_sort_order),
189            sort: Arc::new(Mutex::new((default_sort_field, default_sort_order))),
190            group_dirs: Arc::new(Mutex::new(settings.group_directories)),
191            rendered_rect: Rect::default(),
192            breadcrumb_cells: Vec::new(),
193            key_bindings,
194        }
195    }
196
197    /// The breadcrumb segment under the given screen cell, if any.
198    fn breadcrumb_at(&self, column: u16, row: u16) -> Option<&VaultPath> {
199        self.breadcrumb_cells
200            .iter()
201            .find(|(rect, _)| rect.contains(Position { x: column, y: row }))
202            .map(|(_, dir)| dir)
203    }
204
205    pub fn current_dir(&self) -> &VaultPath {
206        &self.current_dir
207    }
208
209    /// `true` until a directory has been loaded (no engine yet). The editor
210    /// uses this to decide whether to issue the first-open navigation.
211    pub fn is_empty(&self) -> bool {
212        self.list.is_none()
213    }
214
215    /// Sort field/order to apply for `dir` (journal dirs get their own).
216    fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
217        if dir == self.vault.journal_path() {
218            (self.journal_sort_field, self.journal_sort_order)
219        } else {
220            (self.default_sort_field, self.default_sort_order)
221        }
222    }
223
224    /// (Re)build the engine for `dir`, replacing any prior listing. This is the
225    /// single directory-navigation entry point: changing directory = rebuild
226    /// the engine with a fresh `DirListingSource` for the new dir.
227    pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
228        self.current_dir = dir.clone();
229        let (sort_field, sort_order) = self.sort_for(&dir);
230        self.sort = Arc::new(Mutex::new((sort_field, sort_order)));
231        let source = DirListingSource {
232            vault: self.vault.clone(),
233            dir,
234            sort: self.sort.clone(),
235            group_dirs: self.group_dirs.clone(),
236        };
237        self.list = Some(
238            SearchList::builder(source, redraw_callback(tx.clone()))
239                .filter(Filter::Fuzzy)
240                .icons(self.icons.clone())
241                .build(),
242        );
243    }
244
245    /// Rebuild the listing only when it is currently showing `dir`, so a
246    /// create/rename/delete/move in that directory is reflected without yanking
247    /// the user away from an unrelated directory they browsed to. A no-op
248    /// otherwise. Shared by every screen that hosts a sidebar.
249    pub fn refresh_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
250        if dir.is_like(&self.current_dir) {
251            self.navigate(self.current_dir.clone(), tx);
252        }
253    }
254
255    /// Set (or clear) the note the editor currently has open, then re-stamp the
256    /// marker on the live rows. The editor calls this on every open and on an
257    /// open-note rename.
258    pub fn set_open_note(&mut self, path: Option<VaultPath>) {
259        self.open_note = path;
260        self.stamp_open_marker();
261    }
262
263    /// Re-apply `is_open` to the rows so exactly the open note's row is marked.
264    /// Idempotent: a full reload rebuilds rows without the flag, so this runs
265    /// again after each load (see `render`).
266    fn stamp_open_marker(&mut self) {
267        let open = self.open_note.clone();
268        if let Some(list) = &mut self.list {
269            list.update_rows(|row| {
270                if let FileListEntry::Note { path, is_open, .. } = row {
271                    let want = open.as_ref().is_some_and(|o| path.is_like(o));
272                    if *is_open != want {
273                        *is_open = want;
274                        return true;
275                    }
276                }
277                false
278            });
279        }
280    }
281
282    /// Update the title of the row whose note path matches `path`, if it is in
283    /// the current listing. Called when a note is saved and its title (first
284    /// body line) may have changed. Position is left unchanged (no re-sort).
285    pub fn update_note_row(&mut self, path: &VaultPath, new_title: &str) {
286        if let Some(list) = &mut self.list {
287            list.update_rows(|row| {
288                if let FileListEntry::Note {
289                    path: row_path,
290                    title,
291                    ..
292                } = row
293                    && row_path.is_like(path)
294                    && title != new_title
295                {
296                    *title = new_title.to_string();
297                    return true;
298                }
299                false
300            });
301        }
302    }
303
304    /// Move the row at `from` to `to` (path + filename + journal_date) in
305    /// place, for a same-directory note rename. Position is left unchanged
306    /// (no re-sort). `journal_date` is recomputed so a rename into/out of a
307    /// `YYYY-MM-DD` name under the journal directory flips the glyph and the
308    /// secondary date line correctly.
309    pub fn rename_note_row(&mut self, from: &VaultPath, to: &VaultPath) {
310        let new_filename = to.get_parent_path().1;
311        let new_journal_date = self.vault.journal_date(to).map(format_journal_date);
312        if let Some(list) = &mut self.list {
313            list.update_rows(|row| {
314                if let FileListEntry::Note {
315                    path,
316                    filename,
317                    journal_date,
318                    ..
319                } = row
320                    && path.is_like(from)
321                {
322                    *path = to.clone();
323                    *filename = new_filename.clone();
324                    *journal_date = new_journal_date.clone();
325                    return true;
326                }
327                false
328            });
329        }
330    }
331
332    /// Seed the directory the sidebar will show before its first `navigate`.
333    /// Lets a screen open at a non-root path while keeping `current_dir` the
334    /// single source of truth for the browsed directory.
335    pub fn set_current_dir(&mut self, dir: VaultPath) {
336        self.current_dir = dir;
337    }
338
339    /// Current sort field/order for the active listing.
340    pub fn current_sort(&self) -> (SortField, SortOrder) {
341        *self.sort.lock().unwrap()
342    }
343
344    /// Current "group directories first" flag.
345    pub fn group_dirs(&self) -> bool {
346        *self.group_dirs.lock().unwrap()
347    }
348
349    /// Apply a sort selection from the sort dialog and reload so the source
350    /// re-orders the listing.
351    pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
352        *self.sort.lock().unwrap() = (field, order);
353        *self.group_dirs.lock().unwrap() = group_dirs;
354        if let Some(list) = &mut self.list {
355            list.reload();
356        }
357    }
358
359    /// `true` when the active directory is the journal (so its sort default is
360    /// the journal one). Lets the caller persist to the matching settings.
361    pub fn is_current_journal(&self) -> bool {
362        &self.current_dir == self.vault.journal_path()
363    }
364
365    /// Save the dialog's selection as the in-session default for the active
366    /// context (journal vs. normal), then apply it live. Without this, the
367    /// cached per-context defaults that `sort_for`/`navigate` read stay at their
368    /// construction-time values, so a saved default would have no effect until
369    /// restart. The caller is responsible for persisting to the settings file.
370    pub fn save_default(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
371        if self.is_current_journal() {
372            self.journal_sort_field = field;
373            self.journal_sort_order = order;
374        } else {
375            self.default_sort_field = field;
376            self.default_sort_order = order;
377        }
378        self.apply_sort(field, order, group_dirs);
379    }
380
381    /// Number of note rows currently visible (excludes Up / dirs / create).
382    fn note_count(&self) -> usize {
383        match &self.list {
384            None => 0,
385            Some(list) => list
386                .visible_rows()
387                .iter()
388                .filter(|e| matches!(e, FileListEntry::Note { .. }))
389                .count(),
390        }
391    }
392
393    /// Act on the selected row: Up/Note/Directory → `OpenPath` (directories and
394    /// Up route back through the editor's navigate, rebuilding the engine);
395    /// CreateNote → materialise the note, then open it.
396    fn activate_selected_entry(&self, tx: &AppTx) {
397        let Some(list) = &self.list else { return };
398        let Some(entry) = list.selected_row() else {
399            return;
400        };
401        match entry {
402            FileListEntry::CreateNote { path, .. } => {
403                let path = path.clone();
404                let vault = Arc::clone(&self.vault);
405                let tx2 = tx.clone();
406                tokio::spawn(async move {
407                    match vault.load_or_create_note(&path, None).await {
408                        Ok((_, created)) => tx2.announce_and_open(path, created),
409                        Err(e) => {
410                            tracing::warn!("create note failed for {path}: {e}");
411                        }
412                    }
413                });
414            }
415            FileListEntry::Attachment { path, .. } => {
416                tx.send(AppEvent::OpenAttachment(path.clone())).ok();
417            }
418            other => {
419                tx.send(AppEvent::open(other.path().clone())).ok();
420            }
421        }
422    }
423}
424
425/// Format a `NaiveDate` as a human-readable string with day-of-week.
426/// Example: "Wednesday, March 17, 2026"
427fn format_journal_date(date: NaiveDate) -> String {
428    date.format("%A, %B %-d, %Y").to_string()
429}
430
431impl Component for SidebarComponent {
432    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
433        if let InputEvent::Mouse(mouse) = event {
434            let pos = Position {
435                x: mouse.column,
436                y: mouse.row,
437            };
438            if !self.rendered_rect.contains(pos) {
439                return EventState::NotConsumed;
440            }
441            // A click on a breadcrumb segment jumps up the tree.
442            if matches!(
443                mouse.kind,
444                ratatui::crossterm::event::MouseEventKind::Down(
445                    ratatui::crossterm::event::MouseButton::Left
446                )
447            ) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
448            {
449                tx.send(AppEvent::open(dir.clone())).ok();
450                return EventState::Consumed;
451            }
452            // Click-to-focus is handled centrally by `PanelSet::handle_mouse`;
453            // only the sidebar's internal behavior lives here. The engine
454            // hit-tests the wheel against the recorded panel rect (the whole
455            // sidebar — header and search box included) and clicks against
456            // the list rect.
457            if let Some(list) = &mut self.list {
458                match list.handle_mouse(mouse) {
459                    SearchMouse::Activated(_) => self.activate_selected_entry(tx),
460                    // Right-click on a file/dir row → context menu (spec §10).
461                    SearchMouse::Context(_) => {
462                        if let Some(entry) = list.selected_row()
463                            && !matches!(
464                                entry,
465                                FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
466                            )
467                        {
468                            tx.send(AppEvent::ShowFileOpsMenu(entry.path().clone()))
469                                .ok();
470                        }
471                    }
472                    // ContentScroll* are unreachable: this host records no
473                    // content sub-region.
474                    SearchMouse::Selected(_)
475                    | SearchMouse::Scrolled
476                    | SearchMouse::ContentScrollUp
477                    | SearchMouse::ContentScrollDown
478                    | SearchMouse::None => {}
479                }
480            }
481            return EventState::Consumed;
482        }
483
484        if let InputEvent::Key(key) = event {
485            if self.list.is_none() {
486                return EventState::NotConsumed;
487            }
488            let reaction = self.list.as_mut().unwrap().handle_key(key);
489            match reaction {
490                KeyReaction::Submit => {
491                    self.activate_selected_entry(tx);
492                    EventState::Consumed
493                }
494                KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
495                KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
496            }
497        } else {
498            EventState::NotConsumed
499        }
500    }
501
502    fn hint_shortcuts(&self) -> Vec<(String, String)> {
503        use crate::keys::action_shortcuts::ActionShortcuts;
504
505        crate::components::hints::hints_for(
506            &self.key_bindings,
507            &[
508                (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
509                (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
510                (ActionShortcuts::OpenSortDialog, "sort"),
511            ],
512        )
513    }
514
515    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
516        self.rendered_rect = rect;
517
518        let rows = Layout::default()
519            .direction(Direction::Vertical)
520            .constraints([
521                Constraint::Length(3),
522                Constraint::Length(3),
523                Constraint::Min(0),
524            ])
525            .split(rect);
526
527        let border_style = theme.border_style(focused);
528
529        let header = Block::default()
530            .title(format!("─ Files · {} ", self.current_dir))
531            .borders(Borders::ALL)
532            .border_style(border_style)
533            .style(theme.panel_style());
534        let header_inner = header.inner(rows[0]);
535        f.render_widget(header, rows[0]);
536
537        // Clickable breadcrumb: one span per ancestor directory, separated by
538        // " / ", with the note count right-aligned. Each segment's cell is
539        // recorded for the click hit-test.
540        self.breadcrumb_cells.clear();
541        let seg_style = Style::default()
542            .fg(theme.fg_secondary.to_ratatui())
543            .bg(theme.bg_panel.to_ratatui());
544        let sep_style = Style::default()
545            .fg(theme.gray.to_ratatui())
546            .bg(theme.bg_panel.to_ratatui());
547        let mut spans: Vec<Span> = Vec::new();
548        let mut x = header_inner.x;
549        let mut push_segment =
550            |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
551                let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
552                // Only record cells that are (at least partly) visible — the
553                // Paragraph clips at the header edge, so fully clipped
554                // segments must not be clickable.
555                if *x < header_inner.right() {
556                    let visible = w.min(header_inner.right() - *x);
557                    self.breadcrumb_cells
558                        .push((Rect::new(*x, header_inner.y, visible, 1), dir));
559                }
560                spans.push(Span::styled(label, seg_style));
561                *x += w;
562            };
563        push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
564        let slices = self.current_dir.get_slices();
565        let mut acc = String::new();
566        for slice in &slices {
567            spans.push(Span::styled(" / ", sep_style));
568            x += 3;
569            acc.push('/');
570            acc.push_str(slice);
571            push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
572        }
573        let count = format!("{} notes", self.note_count());
574        let used: u16 = x - header_inner.x;
575        let pad = header_inner
576            .width
577            .saturating_sub(used)
578            .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
579        spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
580        spans.push(Span::styled(count, sep_style));
581        f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
582
583        let search_block = Block::default()
584            .title(" Search")
585            .borders(Borders::ALL)
586            .border_style(border_style)
587            .style(theme.panel_style());
588        let search_inner = search_block.inner(rows[1]);
589        f.render_widget(search_block, rows[1]);
590
591        let list_block = Block::default()
592            .borders(Borders::ALL)
593            .border_style(border_style)
594            .style(theme.panel_style());
595        let list_inner = list_block.inner(rows[2]);
596        f.render_widget(list_block, rows[2]);
597
598        // Poll the engine so a just-completed load's rows are applied, then
599        // re-stamp the open-note marker (the reload rebuilt rows without it)
600        // before the list renders.
601        if let Some(list) = &mut self.list {
602            list.poll();
603        }
604        self.stamp_open_marker();
605        if let Some(list) = &mut self.list {
606            list.render_query(f, search_inner, theme, focused);
607            list.render(f, list_inner, theme, focused);
608            // Record the rendered-items rect (block inner area) for mouse
609            // hit-testing: the engine maps a click to `row - rect.y`, so row 0
610            // is the first item. The panel rect (whole sidebar) lets the wheel
611            // scroll from anywhere within the sidebar, not just over the list.
612            list.set_list_rect(list_inner);
613            list.set_panel_rect(rect);
614        }
615    }
616}
617
618#[cfg(test)]
619impl SidebarComponent {
620    pub(crate) fn poll_for_test(&mut self) {
621        if let Some(list) = &mut self.list {
622            list.poll();
623        }
624        self.stamp_open_marker();
625    }
626
627    pub(crate) fn is_loading_for_test(&self) -> bool {
628        self.list.as_ref().is_some_and(|l| l.is_loading())
629    }
630
631    pub(crate) fn note_row_is_open_for_test(&self, name: &str) -> bool {
632        self.list.as_ref().is_some_and(|l| {
633            l.rows().iter().any(|r| {
634                matches!(r, FileListEntry::Note { path, is_open, .. }
635                    if path.get_name() == name && *is_open)
636            })
637        })
638    }
639
640    pub(crate) fn note_row_title_for_test(&self, name: &str) -> Option<String> {
641        self.list.as_ref().and_then(|l| {
642            l.rows().iter().find_map(|r| match r {
643                FileListEntry::Note { path, title, .. } if path.get_name() == name => {
644                    Some(title.clone())
645                }
646                _ => None,
647            })
648        })
649    }
650
651    pub(crate) fn note_row_journal_date_for_test(&self, path: &VaultPath) -> Option<String> {
652        self.list.as_ref().and_then(|l| {
653            l.rows().iter().find_map(|r| match r {
654                FileListEntry::Note {
655                    path: row_path,
656                    journal_date,
657                    ..
658                } if row_path.is_like(path) => journal_date.clone(),
659                _ => None,
660            })
661        })
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use crate::settings::AppSettings;
669    use crate::test_support::{mouse_down_at, temp_vault};
670    use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
671    use tokio::sync::mpsc::unbounded_channel;
672
673    async fn make_sidebar() -> SidebarComponent {
674        let vault = temp_vault("sidebar").await;
675        vault.validate_and_init().await.unwrap();
676        let settings = AppSettings::default();
677        SidebarComponent::new(
678            settings.key_bindings.clone(),
679            vault,
680            settings.icons(),
681            &settings,
682        )
683    }
684
685    /// Build a sidebar over `vault` after creating each named note at root.
686    async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
687        let vault = temp_vault(prefix).await;
688        vault.validate_and_init().await.unwrap();
689        for name in names {
690            vault
691                .create_note(&VaultPath::note_path_from(name), "body")
692                .await
693                .unwrap();
694        }
695        let settings = AppSettings::default();
696        SidebarComponent::new(
697            settings.key_bindings.clone(),
698            vault,
699            settings.icons(),
700            &settings,
701        )
702    }
703
704    /// Clicks anywhere in the sidebar bounds — header, search box, list — are
705    /// consumed by the sidebar. (Click-to-focus itself is handled centrally by
706    /// `PanelSet::handle_mouse`, not here.)
707    #[tokio::test]
708    async fn mouse_down_in_sidebar_bounds_is_consumed() {
709        let mut sidebar = make_sidebar().await;
710        sidebar.rendered_rect = Rect {
711            x: 0,
712            y: 3,
713            width: 30,
714            height: 20,
715        };
716        let (tx, _rx) = unbounded_channel();
717
718        // Header (top-of-sidebar) area.
719        assert_eq!(
720            sidebar.handle_input(&mouse_down_at(5, 4), &tx),
721            EventState::Consumed
722        );
723        // Search-box area (rows 6..9 within the sidebar layout).
724        assert_eq!(
725            sidebar.handle_input(&mouse_down_at(5, 7), &tx),
726            EventState::Consumed
727        );
728        // Outside the sidebar bounds.
729        assert_eq!(
730            sidebar.handle_input(&mouse_down_at(40, 7), &tx),
731            EventState::NotConsumed
732        );
733    }
734
735    fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
736        InputEvent::Mouse(MouseEvent {
737            kind,
738            column: col,
739            row,
740            modifiers: KeyModifiers::NONE,
741        })
742    }
743
744    /// Load the sidebar at the vault root, then poll the engine to idle so the
745    /// streamed rows have arrived.
746    async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
747        sidebar.navigate(VaultPath::root(), tx);
748        // The streamed source spawns `browse_vault` + a blocking drain; give the
749        // background work real time to land, polling the engine between waits.
750        for _ in 0..50 {
751            if let Some(list) = &mut sidebar.list {
752                list.poll();
753                if !list.is_loading() {
754                    break;
755                }
756            }
757            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
758        }
759        if let Some(list) = &mut sidebar.list {
760            list.poll();
761        }
762    }
763
764    /// Two clicks on the same list row activate it: first selects, second sends
765    /// `OpenPath` (or, for `CreateNote`, materialises the note then opens it).
766    #[tokio::test(flavor = "multi_thread")]
767    async fn mouse_double_click_on_list_row_sends_open_path() {
768        let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
769        let (tx, mut rx) = unbounded_channel();
770        navigate_to_root(&mut sidebar, &tx).await;
771
772        sidebar.rendered_rect = Rect {
773            x: 0,
774            y: 3,
775            width: 30,
776            height: 20,
777        };
778        // The engine records the rendered-items rect; clicks hit-test as
779        // `row - rect.y`, so row 0 (y=9) is the first item.
780        if let Some(list) = &mut sidebar.list {
781            list.set_list_rect(Rect {
782                x: 0,
783                y: 9,
784                width: 30,
785                height: 14,
786            });
787        }
788
789        // First click: in the list area, on the first row (rect.y) — selects.
790        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
791
792        // Second click on the same row activates the entry.
793        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
794        let mut events = Vec::new();
795        while let Ok(evt) = rx.try_recv() {
796            events.push(evt);
797        }
798        assert!(
799            events
800                .iter()
801                .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
802            "expected OpenPath for the activated note, got {events:?}"
803        );
804    }
805
806    /// Scroll wheel anywhere in the sidebar bounds scrolls the file list — even
807    /// when the cursor is over the header or search box. The viewport moves and
808    /// the selection is carried along (keeping its screen position), so with a
809    /// 1-row viewport the selected row changes on the first scroll.
810    #[tokio::test(flavor = "multi_thread")]
811    async fn scroll_down_in_sidebar_bounds_scrolls_list() {
812        let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
813        let (tx, _rx) = unbounded_channel();
814        navigate_to_root(&mut sidebar, &tx).await;
815
816        sidebar.rendered_rect = Rect {
817            x: 0,
818            y: 3,
819            width: 30,
820            height: 20,
821        };
822        // A 1-row viewport over 2 notes, so the list overflows and can scroll.
823        // The panel rect covers the whole sidebar, so the wheel works from the
824        // header/search box too.
825        if let Some(list) = &mut sidebar.list {
826            list.set_list_rect(Rect {
827                x: 0,
828                y: 9,
829                width: 30,
830                height: 1,
831            });
832            list.set_panel_rect(Rect {
833                x: 0,
834                y: 3,
835                width: 30,
836                height: 20,
837            });
838        }
839
840        let first = sidebar
841            .list
842            .as_ref()
843            .unwrap()
844            .selected_row()
845            .map(|e| e.path().to_string());
846
847        // Scroll down with the cursor inside the sidebar header (not the list).
848        let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
849        assert_eq!(result, EventState::Consumed);
850        let after = sidebar
851            .list
852            .as_ref()
853            .unwrap()
854            .selected_row()
855            .map(|e| e.path().to_string());
856        assert_ne!(
857            first, after,
858            "scroll-from-header should scroll the list, carrying the selection"
859        );
860    }
861
862    #[tokio::test]
863    async fn mouse_down_outside_sidebar_is_not_consumed() {
864        let mut sidebar = make_sidebar().await;
865        sidebar.rendered_rect = Rect {
866            x: 0,
867            y: 3,
868            width: 30,
869            height: 20,
870        };
871        let (tx, mut rx) = unbounded_channel();
872
873        // Click to the right of the sidebar (in the editor area).
874        let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
875        assert_eq!(result, EventState::NotConsumed);
876        assert!(rx.try_recv().is_err());
877    }
878
879    /// Navigating loads the directory's notes via the streamed source.
880    #[tokio::test(flavor = "multi_thread")]
881    async fn navigate_loads_directory_notes() {
882        let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
883        assert!(sidebar.is_empty());
884        let (tx, _rx) = unbounded_channel();
885        navigate_to_root(&mut sidebar, &tx).await;
886        assert!(!sidebar.is_empty());
887        assert_eq!(sidebar.note_count(), 1);
888    }
889
890    /// Poll the (already-navigated) engine to idle so a reload's streamed rows
891    /// have arrived.
892    async fn poll_to_idle(sidebar: &mut SidebarComponent) {
893        for _ in 0..50 {
894            if let Some(list) = &mut sidebar.list {
895                list.poll();
896                if !list.is_loading() {
897                    break;
898                }
899            }
900            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
901        }
902        if let Some(list) = &mut sidebar.list {
903            list.poll();
904        }
905    }
906
907    /// Names of the visible note rows, in display order.
908    fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
909        sidebar
910            .list
911            .as_ref()
912            .unwrap()
913            .visible_rows()
914            .iter()
915            .filter_map(|e| match e {
916                FileListEntry::Note { filename, .. } => Some(filename.clone()),
917                _ => None,
918            })
919            .collect()
920    }
921
922    #[tokio::test(flavor = "multi_thread")]
923    async fn apply_sort_reverse_flips_listing_order() {
924        let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
925        let (tx, _rx) = unbounded_channel();
926        navigate_to_root(&mut sidebar, &tx).await;
927        let before = note_names(&sidebar);
928        assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
929        sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
930        poll_to_idle(&mut sidebar).await;
931        let after = note_names(&sidebar);
932        assert_eq!(
933            after,
934            before.iter().rev().cloned().collect::<Vec<_>>(),
935            "descending order should reverse the listing"
936        );
937    }
938
939    #[tokio::test(flavor = "multi_thread")]
940    async fn apply_sort_changes_field() {
941        let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
942        let (tx, _rx) = unbounded_channel();
943        navigate_to_root(&mut sidebar, &tx).await;
944        sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
945        poll_to_idle(&mut sidebar).await;
946        assert_eq!(sidebar.current_sort().0, SortField::Title);
947        assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
948    }
949
950    /// Build a sidebar over a vault with both notes and a subdirectory.
951    async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
952        let vault = temp_vault(prefix).await;
953        vault.validate_and_init().await.unwrap();
954        vault
955            .create_note(&VaultPath::note_path_from("alpha"), "body")
956            .await
957            .unwrap();
958        vault
959            .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
960            .await
961            .unwrap();
962        let settings = AppSettings::default();
963        SidebarComponent::new(
964            settings.key_bindings.clone(),
965            vault,
966            settings.icons(),
967            &settings,
968        )
969    }
970
971    /// Kinds of the visible rows, in display order (excluding the Up row).
972    fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
973        sidebar
974            .list
975            .as_ref()
976            .unwrap()
977            .visible_rows()
978            .iter()
979            .filter_map(|e| match e {
980                FileListEntry::Note { .. } => Some("note"),
981                FileListEntry::Directory { .. } => Some("dir"),
982                _ => None,
983            })
984            .collect()
985    }
986
987    #[tokio::test(flavor = "multi_thread")]
988    async fn group_dirs_puts_directories_first() {
989        let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
990        let (tx, _rx) = unbounded_channel();
991        navigate_to_root(&mut sidebar, &tx).await;
992        assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
993        sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
994        poll_to_idle(&mut sidebar).await;
995        assert_eq!(
996            row_kinds(&sidebar),
997            vec!["dir", "note"],
998            "grouping must cluster directories first"
999        );
1000    }
1001
1002    #[tokio::test(flavor = "multi_thread")]
1003    async fn apply_sort_updates_shared_state() {
1004        let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
1005        let (tx, _rx) = unbounded_channel();
1006        navigate_to_root(&mut sidebar, &tx).await;
1007        sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1008        poll_to_idle(&mut sidebar).await;
1009        assert_eq!(
1010            sidebar.current_sort(),
1011            (SortField::Title, SortOrder::Descending)
1012        );
1013        assert!(!sidebar.group_dirs());
1014    }
1015
1016    #[tokio::test(flavor = "multi_thread")]
1017    async fn set_open_note_stamps_matching_row() {
1018        let mut sb = sidebar_with_notes("sb-open", &["alpha", "beta"]).await;
1019        let (tx, _rx) = unbounded_channel();
1020        navigate_to_root(&mut sb, &tx).await;
1021
1022        sb.set_open_note(Some(VaultPath::note_path_from("alpha")));
1023        assert!(
1024            sb.note_row_is_open_for_test("alpha.md"),
1025            "open note is marked"
1026        );
1027        assert!(
1028            !sb.note_row_is_open_for_test("beta.md"),
1029            "other note is not marked"
1030        );
1031
1032        sb.set_open_note(Some(VaultPath::note_path_from("beta")));
1033        assert!(!sb.note_row_is_open_for_test("alpha.md"));
1034        assert!(sb.note_row_is_open_for_test("beta.md"));
1035
1036        sb.set_open_note(None);
1037        assert!(!sb.note_row_is_open_for_test("beta.md"));
1038    }
1039
1040    #[tokio::test(flavor = "multi_thread")]
1041    async fn update_note_row_changes_title_in_place() {
1042        let mut sb = sidebar_with_notes("sb-title", &["alpha"]).await;
1043        let (tx, _rx) = unbounded_channel();
1044        navigate_to_root(&mut sb, &tx).await;
1045
1046        sb.update_note_row(&VaultPath::note_path_from("alpha"), "Fresh Title");
1047        assert_eq!(
1048            sb.note_row_title_for_test("alpha.md").as_deref(),
1049            Some("Fresh Title")
1050        );
1051    }
1052
1053    #[tokio::test(flavor = "multi_thread")]
1054    async fn rename_note_row_updates_path_and_filename() {
1055        let mut sb = sidebar_with_notes("sb-rename", &["alpha"]).await;
1056        let (tx, _rx) = unbounded_channel();
1057        navigate_to_root(&mut sb, &tx).await;
1058
1059        let to = VaultPath::note_path_from("gamma");
1060        let expected_filename = to.get_parent_path().1;
1061        sb.rename_note_row(&VaultPath::note_path_from("alpha"), &to);
1062        assert!(
1063            sb.note_row_title_for_test("gamma.md").is_some(),
1064            "row now at new name"
1065        );
1066        assert!(
1067            sb.note_row_title_for_test("alpha.md").is_none(),
1068            "old name gone"
1069        );
1070        // Also verify the filename field itself was updated to the new name.
1071        let renamed_filename = sb
1072            .list
1073            .as_ref()
1074            .unwrap()
1075            .rows()
1076            .iter()
1077            .find_map(|r| match r {
1078                FileListEntry::Note { path, filename, .. } if path.is_like(&to) => {
1079                    Some(filename.clone())
1080                }
1081                _ => None,
1082            });
1083        assert_eq!(
1084            renamed_filename.as_deref(),
1085            Some(expected_filename.as_str()),
1086            "filename field must be updated to the new name"
1087        );
1088    }
1089
1090    /// Renaming a journal-dir note (`YYYY-MM-DD`) to a non-date name clears
1091    /// `journal_date` on the row so the glyph and secondary date line update.
1092    #[tokio::test(flavor = "multi_thread")]
1093    async fn rename_note_row_clears_journal_date_when_renamed_away_from_date_name() {
1094        // Build a vault and create a note inside the journal directory with a
1095        // valid YYYY-MM-DD name so `vault.journal_date` returns Some(_).
1096        let vault = crate::test_support::temp_vault("sb-jdate").await;
1097        vault.validate_and_init().await.unwrap();
1098        let journal_path = vault.journal_path().clone();
1099        let date_name = "2026-06-09";
1100        let from = journal_path
1101            .append(&VaultPath::note_path_from(date_name))
1102            .absolute();
1103        vault.create_note(&from, "journal body").await.unwrap();
1104
1105        let settings = AppSettings::default();
1106        let mut sb = SidebarComponent::new(
1107            settings.key_bindings.clone(),
1108            vault,
1109            settings.icons(),
1110            &settings,
1111        );
1112        let (tx, _rx) = unbounded_channel();
1113        // Navigate to the journal directory (not root) so the note row is listed.
1114        sb.navigate(journal_path.clone(), &tx);
1115        for _ in 0..50 {
1116            sb.poll_for_test();
1117            if !sb.is_loading_for_test() {
1118                break;
1119            }
1120            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1121        }
1122        sb.poll_for_test();
1123
1124        // Precondition: the journal row has a non-None `journal_date`.
1125        assert!(
1126            sb.note_row_journal_date_for_test(&from).is_some(),
1127            "journal note must have a journal_date before rename"
1128        );
1129
1130        // Rename the note to a plain name (not a date) in the same directory.
1131        let to = journal_path
1132            .append(&VaultPath::note_path_from("meeting"))
1133            .absolute();
1134        sb.rename_note_row(&from, &to);
1135
1136        // The row should now have journal_date = None.
1137        assert_eq!(
1138            sb.note_row_journal_date_for_test(&to),
1139            None,
1140            "journal_date must be cleared after renaming to a non-date name"
1141        );
1142    }
1143
1144    /// Regression: saving a default must survive navigation. `save_default`
1145    /// updates the cached per-context default that `sort_for`/`navigate` read;
1146    /// without it, navigating re-derives the construction-time default and the
1147    /// saved choice is silently lost.
1148    #[tokio::test(flavor = "multi_thread")]
1149    async fn save_default_survives_navigation() {
1150        let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
1151        let (tx, _rx) = unbounded_channel();
1152        navigate_to_root(&mut sidebar, &tx).await;
1153
1154        sidebar.save_default(SortField::Title, SortOrder::Descending, false);
1155        poll_to_idle(&mut sidebar).await;
1156
1157        // Re-navigate (root is non-journal) — sort_for must now yield the saved
1158        // default, not the constructor-time (Name, Ascending).
1159        sidebar.navigate(VaultPath::root(), &tx);
1160        poll_to_idle(&mut sidebar).await;
1161        assert_eq!(
1162            sidebar.current_sort(),
1163            (SortField::Title, SortOrder::Descending),
1164            "saved default must persist across navigation"
1165        );
1166    }
1167}