Skip to main content

kimun_notes/components/
sidebar.rs

1use std::sync::Arc;
2
3use crate::settings::themes::Theme;
4use async_trait::async_trait;
5use chrono::NaiveDate;
6use futures::StreamExt;
7use kimun_core::nfs::VaultPath;
8use kimun_core::{NoteVault, NotesValidation, ResultType, VaultBrowseOptionsBuilder};
9use ratatui::Frame;
10use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
11use ratatui::style::Style;
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Borders, Paragraph};
14
15use crate::components::Component;
16use crate::components::event_state::EventState;
17use crate::components::events::{
18    AppEvent, AppTx, AppTxExt, FileOp, InputEvent, SortTarget, redraw_callback,
19};
20use crate::components::file_list::{FileListEntry, SortField, SortOrder, entry_order};
21use crate::components::search_list::{
22    Emit, Filter, KeyReaction, OrderFn, RowSource, SearchList, SearchMouse,
23};
24use crate::components::sortable::{
25    PropertySort, SortState, SortableList, is_blank_property, property_key,
26};
27use crate::keys::KeyBindings;
28use crate::settings::AppSettings;
29use crate::settings::icons::Icons;
30
31/// Streamed `RowSource` over one directory's listing. Pushes an `Up` row first
32/// (when not at root) so it is always present, then forwards each entry the
33/// moment `browse_vault_stream` yields it — rows show up while a slow drive is
34/// still being read. Ordering is the engine's job (`order_by`, see
35/// [`listing_order`]), so nothing is re-delivered at the end. Loads once; a
36/// local `Filter::Fuzzy` narrows the set and `leading_row` provides the
37/// "Create: …" affordance.
38struct DirListingSource {
39    vault: Arc<NoteVault>,
40    dir: VaultPath,
41}
42
43#[async_trait]
44impl RowSource<FileListEntry> for DirListingSource {
45    async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
46        // Up row first (if not root) — pushed so it's always present.
47        if !self.dir.is_root_or_empty() {
48            emit.push(FileListEntry::Up {
49                parent: self.dir.get_parent_path().0,
50            });
51        }
52
53        let options = VaultBrowseOptionsBuilder::new(&self.dir)
54            .recursive(false)
55            .validation(NotesValidation::Full)
56            .build();
57
58        let mut stream = std::pin::pin!(self.vault.browse_vault_stream(options));
59        while let Some(item) = stream.next().await {
60            let result = match item {
61                Ok(result) => result,
62                Err(e) => {
63                    tracing::warn!("sidebar directory listing failed: {e}");
64                    break;
65                }
66            };
67            // `is_like` ignores relative/absolute form: skip the current dir's
68            // own "." entry whichever form each side carries.
69            if matches!(result.rtype, ResultType::Directory) && result.path.is_like(&self.dir) {
70                continue;
71            }
72            let journal_date = self
73                .vault
74                .journal_date(&result.path)
75                .map(format_journal_date);
76            emit.push(FileListEntry::from_result(result, journal_date));
77        }
78        emit.done();
79    }
80
81    fn leading_row(&self, query: &str) -> Option<FileListEntry> {
82        if query.is_empty() {
83            None
84        } else {
85            let path = self.dir.append(&VaultPath::note_path_from(query)).flatten();
86            Some(FileListEntry::CreateNote {
87                filename: path.to_string(),
88                path,
89            })
90        }
91    }
92
93    fn reload_on_query(&self) -> bool {
94        // Load the directory once; the local fuzzy filter narrows it and
95        // `leading_row` keeps the create affordance in sync per keystroke.
96        false
97    }
98}
99
100pub struct SidebarComponent {
101    current_dir: VaultPath,
102    /// The note currently open in the editor, if any — drives the open-note
103    /// marker. `None` on the Browse screen (it never opens notes). Matched
104    /// against `FileListEntry::Note` rows by `is_like`.
105    open_note: Option<VaultPath>,
106    list: Option<SearchList<FileListEntry>>,
107    vault: Arc<NoteVault>,
108    icons: Icons,
109    default_sort_field: SortField,
110    default_sort_order: SortOrder,
111    journal_sort_field: SortField,
112    journal_sort_order: SortOrder,
113    /// Sort field/order of the active listing. Set per `navigate` from the
114    /// per-dir defaults (kept across a refresh of the same directory); the
115    /// sort dialog changes it via `apply_sort`, which hands the engine a new
116    /// order.
117    sort: (SortField, SortOrder),
118    /// Index values a property sort orders by, fetched per key.
119    property_sort: PropertySort,
120    /// "Group directories first" for the active listing; see `sort`.
121    group_dirs: bool,
122    rendered_rect: Rect,
123    /// Screen cell each breadcrumb segment was drawn into on the last render,
124    /// with the directory it navigates to — clickable breadcrumb hit-test.
125    breadcrumb_cells: Vec<(Rect, VaultPath)>,
126    /// The sort chip (`Name ↑`) on the search box border from the last render.
127    sort_chip: crate::components::clickable::BorderChip,
128    /// Whether to draw the sort chip at all — off where the host cannot open
129    /// the sort dialog (Browse).
130    show_sort_chip: bool,
131    key_bindings: KeyBindings,
132}
133
134impl SidebarComponent {
135    /// Build a sidebar from the application settings, pulling its key bindings
136    /// and icons from `settings`. The shared constructor for the screens that
137    /// host a sidebar (Editor and Browse), so the kb/icons wiring lives once.
138    pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
139        Self::new(
140            settings.key_bindings.clone(),
141            vault,
142            settings.icons(),
143            settings,
144        )
145    }
146
147    pub fn new(
148        key_bindings: KeyBindings,
149        vault: Arc<NoteVault>,
150        icons: Icons,
151        settings: &AppSettings,
152    ) -> Self {
153        let default_sort_field = SortField::from(settings.default_sort_field);
154        let default_sort_order = SortOrder::from(settings.default_sort_order);
155        Self {
156            current_dir: VaultPath::root(),
157            open_note: None,
158            list: None,
159            vault,
160            icons,
161            default_sort_field: default_sort_field.clone(),
162            default_sort_order,
163            journal_sort_field: SortField::from(settings.journal_sort_field),
164            journal_sort_order: SortOrder::from(settings.journal_sort_order),
165            sort: (default_sort_field, default_sort_order),
166            property_sort: PropertySort::default(),
167            group_dirs: settings.group_directories,
168            rendered_rect: Rect::default(),
169            breadcrumb_cells: Vec::new(),
170            sort_chip: Default::default(),
171            show_sort_chip: true,
172            key_bindings,
173        }
174    }
175
176    /// The breadcrumb segment under the given screen cell, if any.
177    fn breadcrumb_at(&self, column: u16, row: u16) -> Option<&VaultPath> {
178        self.breadcrumb_cells
179            .iter()
180            .find(|(rect, _)| rect.contains(Position { x: column, y: row }))
181            .map(|(_, dir)| dir)
182    }
183
184    pub fn current_dir(&self) -> &VaultPath {
185        &self.current_dir
186    }
187
188    /// `true` until a directory has been loaded (no engine yet). The editor
189    /// uses this to decide whether to issue the first-open navigation.
190    pub fn is_empty(&self) -> bool {
191        self.list.is_none()
192    }
193
194    /// Sort field/order to apply for `dir` (journal dirs get their own).
195    fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
196        if dir.is_like(self.vault.journal_path()) {
197            (self.journal_sort_field.clone(), self.journal_sort_order)
198        } else {
199            (self.default_sort_field.clone(), self.default_sort_order)
200        }
201    }
202
203    /// (Re)build the engine for `dir`, replacing any prior listing. This is the
204    /// single directory-navigation entry point: changing directory = rebuild
205    /// the engine with a fresh `DirListingSource` for the new dir, sorted by
206    /// that directory's default.
207    pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
208        self.sort = self.sort_for(&dir);
209        self.current_dir = dir;
210        self.build_listing(tx);
211    }
212
213    /// A fresh engine over `current_dir`, ordered by the active sort. A
214    /// property sort uses the values cached for its key and fetches them
215    /// again, so edits since the last fetch show up.
216    fn build_listing(&mut self, tx: &AppTx) {
217        let source = DirListingSource {
218            vault: self.vault.clone(),
219            dir: self.current_dir.clone(),
220        };
221        self.list = Some(
222            SearchList::builder(source, redraw_callback(tx.clone()))
223                .filter(Filter::Fuzzy)
224                .order_by(self.order())
225                .yank_combos_from(&self.key_bindings)
226                .icons(self.icons.clone())
227                .build(),
228        );
229        self.fetch_property_values(tx);
230    }
231
232    /// Start a fetch of the active property sort's values, if it is one;
233    /// any other sort forgets the cached values.
234    fn fetch_property_values(&mut self, tx: &AppTx) {
235        match property_key(&self.sort.0) {
236            Some(key) => self.property_sort.fetch(&self.vault, key, tx),
237            None => self.property_sort.clear(),
238        }
239    }
240
241    /// The engine order for the active sort. A property sort whose values
242    /// have not arrived yet orders by name (see [`entry_order`]).
243    fn order(&self) -> OrderFn<FileListEntry> {
244        let (field, order) = self.sort.clone();
245        let values = self.property_sort.values_for_field(&field);
246        entry_order(field, order, self.group_dirs, values)
247    }
248
249    /// Re-sort the loaded rows by the active sort (no reload). A property
250    /// sort still waiting for its values keeps the current order.
251    fn reorder(&mut self) {
252        if self.property_sort.is_awaiting(&self.sort.0) {
253            return;
254        }
255        let order = self.order();
256        if let Some(list) = &mut self.list {
257            list.set_order(Some(order));
258        }
259    }
260
261    /// Property values fetched for `key` landed: re-sort if they belong to
262    /// the active sort. `false` when they are stale and were dropped.
263    pub(crate) fn on_property_sort_values(
264        &mut self,
265        key: &str,
266        values: crate::components::file_list::PropertyValues,
267    ) -> bool {
268        let accepted = self.property_sort.receive(key, values);
269        if accepted {
270            self.reorder();
271        }
272        accepted
273    }
274
275    /// Apply property values that arrived since the last frame.
276    fn poll_property_sort(&mut self) {
277        if let Some((key, values)) = self.property_sort.poll() {
278            self.on_property_sort_values(&key, values);
279        }
280    }
281
282    /// Rebuild the listing only when it is currently showing `dir`, so a
283    /// create/rename/delete/move in that directory is reflected without yanking
284    /// the user away from an unrelated directory they browsed to. The active
285    /// sort is kept (a property sort refetches its values). A no-op
286    /// otherwise. Shared by every screen that hosts a sidebar.
287    pub fn refresh_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
288        if dir.is_like(&self.current_dir) {
289            self.build_listing(tx);
290        }
291    }
292
293    /// Set (or clear) the note the editor currently has open, then re-stamp the
294    /// marker on the live rows. The editor calls this on every open and on an
295    /// open-note rename.
296    pub fn set_open_note(&mut self, path: Option<VaultPath>) {
297        self.open_note = path;
298        self.stamp_open_marker();
299    }
300
301    /// Re-apply `is_open` to the rows so exactly the open note's row is marked.
302    /// Idempotent: a full reload rebuilds rows without the flag, so this runs
303    /// again after each load (see `render`).
304    fn stamp_open_marker(&mut self) {
305        let open = self.open_note.clone();
306        if let Some(list) = &mut self.list {
307            list.update_rows(|row| {
308                if let FileListEntry::Note { path, is_open, .. } = row {
309                    let want = open.as_ref().is_some_and(|o| path.is_like(o));
310                    if *is_open != want {
311                        *is_open = want;
312                        return true;
313                    }
314                }
315                false
316            });
317        }
318    }
319
320    /// Update the title of the row whose note path matches `path`, if it is in
321    /// the current listing. Called when a note is saved and its title (first
322    /// body line) may have changed. Position is left unchanged (no re-sort).
323    pub fn update_note_row(&mut self, path: &VaultPath, new_title: &str) {
324        if let Some(list) = &mut self.list {
325            list.update_rows(|row| {
326                if let FileListEntry::Note {
327                    path: row_path,
328                    title,
329                    ..
330                } = row
331                    && row_path.is_like(path)
332                    && title != new_title
333                {
334                    *title = new_title.to_string();
335                    return true;
336                }
337                false
338            });
339        }
340    }
341
342    /// Move the row at `from` to `to` (path + filename + journal_date) in
343    /// place, for a same-directory note rename. The row is re-sorted into its
344    /// new spot under the active `listing_order` (a Name sort reads the very
345    /// field this changes) and keeps the highlight if it had it.
346    /// `journal_date` is recomputed so a rename into/out of a
347    /// `YYYY-MM-DD` name under the journal directory flips the glyph and the
348    /// secondary date line correctly.
349    pub fn rename_note_row(&mut self, from: &VaultPath, to: &VaultPath) {
350        let new_filename = to.get_parent_path().1;
351        let new_journal_date = self.vault.journal_date(to).map(format_journal_date);
352        if let Some(list) = &mut self.list {
353            list.update_rows(|row| {
354                if let FileListEntry::Note {
355                    path,
356                    filename,
357                    journal_date,
358                    ..
359                } = row
360                    && path.is_like(from)
361                {
362                    *path = to.clone();
363                    *filename = new_filename.clone();
364                    *journal_date = new_journal_date.clone();
365                    return true;
366                }
367                false
368            });
369        }
370    }
371
372    /// Draw (and accept clicks on) the sort chip — on by default; off for a
373    /// host that cannot open the sort dialog.
374    pub fn set_sort_chip(&mut self, show: bool) {
375        self.show_sort_chip = show;
376    }
377
378    /// Seed the directory the sidebar will show before its first `navigate`.
379    /// Lets a screen open at a non-root path while keeping `current_dir` the
380    /// single source of truth for the browsed directory.
381    pub fn set_current_dir(&mut self, dir: VaultPath) {
382        self.current_dir = dir;
383    }
384
385    /// Current sort field/order for the active listing.
386    pub fn current_sort(&self) -> (SortField, SortOrder) {
387        self.sort.clone()
388    }
389
390    /// Current "group directories first" flag.
391    pub fn group_dirs(&self) -> bool {
392        self.group_dirs
393    }
394
395    /// Apply a sort selection from the sort dialog: the engine re-orders the
396    /// rows it already holds — no second walk of the directory. A property
397    /// sort orders by the values cached for its key; without them the current
398    /// order stays until they arrive (see [`SortableList::apply_sort`]).
399    pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
400        self.sort = (field, order);
401        self.group_dirs = group_dirs;
402        self.reorder();
403    }
404
405    /// `true` when the active directory is the journal (so its sort default is
406    /// the journal one). Lets the caller persist to the matching settings.
407    pub fn is_current_journal(&self) -> bool {
408        self.current_dir.is_like(self.vault.journal_path())
409    }
410
411    /// Record `field` / `order` as the in-session default for the active
412    /// context (journal vs. normal) without re-sorting — the sidebar's "save
413    /// as default", for callers that already applied the sort. Without it the
414    /// cached per-context defaults that `sort_for`/`navigate` read stay at
415    /// their construction-time values until restart. Never called with a
416    /// property sort (it can't be a default).
417    pub fn remember_default(&mut self, field: SortField, order: SortOrder) {
418        if self.is_current_journal() {
419            self.journal_sort_field = field;
420            self.journal_sort_order = order;
421        } else {
422            self.default_sort_field = field;
423            self.default_sort_order = order;
424        }
425    }
426
427    /// Number of note rows currently visible (excludes Up / dirs / create).
428    fn note_count(&self) -> usize {
429        match &self.list {
430            None => 0,
431            Some(list) => list
432                .visible_rows()
433                .iter()
434                .filter(|e| matches!(e, FileListEntry::Note { .. }))
435                .count(),
436        }
437    }
438
439    /// Act on the selected row: Up/Note/Directory → `OpenPath` (directories and
440    /// Up route back through the editor's navigate, rebuilding the engine);
441    /// CreateNote → materialise the note, then open it.
442    fn activate_selected_entry(&self, tx: &AppTx) {
443        let Some(list) = &self.list else { return };
444        let Some(entry) = list.selected_row() else {
445            return;
446        };
447        match entry {
448            FileListEntry::CreateNote { path, .. } => {
449                let path = path.clone();
450                let vault = Arc::clone(&self.vault);
451                let tx2 = tx.clone();
452                tokio::spawn(async move {
453                    match vault.load_or_create_note(&path, None).await {
454                        Ok((_, created)) => tx2.announce_and_open(path, created),
455                        Err(e) => {
456                            tracing::warn!("create note failed for {path}: {e}");
457                        }
458                    }
459                });
460            }
461            FileListEntry::Attachment { path, .. } => {
462                tx.send(AppEvent::OpenAttachment(path.clone())).ok();
463            }
464            other => {
465                tx.send(AppEvent::open(other.path().clone())).ok();
466            }
467        }
468    }
469}
470
471/// Format a `NaiveDate` as a human-readable string with day-of-week.
472/// Example: "Wednesday, March 17, 2026"
473fn format_journal_date(date: NaiveDate) -> String {
474    date.format("%A, %B %-d, %Y").to_string()
475}
476
477impl SortableList for SidebarComponent {
478    fn sort_state(&self) -> SortState {
479        let (field, order) = self.current_sort();
480        SortState {
481            field,
482            order,
483            group_dirs: Some(self.group_dirs),
484        }
485    }
486
487    /// A `None` group flag keeps the current grouping. A property sort on a
488    /// new key fetches its values from the index and re-sorts when they
489    /// land; on the same key (an Order / Group toggle) it reuses the cached
490    /// values. One with no key yet is ignored.
491    fn apply_sort(&mut self, state: &SortState, tx: &AppTx) {
492        if is_blank_property(&state.field) {
493            return;
494        }
495        let group_dirs = state.group_dirs.unwrap_or(self.group_dirs);
496        SidebarComponent::apply_sort(self, state.field.clone(), state.order, group_dirs);
497        self.property_sort.sync(&self.vault, &state.field, tx);
498    }
499
500    fn allows_property(&self) -> bool {
501        true
502    }
503}
504
505impl Component for SidebarComponent {
506    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
507        if let InputEvent::Mouse(mouse) = event {
508            let pos = Position {
509                x: mouse.column,
510                y: mouse.row,
511            };
512            if !self.rendered_rect.contains(pos) {
513                return EventState::NotConsumed;
514            }
515            // A click on a breadcrumb segment jumps up the tree.
516            if matches!(
517                mouse.kind,
518                ratatui::crossterm::event::MouseEventKind::Down(
519                    ratatui::crossterm::event::MouseButton::Left
520                )
521            ) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
522            {
523                tx.send(AppEvent::open(dir.clone())).ok();
524                return EventState::Consumed;
525            }
526            // A click on the sort chip opens the sort dialog.
527            if self.sort_chip.hit(mouse) {
528                tx.send(AppEvent::OpenSortDialog(SortTarget::Sidebar)).ok();
529                return EventState::Consumed;
530            }
531            // Click-to-focus is handled centrally by `PanelSet::handle_mouse`;
532            // only the sidebar's internal behavior lives here. The engine
533            // hit-tests the wheel against the recorded panel rect (the whole
534            // sidebar — header and search box included) and clicks against
535            // the list rect.
536            if let Some(list) = &mut self.list {
537                match list.handle_mouse(mouse) {
538                    SearchMouse::Activated(_) | SearchMouse::DoubleClicked { repeat: false, .. } => {
539                        self.activate_selected_entry(tx)
540                    }
541                    // Right-click on a file/dir row → context menu.
542                    SearchMouse::Context(_) => {
543                        if let Some(entry) = list.selected_row()
544                            && !matches!(
545                                entry,
546                                FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
547                            )
548                        {
549                            tx.send(AppEvent::FileOp(FileOp::ShowMenu(entry.path().clone())))
550                                .ok();
551                        }
552                    }
553                    // ContentScroll* are unreachable: this host records no
554                    // content sub-region.
555                    SearchMouse::Selected(_)
556                    | SearchMouse::Scrolled
557                    | SearchMouse::ContentScrollUp
558                    | SearchMouse::ContentScrollDown
559                    | SearchMouse::InputFocused
560                    // A repeat double-click: its first press already activated.
561                    | SearchMouse::DoubleClicked { repeat: true, .. }
562                    | SearchMouse::Autocomplete { .. }
563                    | SearchMouse::None => {}
564                }
565            }
566            return EventState::Consumed;
567        }
568
569        if let InputEvent::Key(key) = event {
570            if self.list.is_none() {
571                return EventState::NotConsumed;
572            }
573            let reaction = self.list.as_mut().unwrap().handle_key(key);
574            match reaction {
575                KeyReaction::Submit => {
576                    self.activate_selected_entry(tx);
577                    EventState::Consumed
578                }
579                KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
580                KeyReaction::Yank(target) => {
581                    crate::components::yank_row(target, tx);
582                    EventState::Consumed
583                }
584                KeyReaction::Intercepted(_) | KeyReaction::ListVerb(_) | KeyReaction::Unhandled => {
585                    EventState::NotConsumed
586                }
587            }
588        } else {
589            EventState::NotConsumed
590        }
591    }
592
593    fn hint_shortcuts(&self) -> Vec<(String, String)> {
594        use crate::keys::action_shortcuts::ActionShortcuts;
595
596        crate::components::hints::hints_for(
597            &self.key_bindings,
598            &[
599                (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
600                (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
601                (ActionShortcuts::OpenSortDialog, "sort"),
602            ],
603        )
604    }
605
606    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
607        self.rendered_rect = rect;
608
609        let rows = Layout::default()
610            .direction(Direction::Vertical)
611            .constraints([
612                Constraint::Length(3),
613                Constraint::Length(3),
614                Constraint::Min(0),
615            ])
616            .split(rect);
617
618        let border_style = theme.border_style(focused);
619
620        let header = Block::default()
621            .title(format!("─ Files · {} ", self.current_dir))
622            .borders(Borders::ALL)
623            .border_style(border_style)
624            .style(theme.panel_style());
625        let header_inner = header.inner(rows[0]);
626        f.render_widget(header, rows[0]);
627
628        // Clickable breadcrumb: one span per ancestor directory, separated by
629        // " / ", with the note count right-aligned. Each segment's cell is
630        // recorded for the click hit-test.
631        self.breadcrumb_cells.clear();
632        let seg_style = Style::default()
633            .fg(theme.fg_secondary.to_ratatui())
634            .bg(theme.bg_panel.to_ratatui());
635        let sep_style = Style::default()
636            .fg(theme.gray.to_ratatui())
637            .bg(theme.bg_panel.to_ratatui());
638        let mut spans: Vec<Span> = Vec::new();
639        let mut x = header_inner.x;
640        let mut push_segment =
641            |spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
642                let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
643                // Only record cells that are (at least partly) visible — the
644                // Paragraph clips at the header edge, so fully clipped
645                // segments must not be clickable.
646                if *x < header_inner.right() {
647                    let visible = w.min(header_inner.right() - *x);
648                    let r = Rect::new(*x, header_inner.y, visible, 1);
649                    crate::components::clickable::register(r);
650                    self.breadcrumb_cells.push((r, dir));
651                }
652                spans.push(Span::styled(label, seg_style));
653                *x += w;
654            };
655        push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
656        let slices = self.current_dir.get_slices();
657        let mut acc = String::new();
658        for slice in &slices {
659            spans.push(Span::styled(" / ", sep_style));
660            x += 3;
661            acc.push('/');
662            acc.push_str(slice);
663            push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
664        }
665        let count = format!("{} notes", self.note_count());
666        let used: u16 = x - header_inner.x;
667        let pad = header_inner
668            .width
669            .saturating_sub(used)
670            .saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
671        spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
672        spans.push(Span::styled(count, sep_style));
673        f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
674
675        // The sort chip sits on the search box, next to what it orders.
676        let mut search_block = Block::default().title(" Search");
677        if self.show_sort_chip {
678            let (field, order) = self.current_sort();
679            search_block = self.sort_chip.place(
680                search_block,
681                rows[1],
682                crate::components::clickable::Edge::Top,
683                crate::components::sortable::sort_chip_line(&field, order, theme),
684            );
685        } else {
686            self.sort_chip.hide();
687        }
688        let search_block = search_block
689            .borders(Borders::ALL)
690            .border_style(border_style)
691            .style(theme.panel_style());
692        let search_inner = search_block.inner(rows[1]);
693        f.render_widget(search_block, rows[1]);
694
695        let list_block = Block::default()
696            .borders(Borders::ALL)
697            .border_style(border_style)
698            .style(theme.panel_style());
699        let list_inner = list_block.inner(rows[2]);
700        f.render_widget(list_block, rows[2]);
701
702        // Poll the engine so a just-completed load's rows are applied, then
703        // re-stamp the open-note marker (the reload rebuilt rows without it)
704        // before the list renders.
705        if let Some(list) = &mut self.list {
706            list.poll();
707        }
708        self.poll_property_sort();
709        self.stamp_open_marker();
710        if let Some(list) = &mut self.list {
711            list.render_query(f, search_inner, theme, focused);
712            list.render(f, list_inner, theme, focused);
713            // Record the rendered-items rect (block inner area) for mouse
714            // hit-testing: the engine maps a click to `row - rect.y`, so row 0
715            // is the first item. The panel rect (whole sidebar) lets the wheel
716            // scroll from anywhere within the sidebar, not just over the list.
717            list.set_list_rect(list_inner);
718            list.set_panel_rect(rect);
719        }
720    }
721}
722
723#[cfg(test)]
724impl SidebarComponent {
725    pub(crate) fn poll_for_test(&mut self) {
726        if let Some(list) = &mut self.list {
727            list.poll();
728        }
729        self.poll_property_sort();
730        self.stamp_open_marker();
731    }
732
733    pub(crate) fn is_loading_for_test(&self) -> bool {
734        self.list.as_ref().is_some_and(|l| l.is_loading())
735    }
736
737    pub(crate) fn note_row_is_open_for_test(&self, name: &str) -> bool {
738        self.list.as_ref().is_some_and(|l| {
739            l.rows().iter().any(|r| {
740                matches!(r, FileListEntry::Note { path, is_open, .. }
741                    if path.get_name() == name && *is_open)
742            })
743        })
744    }
745
746    pub(crate) fn note_row_title_for_test(&self, name: &str) -> Option<String> {
747        self.list.as_ref().and_then(|l| {
748            l.rows().iter().find_map(|r| match r {
749                FileListEntry::Note { path, title, .. } if path.get_name() == name => {
750                    Some(title.clone())
751                }
752                _ => None,
753            })
754        })
755    }
756
757    pub(crate) fn note_row_journal_date_for_test(&self, path: &VaultPath) -> Option<String> {
758        self.list.as_ref().and_then(|l| {
759            l.rows().iter().find_map(|r| match r {
760                FileListEntry::Note {
761                    path: row_path,
762                    journal_date,
763                    ..
764                } if row_path.is_like(path) => journal_date.clone(),
765                _ => None,
766            })
767        })
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774    use crate::settings::AppSettings;
775    use crate::test_support::{mouse_down_at, temp_vault};
776    use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
777    use tokio::sync::mpsc::unbounded_channel;
778
779    async fn make_sidebar() -> SidebarComponent {
780        let vault = temp_vault("sidebar").await;
781        vault.validate_and_init().await.unwrap();
782        let settings = AppSettings::default();
783        SidebarComponent::new(
784            settings.key_bindings.clone(),
785            vault,
786            settings.icons(),
787            &settings,
788        )
789    }
790
791    /// Build a sidebar over `vault` after creating each named note at root.
792    async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
793        let vault = temp_vault(prefix).await;
794        vault.validate_and_init().await.unwrap();
795        for name in names {
796            vault
797                .create_note(&VaultPath::note_path_from(name), "body")
798                .await
799                .unwrap();
800        }
801        let settings = AppSettings::default();
802        SidebarComponent::new(
803            settings.key_bindings.clone(),
804            vault,
805            settings.icons(),
806            &settings,
807        )
808    }
809
810    /// Clicks anywhere in the sidebar bounds — header, search box, list — are
811    /// consumed by the sidebar. (Click-to-focus itself is handled centrally by
812    /// `PanelSet::handle_mouse`, not here.)
813    #[tokio::test]
814    async fn mouse_down_in_sidebar_bounds_is_consumed() {
815        let mut sidebar = make_sidebar().await;
816        sidebar.rendered_rect = Rect {
817            x: 0,
818            y: 3,
819            width: 30,
820            height: 20,
821        };
822        let (tx, _rx) = unbounded_channel();
823
824        // Header (top-of-sidebar) area.
825        assert_eq!(
826            sidebar.handle_input(&mouse_down_at(5, 4), &tx),
827            EventState::Consumed
828        );
829        // Search-box area (rows 6..9 within the sidebar layout).
830        assert_eq!(
831            sidebar.handle_input(&mouse_down_at(5, 7), &tx),
832            EventState::Consumed
833        );
834        // Outside the sidebar bounds.
835        assert_eq!(
836            sidebar.handle_input(&mouse_down_at(40, 7), &tx),
837            EventState::NotConsumed
838        );
839    }
840
841    fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
842        InputEvent::Mouse(MouseEvent {
843            kind,
844            column: col,
845            row,
846            modifiers: KeyModifiers::NONE,
847        })
848    }
849
850    /// Load the sidebar at the vault root, then poll the engine to idle so the
851    /// streamed rows have arrived.
852    async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
853        sidebar.navigate(VaultPath::root(), tx);
854        // The streamed source awaits `browse_vault_stream`; give the background
855        // work real time to land, polling the engine between waits.
856        for _ in 0..50 {
857            if let Some(list) = &mut sidebar.list {
858                list.poll();
859                if !list.is_loading() {
860                    break;
861                }
862            }
863            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
864        }
865        if let Some(list) = &mut sidebar.list {
866            list.poll();
867        }
868    }
869
870    /// Two clicks on the same list row activate it: first selects, second sends
871    /// `OpenPath` (or, for `CreateNote`, materialises the note then opens it).
872    #[tokio::test(flavor = "multi_thread")]
873    async fn mouse_double_click_on_list_row_sends_open_path() {
874        let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
875        let (tx, mut rx) = unbounded_channel();
876        navigate_to_root(&mut sidebar, &tx).await;
877
878        sidebar.rendered_rect = Rect {
879            x: 0,
880            y: 3,
881            width: 30,
882            height: 20,
883        };
884        // The engine records the rendered-items rect; clicks hit-test as
885        // `row - rect.y`, so row 0 (y=9) is the first item.
886        if let Some(list) = &mut sidebar.list {
887            list.set_list_rect(Rect {
888                x: 0,
889                y: 9,
890                width: 30,
891                height: 14,
892            });
893        }
894
895        // First click: in the list area, on the first row (rect.y) — selects.
896        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
897
898        // Second click on the same row activates the entry.
899        sidebar.handle_input(&mouse_down_at(5, 9), &tx);
900        let mut events = Vec::new();
901        while let Ok(evt) = rx.try_recv() {
902            events.push(evt);
903        }
904        assert!(
905            events
906                .iter()
907                .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
908            "expected OpenPath for the activated note, got {events:?}"
909        );
910    }
911
912    /// Scroll wheel anywhere in the sidebar bounds scrolls the file list — even
913    /// when the cursor is over the header or search box. The viewport moves and
914    /// the selection is carried along (keeping its screen position), so with a
915    /// 1-row viewport the selected row changes on the first scroll.
916    #[tokio::test(flavor = "multi_thread")]
917    async fn scroll_down_in_sidebar_bounds_scrolls_list() {
918        let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
919        let (tx, _rx) = unbounded_channel();
920        navigate_to_root(&mut sidebar, &tx).await;
921
922        sidebar.rendered_rect = Rect {
923            x: 0,
924            y: 3,
925            width: 30,
926            height: 20,
927        };
928        // A 1-row viewport over 2 notes, so the list overflows and can scroll.
929        // The panel rect covers the whole sidebar, so the wheel works from the
930        // header/search box too.
931        if let Some(list) = &mut sidebar.list {
932            list.set_list_rect(Rect {
933                x: 0,
934                y: 9,
935                width: 30,
936                height: 1,
937            });
938            list.set_panel_rect(Rect {
939                x: 0,
940                y: 3,
941                width: 30,
942                height: 20,
943            });
944        }
945
946        let first = sidebar
947            .list
948            .as_ref()
949            .unwrap()
950            .selected_row()
951            .map(|e| e.path().to_string());
952
953        // Scroll down with the cursor inside the sidebar header (not the list).
954        let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
955        assert_eq!(result, EventState::Consumed);
956        let after = sidebar
957            .list
958            .as_ref()
959            .unwrap()
960            .selected_row()
961            .map(|e| e.path().to_string());
962        assert_ne!(
963            first, after,
964            "scroll-from-header should scroll the list, carrying the selection"
965        );
966    }
967
968    #[tokio::test]
969    async fn mouse_down_outside_sidebar_is_not_consumed() {
970        let mut sidebar = make_sidebar().await;
971        sidebar.rendered_rect = Rect {
972            x: 0,
973            y: 3,
974            width: 30,
975            height: 20,
976        };
977        let (tx, mut rx) = unbounded_channel();
978
979        // Click to the right of the sidebar (in the editor area).
980        let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
981        assert_eq!(result, EventState::NotConsumed);
982        assert!(rx.try_recv().is_err());
983    }
984
985    /// Navigating loads the directory's notes via the streamed source.
986    #[tokio::test(flavor = "multi_thread")]
987    async fn navigate_loads_directory_notes() {
988        let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
989        assert!(sidebar.is_empty());
990        let (tx, _rx) = unbounded_channel();
991        navigate_to_root(&mut sidebar, &tx).await;
992        assert!(!sidebar.is_empty());
993        assert_eq!(sidebar.note_count(), 1);
994    }
995
996    /// Poll the (already-navigated) engine to idle so a reload's streamed rows
997    /// have arrived.
998    async fn poll_to_idle(sidebar: &mut SidebarComponent) {
999        for _ in 0..50 {
1000            if let Some(list) = &mut sidebar.list {
1001                list.poll();
1002                if !list.is_loading() {
1003                    break;
1004                }
1005            }
1006            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1007        }
1008        if let Some(list) = &mut sidebar.list {
1009            list.poll();
1010        }
1011    }
1012
1013    /// Names of the visible note rows, in display order.
1014    fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
1015        sidebar
1016            .list
1017            .as_ref()
1018            .unwrap()
1019            .visible_rows()
1020            .iter()
1021            .filter_map(|e| match e {
1022                FileListEntry::Note { filename, .. } => Some(filename.clone()),
1023                _ => None,
1024            })
1025            .collect()
1026    }
1027
1028    /// The directory listing streams each row the moment the walk yields it
1029    /// and nothing else: ordering is the engine's job (`order_by`), so there
1030    /// is no end-of-walk re-delivery to make rows jump.
1031    #[tokio::test(flavor = "multi_thread")]
1032    async fn listing_streams_each_row_then_done() {
1033        use crate::components::search_list::Loaded;
1034
1035        let sidebar = sidebar_with_notes("sidebar-stream", &["charlie", "alpha", "bravo"]).await;
1036        let source = DirListingSource {
1037            vault: sidebar.vault.clone(),
1038            dir: VaultPath::root(),
1039        };
1040        let (emit, rx) = Emit::capture();
1041
1042        source.load("", emit).await;
1043
1044        let events: Vec<Loaded<FileListEntry>> = rx.try_iter().map(|(_, ev)| ev).collect();
1045        let pushed_notes = events
1046            .iter()
1047            .filter(|e| matches!(e, Loaded::Push(FileListEntry::Note { .. })))
1048            .count();
1049        assert_eq!(pushed_notes, 3, "each note is streamed as its own row");
1050        assert!(
1051            !events.iter().any(|e| matches!(e, Loaded::Replace(_))),
1052            "no whole-set re-delivery"
1053        );
1054        assert!(
1055            matches!(events.last(), Some(Loaded::Done)),
1056            "done closes the load"
1057        );
1058    }
1059
1060    /// A sort change re-orders the rows already loaded; it must not walk the
1061    /// directory again.
1062    #[tokio::test(flavor = "multi_thread")]
1063    async fn apply_sort_reorders_without_reloading() {
1064        let mut sidebar =
1065            sidebar_with_notes("sidebar-resort", &["alpha", "bravo", "charlie"]).await;
1066        let (tx, _rx) = unbounded_channel();
1067        navigate_to_root(&mut sidebar, &tx).await;
1068        let before = note_names(&sidebar);
1069        assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
1070
1071        sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
1072
1073        let list = sidebar.list.as_ref().unwrap();
1074        assert!(
1075            !list.is_loading(),
1076            "a sort change must not reload the listing"
1077        );
1078        assert_eq!(
1079            note_names(&sidebar),
1080            before.iter().rev().cloned().collect::<Vec<_>>(),
1081            "rows re-ordered in place"
1082        );
1083    }
1084
1085    #[tokio::test(flavor = "multi_thread")]
1086    async fn apply_sort_reverse_flips_listing_order() {
1087        let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
1088        let (tx, _rx) = unbounded_channel();
1089        navigate_to_root(&mut sidebar, &tx).await;
1090        let before = note_names(&sidebar);
1091        assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
1092        sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
1093        poll_to_idle(&mut sidebar).await;
1094        let after = note_names(&sidebar);
1095        assert_eq!(
1096            after,
1097            before.iter().rev().cloned().collect::<Vec<_>>(),
1098            "descending order should reverse the listing"
1099        );
1100    }
1101
1102    #[tokio::test(flavor = "multi_thread")]
1103    async fn apply_sort_changes_field() {
1104        let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
1105        let (tx, _rx) = unbounded_channel();
1106        navigate_to_root(&mut sidebar, &tx).await;
1107        sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
1108        poll_to_idle(&mut sidebar).await;
1109        assert_eq!(sidebar.current_sort().0, SortField::Title);
1110        assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
1111    }
1112
1113    /// Build a sidebar over a vault with both notes and a subdirectory.
1114    async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
1115        let vault = temp_vault(prefix).await;
1116        vault.validate_and_init().await.unwrap();
1117        vault
1118            .create_note(&VaultPath::note_path_from("alpha"), "body")
1119            .await
1120            .unwrap();
1121        vault
1122            .create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
1123            .await
1124            .unwrap();
1125        let settings = AppSettings::default();
1126        SidebarComponent::new(
1127            settings.key_bindings.clone(),
1128            vault,
1129            settings.icons(),
1130            &settings,
1131        )
1132    }
1133
1134    /// Kinds of the visible rows, in display order (excluding the Up row).
1135    fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
1136        sidebar
1137            .list
1138            .as_ref()
1139            .unwrap()
1140            .visible_rows()
1141            .iter()
1142            .filter_map(|e| match e {
1143                FileListEntry::Note { .. } => Some("note"),
1144                FileListEntry::Directory { .. } => Some("dir"),
1145                _ => None,
1146            })
1147            .collect()
1148    }
1149
1150    #[tokio::test(flavor = "multi_thread")]
1151    async fn group_dirs_puts_directories_first() {
1152        let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
1153        let (tx, _rx) = unbounded_channel();
1154        navigate_to_root(&mut sidebar, &tx).await;
1155        assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
1156        sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
1157        poll_to_idle(&mut sidebar).await;
1158        assert_eq!(
1159            row_kinds(&sidebar),
1160            vec!["dir", "note"],
1161            "grouping must cluster directories first"
1162        );
1163    }
1164
1165    #[tokio::test(flavor = "multi_thread")]
1166    async fn apply_sort_updates_shared_state() {
1167        let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
1168        let (tx, _rx) = unbounded_channel();
1169        navigate_to_root(&mut sidebar, &tx).await;
1170        sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1171        poll_to_idle(&mut sidebar).await;
1172        assert_eq!(
1173            sidebar.current_sort(),
1174            (SortField::Title, SortOrder::Descending)
1175        );
1176        assert!(!sidebar.group_dirs());
1177    }
1178
1179    #[tokio::test(flavor = "multi_thread")]
1180    async fn set_open_note_stamps_matching_row() {
1181        let mut sb = sidebar_with_notes("sb-open", &["alpha", "beta"]).await;
1182        let (tx, _rx) = unbounded_channel();
1183        navigate_to_root(&mut sb, &tx).await;
1184
1185        sb.set_open_note(Some(VaultPath::note_path_from("alpha")));
1186        assert!(
1187            sb.note_row_is_open_for_test("alpha.md"),
1188            "open note is marked"
1189        );
1190        assert!(
1191            !sb.note_row_is_open_for_test("beta.md"),
1192            "other note is not marked"
1193        );
1194
1195        sb.set_open_note(Some(VaultPath::note_path_from("beta")));
1196        assert!(!sb.note_row_is_open_for_test("alpha.md"));
1197        assert!(sb.note_row_is_open_for_test("beta.md"));
1198
1199        sb.set_open_note(None);
1200        assert!(!sb.note_row_is_open_for_test("beta.md"));
1201    }
1202
1203    #[tokio::test(flavor = "multi_thread")]
1204    async fn update_note_row_changes_title_in_place() {
1205        let mut sb = sidebar_with_notes("sb-title", &["alpha"]).await;
1206        let (tx, _rx) = unbounded_channel();
1207        navigate_to_root(&mut sb, &tx).await;
1208
1209        sb.update_note_row(&VaultPath::note_path_from("alpha"), "Fresh Title");
1210        assert_eq!(
1211            sb.note_row_title_for_test("alpha.md").as_deref(),
1212            Some("Fresh Title")
1213        );
1214    }
1215
1216    #[tokio::test(flavor = "multi_thread")]
1217    async fn rename_note_row_updates_path_and_filename() {
1218        let mut sb = sidebar_with_notes("sb-rename", &["alpha"]).await;
1219        let (tx, _rx) = unbounded_channel();
1220        navigate_to_root(&mut sb, &tx).await;
1221
1222        let to = VaultPath::note_path_from("gamma");
1223        let expected_filename = to.get_parent_path().1;
1224        sb.rename_note_row(&VaultPath::note_path_from("alpha"), &to);
1225        assert!(
1226            sb.note_row_title_for_test("gamma.md").is_some(),
1227            "row now at new name"
1228        );
1229        assert!(
1230            sb.note_row_title_for_test("alpha.md").is_none(),
1231            "old name gone"
1232        );
1233        // Also verify the filename field itself was updated to the new name.
1234        let renamed_filename = sb
1235            .list
1236            .as_ref()
1237            .unwrap()
1238            .rows()
1239            .iter()
1240            .find_map(|r| match r {
1241                FileListEntry::Note { path, filename, .. } if path.is_like(&to) => {
1242                    Some(filename.clone())
1243                }
1244                _ => None,
1245            });
1246        assert_eq!(
1247            renamed_filename.as_deref(),
1248            Some(expected_filename.as_str()),
1249            "filename field must be updated to the new name"
1250        );
1251    }
1252
1253    /// Renaming a journal-dir note (`YYYY-MM-DD`) to a non-date name clears
1254    /// `journal_date` on the row so the glyph and secondary date line update.
1255    #[tokio::test(flavor = "multi_thread")]
1256    async fn rename_note_row_clears_journal_date_when_renamed_away_from_date_name() {
1257        // Build a vault and create a note inside the journal directory with a
1258        // valid YYYY-MM-DD name so `vault.journal_date` returns Some(_).
1259        let vault = crate::test_support::temp_vault("sb-jdate").await;
1260        vault.validate_and_init().await.unwrap();
1261        let journal_path = vault.journal_path().clone();
1262        let date_name = "2026-06-09";
1263        let from = journal_path
1264            .append(&VaultPath::note_path_from(date_name))
1265            .absolute();
1266        vault.create_note(&from, "journal body").await.unwrap();
1267
1268        let settings = AppSettings::default();
1269        let mut sb = SidebarComponent::new(
1270            settings.key_bindings.clone(),
1271            vault,
1272            settings.icons(),
1273            &settings,
1274        );
1275        let (tx, _rx) = unbounded_channel();
1276        // Navigate to the journal directory (not root) so the note row is listed.
1277        sb.navigate(journal_path.clone(), &tx);
1278        for _ in 0..50 {
1279            sb.poll_for_test();
1280            if !sb.is_loading_for_test() {
1281                break;
1282            }
1283            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1284        }
1285        sb.poll_for_test();
1286
1287        // Precondition: the journal row has a non-None `journal_date`.
1288        assert!(
1289            sb.note_row_journal_date_for_test(&from).is_some(),
1290            "journal note must have a journal_date before rename"
1291        );
1292
1293        // Rename the note to a plain name (not a date) in the same directory.
1294        let to = journal_path
1295            .append(&VaultPath::note_path_from("meeting"))
1296            .absolute();
1297        sb.rename_note_row(&from, &to);
1298
1299        // The row should now have journal_date = None.
1300        assert_eq!(
1301            sb.note_row_journal_date_for_test(&to),
1302            None,
1303            "journal_date must be cleared after renaming to a non-date name"
1304        );
1305    }
1306
1307    /// Regression: saving a default must survive navigation.
1308    /// `remember_default` updates the cached per-context default that
1309    /// `sort_for`/`navigate` read; without it, navigating re-derives the
1310    /// construction-time default and the saved choice is silently lost.
1311    #[tokio::test(flavor = "multi_thread")]
1312    async fn save_default_survives_navigation() {
1313        let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
1314        let (tx, _rx) = unbounded_channel();
1315        navigate_to_root(&mut sidebar, &tx).await;
1316
1317        sidebar.remember_default(SortField::Title, SortOrder::Descending);
1318        sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
1319        poll_to_idle(&mut sidebar).await;
1320
1321        // Re-navigate (root is non-journal) — sort_for must now yield the saved
1322        // default, not the constructor-time (Name, Ascending).
1323        sidebar.navigate(VaultPath::root(), &tx);
1324        poll_to_idle(&mut sidebar).await;
1325        assert_eq!(
1326            sidebar.current_sort(),
1327            (SortField::Title, SortOrder::Descending),
1328            "saved default must persist across navigation"
1329        );
1330    }
1331
1332    /// A sidebar over notes with the given frontmatter bodies, at root.
1333    async fn sidebar_with_bodies(prefix: &str, notes: &[(&str, &str)]) -> SidebarComponent {
1334        let vault = temp_vault(prefix).await;
1335        vault.validate_and_init().await.unwrap();
1336        for (name, body) in notes {
1337            vault
1338                .create_note(&VaultPath::note_path_from(name), body)
1339                .await
1340                .unwrap();
1341        }
1342        let settings = AppSettings::default();
1343        SidebarComponent::new(
1344            settings.key_bindings.clone(),
1345            vault,
1346            settings.icons(),
1347            &settings,
1348        )
1349    }
1350
1351    const RANKED: [(&str, &str); 3] = [
1352        ("alpha", "---\nrank: 3\n---\nbody"),
1353        ("bravo", "---\nrank: 1\n---\nbody"),
1354        ("charlie", "body"),
1355    ];
1356
1357    fn property_state(key: &str, order: SortOrder) -> SortState {
1358        SortState {
1359            field: SortField::Property(key.into()),
1360            order,
1361            group_dirs: Some(false),
1362        }
1363    }
1364
1365    /// Poll until the property values for the active sort have landed.
1366    async fn poll_property_values(sidebar: &mut SidebarComponent) {
1367        for _ in 0..100 {
1368            sidebar.poll_for_test();
1369            if !sidebar.property_sort.is_pending() {
1370                return;
1371            }
1372            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1373        }
1374        panic!("property values never arrived");
1375    }
1376
1377    /// A property sort keeps the current order until the values arrive, then
1378    /// re-sorts by them: valued notes first, the note without the key last.
1379    #[tokio::test(flavor = "multi_thread")]
1380    async fn property_sort_reorders_once_values_arrive() {
1381        let mut sidebar = sidebar_with_bodies("sidebar-prop", &RANKED).await;
1382        let (tx, _rx) = unbounded_channel();
1383        navigate_to_root(&mut sidebar, &tx).await;
1384        assert!(SortableList::allows_property(&sidebar));
1385        assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1386
1387        SortableList::apply_sort(
1388            &mut sidebar,
1389            &property_state("Rank", SortOrder::Ascending),
1390            &tx,
1391        );
1392        assert_eq!(
1393            note_names(&sidebar),
1394            ["alpha.md", "bravo.md", "charlie.md"],
1395            "current order kept until the values arrive"
1396        );
1397        poll_property_values(&mut sidebar).await;
1398        assert_eq!(note_names(&sidebar), ["bravo.md", "alpha.md", "charlie.md"]);
1399
1400        // Same key again: the cached values apply at once.
1401        SortableList::apply_sort(
1402            &mut sidebar,
1403            &property_state("Rank", SortOrder::Descending),
1404            &tx,
1405        );
1406        assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1407    }
1408
1409    /// Values delivered for a key the sort no longer uses are dropped.
1410    #[tokio::test(flavor = "multi_thread")]
1411    async fn stale_property_values_are_ignored() {
1412        use kimun_core::PropertySortValue::Number;
1413        let mut sidebar = sidebar_with_bodies("sidebar-stale", &RANKED).await;
1414        let (tx, _rx) = unbounded_channel();
1415        navigate_to_root(&mut sidebar, &tx).await;
1416        SortableList::apply_sort(
1417            &mut sidebar,
1418            &property_state("rank", SortOrder::Ascending),
1419            &tx,
1420        );
1421        let stale = Arc::new(std::collections::HashMap::from([(
1422            VaultPath::note_path_from("/charlie"),
1423            Number(0.0),
1424        )]));
1425        assert!(!sidebar.on_property_sort_values("other", stale.clone()));
1426        assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1427        // The same map for the active key lands.
1428        assert!(sidebar.on_property_sort_values("rank", stale));
1429        assert_eq!(note_names(&sidebar), ["charlie.md", "alpha.md", "bravo.md"]);
1430    }
1431
1432    /// A refresh of the shown directory keeps the property sort and fetches
1433    /// the values again, so an edited value moves its row.
1434    #[tokio::test(flavor = "multi_thread")]
1435    async fn refresh_keeps_the_property_sort_and_refetches() {
1436        let mut sidebar = sidebar_with_bodies("sidebar-prop-refresh", &RANKED).await;
1437        let (tx, _rx) = unbounded_channel();
1438        navigate_to_root(&mut sidebar, &tx).await;
1439        SortableList::apply_sort(
1440            &mut sidebar,
1441            &property_state("rank", SortOrder::Ascending),
1442            &tx,
1443        );
1444        poll_property_values(&mut sidebar).await;
1445        assert_eq!(note_names(&sidebar), ["bravo.md", "alpha.md", "charlie.md"]);
1446
1447        sidebar
1448            .vault
1449            .save_note(
1450                &VaultPath::note_path_from("bravo"),
1451                "---\nrank: 9\n---\nbody",
1452            )
1453            .await
1454            .unwrap();
1455        sidebar.refresh_if_showing(&VaultPath::root(), &tx);
1456        poll_to_idle(&mut sidebar).await;
1457        poll_property_values(&mut sidebar).await;
1458        assert_eq!(
1459            sidebar.current_sort(),
1460            (SortField::Property("rank".into()), SortOrder::Ascending)
1461        );
1462        assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1463    }
1464
1465    /// Order / Group toggles on the same property key reuse the cached
1466    /// values; a new key or a listing refresh fetches them again.
1467    #[tokio::test(flavor = "multi_thread")]
1468    async fn property_sort_toggles_reuse_the_cached_values() {
1469        let mut sidebar = sidebar_with_bodies("sidebar-prop-cache", &RANKED).await;
1470        let (tx, _rx) = unbounded_channel();
1471        navigate_to_root(&mut sidebar, &tx).await;
1472        SortableList::apply_sort(
1473            &mut sidebar,
1474            &property_state("rank", SortOrder::Ascending),
1475            &tx,
1476        );
1477        poll_property_values(&mut sidebar).await;
1478        let fetches = sidebar.property_sort.fetches;
1479        assert_eq!(fetches, 1);
1480
1481        for order in [SortOrder::Descending, SortOrder::Ascending] {
1482            SortableList::apply_sort(&mut sidebar, &property_state("Rank", order), &tx);
1483        }
1484        let mut grouped = property_state("rank", SortOrder::Ascending);
1485        grouped.group_dirs = Some(true);
1486        SortableList::apply_sort(&mut sidebar, &grouped, &tx);
1487        assert_eq!(
1488            sidebar.property_sort.fetches, fetches,
1489            "toggles: no refetch"
1490        );
1491        assert!(!sidebar.property_sort.is_pending());
1492        assert_eq!(note_names(&sidebar), ["bravo.md", "alpha.md", "charlie.md"]);
1493
1494        SortableList::apply_sort(
1495            &mut sidebar,
1496            &property_state("due", SortOrder::Ascending),
1497            &tx,
1498        );
1499        assert_eq!(
1500            sidebar.property_sort.fetches,
1501            fetches + 1,
1502            "new key fetches"
1503        );
1504        poll_property_values(&mut sidebar).await;
1505
1506        sidebar.refresh_if_showing(&VaultPath::root(), &tx);
1507        assert_eq!(
1508            sidebar.property_sort.fetches,
1509            fetches + 2,
1510            "refresh refetches"
1511        );
1512        poll_to_idle(&mut sidebar).await;
1513        poll_property_values(&mut sidebar).await;
1514    }
1515
1516    /// Leaving a property sort and coming back to the same key refetches:
1517    /// values edited meanwhile must show up.
1518    #[tokio::test(flavor = "multi_thread")]
1519    async fn returning_to_a_property_sort_refetches() {
1520        let mut sidebar = sidebar_with_bodies("sidebar-prop-return", &RANKED).await;
1521        let (tx, _rx) = unbounded_channel();
1522        navigate_to_root(&mut sidebar, &tx).await;
1523        SortableList::apply_sort(
1524            &mut sidebar,
1525            &property_state("rank", SortOrder::Ascending),
1526            &tx,
1527        );
1528        poll_property_values(&mut sidebar).await;
1529        assert_eq!(note_names(&sidebar), ["bravo.md", "alpha.md", "charlie.md"]);
1530        sidebar
1531            .vault
1532            .save_note(
1533                &VaultPath::note_path_from("bravo"),
1534                "---\nrank: 9\n---\nbody",
1535            )
1536            .await
1537            .unwrap();
1538        let mut name = property_state("rank", SortOrder::Ascending);
1539        name.field = SortField::Name;
1540        SortableList::apply_sort(&mut sidebar, &name, &tx);
1541        SortableList::apply_sort(
1542            &mut sidebar,
1543            &property_state("rank", SortOrder::Ascending),
1544            &tx,
1545        );
1546        assert_eq!(sidebar.property_sort.fetches, 2, "back to rank refetches");
1547        poll_property_values(&mut sidebar).await;
1548        assert_eq!(note_names(&sidebar), ["alpha.md", "bravo.md", "charlie.md"]);
1549    }
1550
1551    // ── A slow filesystem: streamed rows on the test's schedule ──────────
1552    //
1553    // `DirListingSource` forwards each entry the moment `browse_vault_stream`
1554    // yields it, so on a slow drive the sidebar shows a PARTIAL listing for
1555    // many frames — and the user can sort, filter and select inside that
1556    // window while more rows keep landing. A real vault settles on the first
1557    // poll, so none of that window is reachable through `navigate_to_root`.
1558    // This source parks its `Emit` instead and lets the test say when each row
1559    // arrives, which is the only way to pin what the selection does across
1560    // LATER polls.
1561
1562    struct SlowListingSource {
1563        slot: Arc<std::sync::Mutex<Option<Emit<FileListEntry>>>>,
1564    }
1565
1566    #[async_trait]
1567    impl RowSource<FileListEntry> for SlowListingSource {
1568        async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
1569            *self.slot.lock().unwrap() = Some(emit);
1570        }
1571        fn reload_on_query(&self) -> bool {
1572            false
1573        }
1574    }
1575
1576    /// The engine `navigate` builds — same filter and same `listing_order` —
1577    /// over a listing the test drives row by row.
1578    async fn slow_listing(
1579        field: SortField,
1580        order: SortOrder,
1581        group_dirs: bool,
1582    ) -> (SearchList<FileListEntry>, Emit<FileListEntry>) {
1583        let slot = Arc::new(std::sync::Mutex::new(None));
1584        let list = SearchList::builder(
1585            SlowListingSource { slot: slot.clone() },
1586            Arc::new(|| {}) as Arc<dyn Fn() + Send + Sync>,
1587        )
1588        .filter(Filter::Fuzzy)
1589        .order_by(entry_order(field, order, group_dirs, None))
1590        .build();
1591        let emit = loop {
1592            if let Some(e) = slot.lock().unwrap().clone() {
1593                break e;
1594            }
1595            tokio::task::yield_now().await;
1596        };
1597        (list, emit)
1598    }
1599
1600    fn note_row(title: &str) -> FileListEntry {
1601        let path = VaultPath::note_path_from(title);
1602        let filename = path.get_parent_path().1;
1603        FileListEntry::Note {
1604            path,
1605            title: title.to_string(),
1606            filename,
1607            journal_date: None,
1608            is_open: false,
1609        }
1610    }
1611
1612    fn dir_row(name: &str) -> FileListEntry {
1613        FileListEntry::Directory {
1614            path: VaultPath::new(name),
1615            name: name.to_string(),
1616        }
1617    }
1618
1619    /// Row labels in display order. Avoids `sort_key`, which would put the
1620    /// note extension into every assertion.
1621    fn labels(list: &SearchList<FileListEntry>) -> Vec<String> {
1622        list.visible_rows().iter().map(|r| label(r)).collect()
1623    }
1624
1625    fn label(row: &FileListEntry) -> String {
1626        match row {
1627            FileListEntry::Up { .. } => "..".to_string(),
1628            FileListEntry::Directory { name, .. } => format!("{name}/"),
1629            FileListEntry::Note { title, .. } => title.clone(),
1630            FileListEntry::Attachment { filename, .. } => filename.clone(),
1631            FileListEntry::CreateNote { filename, .. } => format!("create:{filename}"),
1632        }
1633    }
1634
1635    fn highlighted(list: &SearchList<FileListEntry>) -> Option<String> {
1636        list.selected_row().map(label)
1637    }
1638
1639    /// The baseline for a slow drive: until the user touches it, the highlight
1640    /// belongs to the TOP of the sort, not to whichever entry the walker
1641    /// happened to reach first. Every later poll re-decides it.
1642    #[tokio::test]
1643    async fn a_slow_listing_keeps_the_untouched_highlight_on_the_top_row() {
1644        let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1645
1646        emit.push(note_row("mike"));
1647        list.poll();
1648        assert_eq!(highlighted(&list).as_deref(), Some("mike"));
1649
1650        emit.push(note_row("delta"));
1651        list.poll();
1652        assert_eq!(labels(&list), ["delta", "mike"]);
1653        assert_eq!(highlighted(&list).as_deref(), Some("delta"));
1654
1655        emit.push(note_row("alpha"));
1656        list.poll();
1657        assert_eq!(labels(&list), ["alpha", "delta", "mike"]);
1658        assert_eq!(
1659            highlighted(&list).as_deref(),
1660            Some("alpha"),
1661            "the highlight follows the top of the sort, not the first row to arrive"
1662        );
1663        emit.done();
1664    }
1665
1666    /// In a subdirectory the `Up` row is PUSHED (not a leading row) and is
1667    /// filter-exempt, so it is both the first row to arrive and the first in
1668    /// `listing_order`. The untouched highlight must sit on it and stay there
1669    /// as the directory's own entries trickle in.
1670    #[tokio::test]
1671    async fn a_slow_subdirectory_listing_keeps_the_highlight_on_the_up_row() {
1672        let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1673
1674        emit.push(FileListEntry::Up {
1675            parent: VaultPath::root(),
1676        });
1677        list.poll();
1678        assert_eq!(highlighted(&list).as_deref(), Some(".."));
1679
1680        emit.push(note_row("alpha"));
1681        list.poll();
1682        emit.push(note_row("aaron")); // sorts above "alpha", but below `Up`
1683        list.poll();
1684        assert_eq!(labels(&list), ["..", "aaron", "alpha"]);
1685        assert_eq!(
1686            highlighted(&list).as_deref(),
1687            Some(".."),
1688            "`Up` outranks every arriving entry, so the seed never leaves it"
1689        );
1690        emit.done();
1691    }
1692
1693    /// The sort dialog is reachable while the drive is still being read.
1694    /// Re-sorting an untouched listing must leave the highlight on the top of
1695    /// the NEW order — and must not silently promote the seed to a choice, so
1696    /// rows arriving afterwards still take the top with it.
1697    #[tokio::test]
1698    async fn a_sort_change_mid_stream_keeps_the_untouched_highlight_on_top() {
1699        let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1700
1701        emit.push(note_row("bravo"));
1702        emit.push(note_row("delta"));
1703        list.poll();
1704        assert_eq!(labels(&list), ["bravo", "delta"]);
1705        assert_eq!(highlighted(&list).as_deref(), Some("bravo"));
1706
1707        // The user flips to descending while rows are still arriving.
1708        list.set_order(Some(entry_order(
1709            SortField::Name,
1710            SortOrder::Descending,
1711            false,
1712            None,
1713        )));
1714        assert_eq!(labels(&list), ["delta", "bravo"]);
1715        assert_eq!(
1716            highlighted(&list).as_deref(),
1717            Some("delta"),
1718            "a re-sort moves the untouched highlight to the new top row"
1719        );
1720
1721        // ...and the rest of the slow listing keeps landing, still sorted,
1722        // still carrying the highlight.
1723        emit.push(note_row("echo"));
1724        list.poll();
1725        assert_eq!(labels(&list), ["echo", "delta", "bravo"]);
1726        assert_eq!(
1727            highlighted(&list).as_deref(),
1728            Some("echo"),
1729            "the sort change must not turn the seed into a user choice"
1730        );
1731        emit.done();
1732    }
1733
1734    /// The chosen half of the same window: a row the user picked mid-stream is
1735    /// theirs. A re-sort and every later arrival move it around the list, and
1736    /// it stays highlighted throughout.
1737    #[tokio::test]
1738    async fn a_row_chosen_mid_stream_survives_a_sort_change_and_later_arrivals() {
1739        let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1740
1741        emit.push(note_row("bravo"));
1742        emit.push(note_row("delta"));
1743        list.poll();
1744        list.select_next(); // "delta", by the user's own keys
1745        assert_eq!(highlighted(&list).as_deref(), Some("delta"));
1746
1747        list.set_order(Some(entry_order(
1748            SortField::Name,
1749            SortOrder::Descending,
1750            false,
1751            None,
1752        )));
1753        assert_eq!(labels(&list), ["delta", "bravo"]);
1754        assert_eq!(highlighted(&list).as_deref(), Some("delta"));
1755
1756        emit.push(note_row("echo")); // sorts above it under the new order
1757        list.poll();
1758        assert_eq!(labels(&list), ["echo", "delta", "bravo"]);
1759        assert_eq!(
1760            highlighted(&list).as_deref(),
1761            Some("delta"),
1762            "a chosen row keeps the highlight through a re-sort and later arrivals"
1763        );
1764        emit.done();
1765    }
1766
1767    /// `listing_order` ranks directories above notes when `group_dirs` is set,
1768    /// so a directory the walker reaches LAST still lands at the top — and
1769    /// takes an untouched highlight with it.
1770    #[tokio::test]
1771    async fn a_late_directory_takes_the_top_when_directories_are_grouped() {
1772        let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, true).await;
1773
1774        emit.push(note_row("alpha"));
1775        emit.push(note_row("bravo"));
1776        list.poll();
1777        assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
1778
1779        emit.push(dir_row("zeta"));
1780        list.poll();
1781        assert_eq!(labels(&list), ["zeta/", "alpha", "bravo"]);
1782        assert_eq!(
1783            highlighted(&list).as_deref(),
1784            Some("zeta/"),
1785            "grouped directories outrank notes whenever they arrive"
1786        );
1787        emit.done();
1788    }
1789
1790    /// Typing narrows the partial listing locally (`reload_on_query` is false,
1791    /// so no reload). A row the user picked stays picked while the rest of the
1792    /// slow listing lands behind the filter.
1793    #[tokio::test]
1794    async fn filtering_mid_stream_keeps_a_chosen_row_while_more_rows_arrive() {
1795        let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
1796
1797        emit.push(note_row("alpha"));
1798        emit.push(note_row("albatross"));
1799        emit.push(note_row("zebra"));
1800        list.poll();
1801        list.set_query("al");
1802        assert_eq!(labels(&list), ["albatross", "alpha"]);
1803        list.select_next(); // "alpha", chosen behind the filter
1804        assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
1805
1806        // More of the directory lands; one entry matches the filter and sorts
1807        // above the chosen row, the other is filtered out entirely.
1808        emit.push(note_row("alabama"));
1809        emit.push(note_row("yankee"));
1810        list.poll();
1811        assert_eq!(labels(&list), ["alabama", "albatross", "alpha"]);
1812        assert_eq!(
1813            highlighted(&list).as_deref(),
1814            Some("alpha"),
1815            "the chosen row keeps the highlight behind an active filter"
1816        );
1817
1818        // Clearing the filter reveals the rest without losing the choice.
1819        list.set_query("");
1820        assert_eq!(
1821            labels(&list),
1822            ["alabama", "albatross", "alpha", "yankee", "zebra"]
1823        );
1824        assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
1825        emit.done();
1826    }
1827
1828    /// An in-place row update (the editor renaming the open note's title) is a
1829    /// recompute, and a recompute re-applies `order_by` — so under a Title sort
1830    /// the touched row MOVES. The highlight has to move with it: the user is
1831    /// pointing at a note, not at a slot.
1832    #[tokio::test]
1833    async fn an_in_place_update_that_re_sorts_carries_the_highlight_with_the_row() {
1834        let (mut list, emit) = slow_listing(SortField::Title, SortOrder::Ascending, false).await;
1835        emit.push(note_row("alpha"));
1836        emit.push(note_row("bravo"));
1837        emit.push(note_row("charlie"));
1838        list.poll();
1839        list.select_next();
1840        list.select_next();
1841        assert_eq!(highlighted(&list).as_deref(), Some("charlie"));
1842
1843        // The editor retitles the selected note to something that sorts first.
1844        list.update_rows(|row| {
1845            if let FileListEntry::Note { title, .. } = row
1846                && title == "charlie"
1847            {
1848                *title = "aaa".to_string();
1849                return true;
1850            }
1851            false
1852        });
1853
1854        assert_eq!(labels(&list), ["aaa", "alpha", "bravo"]);
1855        assert_eq!(
1856            highlighted(&list).as_deref(),
1857            Some("aaa"),
1858            "the highlight belongs to the row, not to the position it used to hold"
1859        );
1860        emit.done();
1861    }
1862
1863    /// ...and the seeded half: an untouched highlight keeps the top slot when
1864    /// an in-place update re-sorts the listing under it.
1865    #[tokio::test]
1866    async fn an_in_place_update_that_re_sorts_leaves_a_seed_on_the_top_row() {
1867        let (mut list, emit) = slow_listing(SortField::Title, SortOrder::Ascending, false).await;
1868        emit.push(note_row("bravo"));
1869        emit.push(note_row("charlie"));
1870        list.poll();
1871        assert_eq!(highlighted(&list).as_deref(), Some("bravo"));
1872
1873        list.update_rows(|row| {
1874            if let FileListEntry::Note { title, .. } = row
1875                && title == "charlie"
1876            {
1877                *title = "aaa".to_string();
1878                return true;
1879            }
1880            false
1881        });
1882
1883        assert_eq!(labels(&list), ["aaa", "bravo"]);
1884        assert_eq!(
1885            highlighted(&list).as_deref(),
1886            Some("aaa"),
1887            "an untouched highlight belongs to the top of the order after any recompute"
1888        );
1889        emit.done();
1890    }
1891}