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            other => {
416                tx.send(AppEvent::open(other.path().clone())).ok();
417            }
418        }
419    }
420}
421
422/// Format a `NaiveDate` as a human-readable string with day-of-week.
423/// Example: "Wednesday, March 17, 2026"
424fn format_journal_date(date: NaiveDate) -> String {
425    date.format("%A, %B %-d, %Y").to_string()
426}
427
428impl Component for SidebarComponent {
429    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
430        if let InputEvent::Mouse(mouse) = event {
431            let pos = Position {
432                x: mouse.column,
433                y: mouse.row,
434            };
435            if !self.rendered_rect.contains(pos) {
436                return EventState::NotConsumed;
437            }
438            // A click on a breadcrumb segment jumps up the tree.
439            if matches!(
440                mouse.kind,
441                ratatui::crossterm::event::MouseEventKind::Down(
442                    ratatui::crossterm::event::MouseButton::Left
443                )
444            ) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
445            {
446                tx.send(AppEvent::open(dir.clone())).ok();
447                return EventState::Consumed;
448            }
449            // Click-to-focus is handled centrally by `PanelSet::handle_mouse`;
450            // only the sidebar's internal behavior lives here. The engine
451            // hit-tests the wheel against the recorded panel rect (the whole
452            // sidebar — header and search box included) and clicks against
453            // the list rect.
454            if let Some(list) = &mut self.list {
455                match list.handle_mouse(mouse) {
456                    SearchMouse::Activated(_) => self.activate_selected_entry(tx),
457                    // Right-click on a file/dir row → context menu (spec §10).
458                    SearchMouse::Context(_) => {
459                        if let Some(entry) = list.selected_row()
460                            && !matches!(
461                                entry,
462                                FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
463                            )
464                        {
465                            tx.send(AppEvent::ShowFileOpsMenu(entry.path().clone()))
466                                .ok();
467                        }
468                    }
469                    // ContentScroll* are unreachable: this host records no
470                    // content sub-region.
471                    SearchMouse::Selected(_)
472                    | SearchMouse::Scrolled
473                    | SearchMouse::ContentScrollUp
474                    | SearchMouse::ContentScrollDown
475                    | SearchMouse::None => {}
476                }
477            }
478            return EventState::Consumed;
479        }
480
481        if let InputEvent::Key(key) = event {
482            if self.list.is_none() {
483                return EventState::NotConsumed;
484            }
485            let reaction = self.list.as_mut().unwrap().handle_key(key);
486            match reaction {
487                KeyReaction::Submit => {
488                    self.activate_selected_entry(tx);
489                    EventState::Consumed
490                }
491                KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
492                KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
493            }
494        } else {
495            EventState::NotConsumed
496        }
497    }
498
499    fn hint_shortcuts(&self) -> Vec<(String, String)> {
500        use crate::keys::action_shortcuts::ActionShortcuts;
501
502        crate::components::hints::hints_for(
503            &self.key_bindings,
504            &[
505                (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
506                (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
507                (ActionShortcuts::OpenSortDialog, "sort"),
508            ],
509        )
510    }
511
512    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
513        self.rendered_rect = rect;
514
515        let rows = Layout::default()
516            .direction(Direction::Vertical)
517            .constraints([
518                Constraint::Length(3),
519                Constraint::Length(3),
520                Constraint::Min(0),
521            ])
522            .split(rect);
523
524        let border_style = theme.border_style(focused);
525
526        let header = Block::default()
527            .title(format!("─ Files · {} ", self.current_dir))
528            .borders(Borders::ALL)
529            .border_style(border_style)
530            .style(theme.panel_style());
531        let header_inner = header.inner(rows[0]);
532        f.render_widget(header, rows[0]);
533
534        // Clickable breadcrumb: one span per ancestor directory, separated by
535        // " / ", with the note count right-aligned. Each segment's cell is
536        // recorded for the click hit-test.
537        self.breadcrumb_cells.clear();
538        let seg_style = Style::default()
539            .fg(theme.fg_secondary.to_ratatui())
540            .bg(theme.bg_panel.to_ratatui());
541        let sep_style = Style::default()
542            .fg(theme.gray.to_ratatui())
543            .bg(theme.bg_panel.to_ratatui());
544        let mut spans: Vec<Span> = Vec::new();
545        let mut x = header_inner.x;
546        let mut push_segment =
547            |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
548                let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
549                // Only record cells that are (at least partly) visible — the
550                // Paragraph clips at the header edge, so fully clipped
551                // segments must not be clickable.
552                if *x < header_inner.right() {
553                    let visible = w.min(header_inner.right() - *x);
554                    self.breadcrumb_cells
555                        .push((Rect::new(*x, header_inner.y, visible, 1), dir));
556                }
557                spans.push(Span::styled(label, seg_style));
558                *x += w;
559            };
560        push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
561        let slices = self.current_dir.get_slices();
562        let mut acc = String::new();
563        for slice in &slices {
564            spans.push(Span::styled(" / ", sep_style));
565            x += 3;
566            acc.push('/');
567            acc.push_str(slice);
568            push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
569        }
570        let count = format!("{} notes", self.note_count());
571        let used: u16 = x - header_inner.x;
572        let pad = header_inner
573            .width
574            .saturating_sub(used)
575            .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
576        spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
577        spans.push(Span::styled(count, sep_style));
578        f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
579
580        let search_block = Block::default()
581            .title(" Search")
582            .borders(Borders::ALL)
583            .border_style(border_style)
584            .style(theme.panel_style());
585        let search_inner = search_block.inner(rows[1]);
586        f.render_widget(search_block, rows[1]);
587
588        let list_block = Block::default()
589            .borders(Borders::ALL)
590            .border_style(border_style)
591            .style(theme.panel_style());
592        let list_inner = list_block.inner(rows[2]);
593        f.render_widget(list_block, rows[2]);
594
595        // Poll the engine so a just-completed load's rows are applied, then
596        // re-stamp the open-note marker (the reload rebuilt rows without it)
597        // before the list renders.
598        if let Some(list) = &mut self.list {
599            list.poll();
600        }
601        self.stamp_open_marker();
602        if let Some(list) = &mut self.list {
603            list.render_query(f, search_inner, theme, focused);
604            list.render(f, list_inner, theme, focused);
605            // Record the rendered-items rect (block inner area) for mouse
606            // hit-testing: the engine maps a click to `row - rect.y`, so row 0
607            // is the first item. The panel rect (whole sidebar) lets the wheel
608            // scroll from anywhere within the sidebar, not just over the list.
609            list.set_list_rect(list_inner);
610            list.set_panel_rect(rect);
611        }
612    }
613}
614
615#[cfg(test)]
616impl SidebarComponent {
617    pub(crate) fn poll_for_test(&mut self) {
618        if let Some(list) = &mut self.list {
619            list.poll();
620        }
621        self.stamp_open_marker();
622    }
623
624    pub(crate) fn is_loading_for_test(&self) -> bool {
625        self.list.as_ref().is_some_and(|l| l.is_loading())
626    }
627
628    pub(crate) fn note_row_is_open_for_test(&self, name: &str) -> bool {
629        self.list.as_ref().is_some_and(|l| {
630            l.rows().iter().any(|r| {
631                matches!(r, FileListEntry::Note { path, is_open, .. }
632                    if path.get_name() == name && *is_open)
633            })
634        })
635    }
636
637    pub(crate) fn note_row_title_for_test(&self, name: &str) -> Option<String> {
638        self.list.as_ref().and_then(|l| {
639            l.rows().iter().find_map(|r| match r {
640                FileListEntry::Note { path, title, .. } if path.get_name() == name => {
641                    Some(title.clone())
642                }
643                _ => None,
644            })
645        })
646    }
647
648    pub(crate) fn note_row_journal_date_for_test(&self, path: &VaultPath) -> Option<String> {
649        self.list.as_ref().and_then(|l| {
650            l.rows().iter().find_map(|r| match r {
651                FileListEntry::Note {
652                    path: row_path,
653                    journal_date,
654                    ..
655                } if row_path.is_like(path) => journal_date.clone(),
656                _ => None,
657            })
658        })
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use crate::settings::AppSettings;
666    use crate::test_support::{mouse_down_at, temp_vault};
667    use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
668    use tokio::sync::mpsc::unbounded_channel;
669
670    async fn make_sidebar() -> SidebarComponent {
671        let vault = temp_vault("sidebar").await;
672        vault.validate_and_init().await.unwrap();
673        let settings = AppSettings::default();
674        SidebarComponent::new(
675            settings.key_bindings.clone(),
676            vault,
677            settings.icons(),
678            &settings,
679        )
680    }
681
682    /// Build a sidebar over `vault` after creating each named note at root.
683    async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
684        let vault = temp_vault(prefix).await;
685        vault.validate_and_init().await.unwrap();
686        for name in names {
687            vault
688                .create_note(&VaultPath::note_path_from(name), "body")
689                .await
690                .unwrap();
691        }
692        let settings = AppSettings::default();
693        SidebarComponent::new(
694            settings.key_bindings.clone(),
695            vault,
696            settings.icons(),
697            &settings,
698        )
699    }
700
701    /// Clicks anywhere in the sidebar bounds — header, search box, list — are
702    /// consumed by the sidebar. (Click-to-focus itself is handled centrally by
703    /// `PanelSet::handle_mouse`, not here.)
704    #[tokio::test]
705    async fn mouse_down_in_sidebar_bounds_is_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, _rx) = unbounded_channel();
714
715        // Header (top-of-sidebar) area.
716        assert_eq!(
717            sidebar.handle_input(&mouse_down_at(5, 4), &tx),
718            EventState::Consumed
719        );
720        // Search-box area (rows 6..9 within the sidebar layout).
721        assert_eq!(
722            sidebar.handle_input(&mouse_down_at(5, 7), &tx),
723            EventState::Consumed
724        );
725        // Outside the sidebar bounds.
726        assert_eq!(
727            sidebar.handle_input(&mouse_down_at(40, 7), &tx),
728            EventState::NotConsumed
729        );
730    }
731
732    fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
733        InputEvent::Mouse(MouseEvent {
734            kind,
735            column: col,
736            row,
737            modifiers: KeyModifiers::NONE,
738        })
739    }
740
741    /// Load the sidebar at the vault root, then poll the engine to idle so the
742    /// streamed rows have arrived.
743    async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
744        sidebar.navigate(VaultPath::root(), tx);
745        // The streamed source spawns `browse_vault` + a blocking drain; give the
746        // background work real time to land, polling the engine between waits.
747        for _ in 0..50 {
748            if let Some(list) = &mut sidebar.list {
749                list.poll();
750                if !list.is_loading() {
751                    break;
752                }
753            }
754            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
755        }
756        if let Some(list) = &mut sidebar.list {
757            list.poll();
758        }
759    }
760
761    /// Two clicks on the same list row activate it: first selects, second sends
762    /// `OpenPath` (or, for `CreateNote`, materialises the note then opens it).
763    #[tokio::test(flavor = "multi_thread")]
764    async fn mouse_double_click_on_list_row_sends_open_path() {
765        let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
766        let (tx, mut rx) = unbounded_channel();
767        navigate_to_root(&mut sidebar, &tx).await;
768
769        sidebar.rendered_rect = Rect {
770            x: 0,
771            y: 3,
772            width: 30,
773            height: 20,
774        };
775        // The engine records the rendered-items rect; clicks hit-test as
776        // `row - rect.y`, so row 0 (y=9) is the first item.
777        if let Some(list) = &mut sidebar.list {
778            list.set_list_rect(Rect {
779                x: 0,
780                y: 9,
781                width: 30,
782                height: 14,
783            });
784        }
785
786        // First click: in the list area, on the first row (rect.y) — selects.
787        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
788
789        // Second click on the same row activates the entry.
790        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
791        let mut events = Vec::new();
792        while let Ok(evt) = rx.try_recv() {
793            events.push(evt);
794        }
795        assert!(
796            events
797                .iter()
798                .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
799            "expected OpenPath for the activated note, got {events:?}"
800        );
801    }
802
803    /// Scroll wheel anywhere in the sidebar bounds scrolls the file list — even
804    /// when the cursor is over the header or search box. The viewport moves and
805    /// the selection is carried along (keeping its screen position), so with a
806    /// 1-row viewport the selected row changes on the first scroll.
807    #[tokio::test(flavor = "multi_thread")]
808    async fn scroll_down_in_sidebar_bounds_scrolls_list() {
809        let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
810        let (tx, _rx) = unbounded_channel();
811        navigate_to_root(&mut sidebar, &tx).await;
812
813        sidebar.rendered_rect = Rect {
814            x: 0,
815            y: 3,
816            width: 30,
817            height: 20,
818        };
819        // A 1-row viewport over 2 notes, so the list overflows and can scroll.
820        // The panel rect covers the whole sidebar, so the wheel works from the
821        // header/search box too.
822        if let Some(list) = &mut sidebar.list {
823            list.set_list_rect(Rect {
824                x: 0,
825                y: 9,
826                width: 30,
827                height: 1,
828            });
829            list.set_panel_rect(Rect {
830                x: 0,
831                y: 3,
832                width: 30,
833                height: 20,
834            });
835        }
836
837        let first = sidebar
838            .list
839            .as_ref()
840            .unwrap()
841            .selected_row()
842            .map(|e| e.path().to_string());
843
844        // Scroll down with the cursor inside the sidebar header (not the list).
845        let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
846        assert_eq!(result, EventState::Consumed);
847        let after = sidebar
848            .list
849            .as_ref()
850            .unwrap()
851            .selected_row()
852            .map(|e| e.path().to_string());
853        assert_ne!(
854            first, after,
855            "scroll-from-header should scroll the list, carrying the selection"
856        );
857    }
858
859    #[tokio::test]
860    async fn mouse_down_outside_sidebar_is_not_consumed() {
861        let mut sidebar = make_sidebar().await;
862        sidebar.rendered_rect = Rect {
863            x: 0,
864            y: 3,
865            width: 30,
866            height: 20,
867        };
868        let (tx, mut rx) = unbounded_channel();
869
870        // Click to the right of the sidebar (in the editor area).
871        let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
872        assert_eq!(result, EventState::NotConsumed);
873        assert!(rx.try_recv().is_err());
874    }
875
876    /// Navigating loads the directory's notes via the streamed source.
877    #[tokio::test(flavor = "multi_thread")]
878    async fn navigate_loads_directory_notes() {
879        let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
880        assert!(sidebar.is_empty());
881        let (tx, _rx) = unbounded_channel();
882        navigate_to_root(&mut sidebar, &tx).await;
883        assert!(!sidebar.is_empty());
884        assert_eq!(sidebar.note_count(), 1);
885    }
886
887    /// Poll the (already-navigated) engine to idle so a reload's streamed rows
888    /// have arrived.
889    async fn poll_to_idle(sidebar: &mut SidebarComponent) {
890        for _ in 0..50 {
891            if let Some(list) = &mut sidebar.list {
892                list.poll();
893                if !list.is_loading() {
894                    break;
895                }
896            }
897            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
898        }
899        if let Some(list) = &mut sidebar.list {
900            list.poll();
901        }
902    }
903
904    /// Names of the visible note rows, in display order.
905    fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
906        sidebar
907            .list
908            .as_ref()
909            .unwrap()
910            .visible_rows()
911            .iter()
912            .filter_map(|e| match e {
913                FileListEntry::Note { filename, .. } => Some(filename.clone()),
914                _ => None,
915            })
916            .collect()
917    }
918
919    #[tokio::test(flavor = "multi_thread")]
920    async fn apply_sort_reverse_flips_listing_order() {
921        let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
922        let (tx, _rx) = unbounded_channel();
923        navigate_to_root(&mut sidebar, &tx).await;
924        let before = note_names(&sidebar);
925        assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
926        sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
927        poll_to_idle(&mut sidebar).await;
928        let after = note_names(&sidebar);
929        assert_eq!(
930            after,
931            before.iter().rev().cloned().collect::<Vec<_>>(),
932            "descending order should reverse the listing"
933        );
934    }
935
936    #[tokio::test(flavor = "multi_thread")]
937    async fn apply_sort_changes_field() {
938        let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
939        let (tx, _rx) = unbounded_channel();
940        navigate_to_root(&mut sidebar, &tx).await;
941        sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
942        poll_to_idle(&mut sidebar).await;
943        assert_eq!(sidebar.current_sort().0, SortField::Title);
944        assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
945    }
946
947    /// Build a sidebar over a vault with both notes and a subdirectory.
948    async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
949        let vault = temp_vault(prefix).await;
950        vault.validate_and_init().await.unwrap();
951        vault
952            .create_note(&VaultPath::note_path_from("alpha"), "body")
953            .await
954            .unwrap();
955        vault
956            .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
957            .await
958            .unwrap();
959        let settings = AppSettings::default();
960        SidebarComponent::new(
961            settings.key_bindings.clone(),
962            vault,
963            settings.icons(),
964            &settings,
965        )
966    }
967
968    /// Kinds of the visible rows, in display order (excluding the Up row).
969    fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
970        sidebar
971            .list
972            .as_ref()
973            .unwrap()
974            .visible_rows()
975            .iter()
976            .filter_map(|e| match e {
977                FileListEntry::Note { .. } => Some("note"),
978                FileListEntry::Directory { .. } => Some("dir"),
979                _ => None,
980            })
981            .collect()
982    }
983
984    #[tokio::test(flavor = "multi_thread")]
985    async fn group_dirs_puts_directories_first() {
986        let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
987        let (tx, _rx) = unbounded_channel();
988        navigate_to_root(&mut sidebar, &tx).await;
989        assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
990        sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
991        poll_to_idle(&mut sidebar).await;
992        assert_eq!(
993            row_kinds(&sidebar),
994            vec!["dir", "note"],
995            "grouping must cluster directories first"
996        );
997    }
998
999    #[tokio::test(flavor = "multi_thread")]
1000    async fn apply_sort_updates_shared_state() {
1001        let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
1002        let (tx, _rx) = unbounded_channel();
1003        navigate_to_root(&mut sidebar, &tx).await;
1004        sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1005        poll_to_idle(&mut sidebar).await;
1006        assert_eq!(
1007            sidebar.current_sort(),
1008            (SortField::Title, SortOrder::Descending)
1009        );
1010        assert!(!sidebar.group_dirs());
1011    }
1012
1013    #[tokio::test(flavor = "multi_thread")]
1014    async fn set_open_note_stamps_matching_row() {
1015        let mut sb = sidebar_with_notes("sb-open", &["alpha", "beta"]).await;
1016        let (tx, _rx) = unbounded_channel();
1017        navigate_to_root(&mut sb, &tx).await;
1018
1019        sb.set_open_note(Some(VaultPath::note_path_from("alpha")));
1020        assert!(
1021            sb.note_row_is_open_for_test("alpha.md"),
1022            "open note is marked"
1023        );
1024        assert!(
1025            !sb.note_row_is_open_for_test("beta.md"),
1026            "other note is not marked"
1027        );
1028
1029        sb.set_open_note(Some(VaultPath::note_path_from("beta")));
1030        assert!(!sb.note_row_is_open_for_test("alpha.md"));
1031        assert!(sb.note_row_is_open_for_test("beta.md"));
1032
1033        sb.set_open_note(None);
1034        assert!(!sb.note_row_is_open_for_test("beta.md"));
1035    }
1036
1037    #[tokio::test(flavor = "multi_thread")]
1038    async fn update_note_row_changes_title_in_place() {
1039        let mut sb = sidebar_with_notes("sb-title", &["alpha"]).await;
1040        let (tx, _rx) = unbounded_channel();
1041        navigate_to_root(&mut sb, &tx).await;
1042
1043        sb.update_note_row(&VaultPath::note_path_from("alpha"), "Fresh Title");
1044        assert_eq!(
1045            sb.note_row_title_for_test("alpha.md").as_deref(),
1046            Some("Fresh Title")
1047        );
1048    }
1049
1050    #[tokio::test(flavor = "multi_thread")]
1051    async fn rename_note_row_updates_path_and_filename() {
1052        let mut sb = sidebar_with_notes("sb-rename", &["alpha"]).await;
1053        let (tx, _rx) = unbounded_channel();
1054        navigate_to_root(&mut sb, &tx).await;
1055
1056        let to = VaultPath::note_path_from("gamma");
1057        let expected_filename = to.get_parent_path().1;
1058        sb.rename_note_row(&VaultPath::note_path_from("alpha"), &to);
1059        assert!(
1060            sb.note_row_title_for_test("gamma.md").is_some(),
1061            "row now at new name"
1062        );
1063        assert!(
1064            sb.note_row_title_for_test("alpha.md").is_none(),
1065            "old name gone"
1066        );
1067        // Also verify the filename field itself was updated to the new name.
1068        let renamed_filename = sb
1069            .list
1070            .as_ref()
1071            .unwrap()
1072            .rows()
1073            .iter()
1074            .find_map(|r| match r {
1075                FileListEntry::Note { path, filename, .. } if path.is_like(&to) => {
1076                    Some(filename.clone())
1077                }
1078                _ => None,
1079            });
1080        assert_eq!(
1081            renamed_filename.as_deref(),
1082            Some(expected_filename.as_str()),
1083            "filename field must be updated to the new name"
1084        );
1085    }
1086
1087    /// Renaming a journal-dir note (`YYYY-MM-DD`) to a non-date name clears
1088    /// `journal_date` on the row so the glyph and secondary date line update.
1089    #[tokio::test(flavor = "multi_thread")]
1090    async fn rename_note_row_clears_journal_date_when_renamed_away_from_date_name() {
1091        // Build a vault and create a note inside the journal directory with a
1092        // valid YYYY-MM-DD name so `vault.journal_date` returns Some(_).
1093        let vault = crate::test_support::temp_vault("sb-jdate").await;
1094        vault.validate_and_init().await.unwrap();
1095        let journal_path = vault.journal_path().clone();
1096        let date_name = "2026-06-09";
1097        let from = journal_path
1098            .append(&VaultPath::note_path_from(date_name))
1099            .absolute();
1100        vault.create_note(&from, "journal body").await.unwrap();
1101
1102        let settings = AppSettings::default();
1103        let mut sb = SidebarComponent::new(
1104            settings.key_bindings.clone(),
1105            vault,
1106            settings.icons(),
1107            &settings,
1108        );
1109        let (tx, _rx) = unbounded_channel();
1110        // Navigate to the journal directory (not root) so the note row is listed.
1111        sb.navigate(journal_path.clone(), &tx);
1112        for _ in 0..50 {
1113            sb.poll_for_test();
1114            if !sb.is_loading_for_test() {
1115                break;
1116            }
1117            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1118        }
1119        sb.poll_for_test();
1120
1121        // Precondition: the journal row has a non-None `journal_date`.
1122        assert!(
1123            sb.note_row_journal_date_for_test(&from).is_some(),
1124            "journal note must have a journal_date before rename"
1125        );
1126
1127        // Rename the note to a plain name (not a date) in the same directory.
1128        let to = journal_path
1129            .append(&VaultPath::note_path_from("meeting"))
1130            .absolute();
1131        sb.rename_note_row(&from, &to);
1132
1133        // The row should now have journal_date = None.
1134        assert_eq!(
1135            sb.note_row_journal_date_for_test(&to),
1136            None,
1137            "journal_date must be cleared after renaming to a non-date name"
1138        );
1139    }
1140
1141    /// Regression: saving a default must survive navigation. `save_default`
1142    /// updates the cached per-context default that `sort_for`/`navigate` read;
1143    /// without it, navigating re-derives the construction-time default and the
1144    /// saved choice is silently lost.
1145    #[tokio::test(flavor = "multi_thread")]
1146    async fn save_default_survives_navigation() {
1147        let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
1148        let (tx, _rx) = unbounded_channel();
1149        navigate_to_root(&mut sidebar, &tx).await;
1150
1151        sidebar.save_default(SortField::Title, SortOrder::Descending, false);
1152        poll_to_idle(&mut sidebar).await;
1153
1154        // Re-navigate (root is non-journal) — sort_for must now yield the saved
1155        // default, not the constructor-time (Name, Ascending).
1156        sidebar.navigate(VaultPath::root(), &tx);
1157        poll_to_idle(&mut sidebar).await;
1158        assert_eq!(
1159            sidebar.current_sort(),
1160            (SortField::Title, SortOrder::Descending),
1161            "saved default must persist across navigation"
1162        );
1163    }
1164}