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