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