Skip to main content

kimun_notes/components/
sidebar.rs

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