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