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