Skip to main content

kimun_notes/components/note_browser/
mod.rs

1use std::sync::Arc;
2use std::sync::mpsc::Receiver;
3
4use chrono::NaiveDate;
5use kimun_core::NoteVault;
6use kimun_core::nfs::VaultPath;
7use ratatui::Frame;
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::Style;
10use ratatui::widgets::{Block, Borders, Paragraph};
11
12use crate::components::autocomplete::AutocompleteMode;
13use crate::components::event_state::EventState;
14use crate::components::events::{
15    AppEvent, AppTx, AppTxExt, InputEvent, OverlayData, redraw_callback,
16};
17use crate::components::file_list::{
18    FileListEntry, PropertyValues, SortField, SortOrder, entry_order,
19};
20use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
21use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
22use crate::components::preview_highlight;
23use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
24use crate::components::search_list::{
25    KeyReaction, RowSource, SearchList, SearchMouse, VaultSuggestions,
26};
27use crate::components::sortable::{
28    PropertySort, SortState, SortableList, is_blank_property, order_of_query, query_with_sort,
29};
30use crate::keys::KeyBindings;
31use crate::keys::action_shortcuts::ActionShortcuts;
32use crate::settings::icons::Icons;
33use crate::settings::themes::Theme;
34
35pub mod file_finder_provider;
36pub mod link_results_provider;
37pub mod search_provider;
38
39// ---------------------------------------------------------------------------
40// NoteBrowserModal
41// ---------------------------------------------------------------------------
42
43/// The Ctrl+K note browser. It hosts a [`SearchList`] engine (query input +
44/// async-loaded result list + hashtag autocomplete) and adds the two things
45/// unique to the browser: a live preview pane for the selected note and the
46/// open-on-enter glue that emits [`AppEvent::OpenPath`].
47/// What the modal is scoped to — drives the input prefix glyph and whether
48/// the query syntax highlighter applies.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum BrowserScope {
51    /// Full query syntax (Ctrl-K, tag/backlink leaves): `⌕` prefix +
52    /// syntax highlighting.
53    Query,
54    /// Fuzzy file finding (Ctrl-O): plain input.
55    Files,
56}
57
58pub struct NoteBrowserModal {
59    scope: BrowserScope,
60    /// Input prefix glyph for the scope (`⌕` query / `▤` files).
61    prefix_glyph: &'static str,
62    title: String,
63    list: SearchList<FileListEntry>,
64    vault: Arc<NoteVault>,
65    tx: AppTx,
66    preview_text: String,
67    // Preview async loading
68    preview_task: Option<tokio::task::JoinHandle<()>>,
69    preview_rx: Option<Receiver<String>>,
70    /// Path the preview pane is currently showing (or loading). Compared at
71    /// render time against the engine's selected row so an async server-side
72    /// reload that auto-selects a different row still refreshes the preview.
73    preview_path: Option<VaultPath>,
74    /// Used to resolve the save-current-query shortcut for the hint bar.
75    key_bindings: KeyBindings,
76    /// The saved-search breadcrumb shown on the search border. Owns its
77    /// sticky/clear/edited state machine; the modal only forwards query events.
78    /// See [`SavedSearchBreadcrumb`].
79    saved_search: SavedSearchBreadcrumb,
80    /// The sort label (`Name ↑`) on the search box border from the last
81    /// render; `None` for a browser that cannot be sorted.
82    sort_chip: crate::components::clickable::BorderChip,
83    /// The hint bar's chips (`[⏎] Open`, `[Esc] Close`, and — for the query
84    /// browser — `[F1] Query syntax`).
85    hints: crate::components::hint_row::HintRow,
86    /// Last create/open error (e.g. a failed `Create: …`), shown in the hint
87    /// bar until the next keystroke. Cleared on input.
88    error: Option<String>,
89    /// Row sorting for a Files-scope list (the Ctrl+O finder) — see
90    /// [`Self::with_row_sort`]. `None`: not sortable (link results), or the
91    /// Query scope, whose sort lives in the query string.
92    row_sort: Option<RowSort>,
93}
94
95/// The file finder's sort: `state` is `None` until the user picks one (rows
96/// keep the provider's order — recency, or fuzzy rank while typing).
97#[derive(Default)]
98struct RowSort {
99    state: Option<SortState>,
100    property: PropertySort,
101}
102
103impl NoteBrowserModal {
104    pub fn new(
105        title: impl Into<String>,
106        scope: BrowserScope,
107        provider: impl RowSource<FileListEntry>,
108        vault: Arc<NoteVault>,
109        key_bindings: KeyBindings,
110        icons: Icons,
111        tx: AppTx,
112    ) -> Self {
113        Self::new_with_query(
114            title,
115            scope,
116            provider,
117            vault,
118            key_bindings,
119            icons,
120            tx,
121            String::new(),
122        )
123    }
124
125    /// Construct the modal with a pre-filled search query.
126    ///
127    /// Behaves exactly like [`new`](Self::new) except the search input is
128    /// pre-populated with `query` (cursor placed at the end) and the initial
129    /// load is triggered for that query string.
130    #[allow(clippy::too_many_arguments)]
131    pub fn with_initial_query<S: Into<String>>(
132        title: impl Into<String>,
133        scope: BrowserScope,
134        provider: impl RowSource<FileListEntry>,
135        vault: Arc<NoteVault>,
136        key_bindings: KeyBindings,
137        icons: Icons,
138        tx: AppTx,
139        query: S,
140    ) -> Self {
141        Self::new_with_query(
142            title,
143            scope,
144            provider,
145            vault,
146            key_bindings,
147            icons,
148            tx,
149            query.into(),
150        )
151    }
152
153    #[allow(clippy::too_many_arguments)]
154    fn new_with_query(
155        title: impl Into<String>,
156        scope: BrowserScope,
157        provider: impl RowSource<FileListEntry>,
158        vault: Arc<NoteVault>,
159        key_bindings: KeyBindings,
160        icons: Icons,
161        tx: AppTx,
162        initial_query: String,
163    ) -> Self {
164        let prefix_glyph = match scope {
165            BrowserScope::Query => icons.rail_find,
166            BrowserScope::Files => icons.rail_files,
167        };
168        let mut builder = SearchList::builder(provider, redraw_callback(tx.clone()))
169            .initial_query(initial_query)
170            .yank_combos_from(&key_bindings)
171            .icons(icons)
172            .autocomplete(
173                Arc::new(VaultSuggestions {
174                    vault: vault.clone(),
175                }),
176                AutocompleteMode::SearchQuery,
177            );
178        if scope == BrowserScope::Query {
179            builder = builder.highlight_query();
180        }
181        let list = builder.build();
182        let mut modal = Self {
183            scope,
184            prefix_glyph,
185            title: title.into(),
186            list,
187            vault,
188            tx,
189            preview_text: String::new(),
190            preview_task: None,
191            preview_rx: None,
192            preview_path: None,
193            key_bindings,
194            saved_search: SavedSearchBreadcrumb::default(),
195            sort_chip: Default::default(),
196            hints: browser_hints(scope),
197            error: None,
198            row_sort: None,
199        };
200        modal.refresh_preview(None);
201        modal
202    }
203
204    /// Make a Files-scope list sortable from the sort dialog (the Ctrl+O
205    /// finder): rows re-sort by name, title or a property's indexed value.
206    /// A no-op for the Query scope, which sorts through its query.
207    pub fn with_row_sort(mut self) -> Self {
208        if self.scope == BrowserScope::Files {
209            self.row_sort = Some(RowSort::default());
210        }
211        self
212    }
213
214    /// Re-sort the loaded rows by the row sort (no reload). A property sort
215    /// still waiting for its values keeps the current order.
216    fn reorder_rows(&mut self) {
217        let Some(RowSort {
218            state: Some(state),
219            property,
220        }) = &self.row_sort
221        else {
222            return;
223        };
224        if property.is_awaiting(&state.field) {
225            return;
226        }
227        let values = property.values_for_field(&state.field);
228        let order = entry_order(state.field.clone(), state.order, false, values);
229        self.list.set_order(Some(order));
230        self.refresh_preview_from_list();
231    }
232
233    /// Property values fetched for `key` landed: re-sort if they belong to
234    /// the active sort. `false` when they are stale and were dropped.
235    pub(crate) fn on_property_sort_values(&mut self, key: &str, values: PropertyValues) -> bool {
236        let accepted = self
237            .row_sort
238            .as_mut()
239            .is_some_and(|rs| rs.property.receive(key, values));
240        if accepted {
241            self.reorder_rows();
242        }
243        accepted
244    }
245
246    /// Apply property values that arrived since the last frame. Runs from
247    /// `render`, which a parked finder (under the sort dialog) still gets.
248    fn poll_row_sort(&mut self) {
249        if let Some((key, values)) = self.row_sort.as_mut().and_then(|rs| rs.property.poll()) {
250            self.on_property_sort_values(&key, values);
251        }
252    }
253
254    #[cfg(test)]
255    fn row_sort_pending(&self) -> bool {
256        self.row_sort
257            .as_ref()
258            .is_some_and(|rs| rs.property.is_pending())
259    }
260
261    /// The lowercase text needles the preview emphasizes: the query's plain
262    /// search terms (Query scope only — the fuzzy Files scope matches names,
263    /// not content).
264    fn preview_needles(&self) -> Vec<String> {
265        if self.scope != BrowserScope::Query {
266            return Vec::new();
267        }
268        crate::components::query_highlight::emphasis_needles(self.list.query())
269    }
270
271    /// The emphasis payload an open from this modal carries: the query's
272    /// needles, Query scope only.
273    fn emphasis(&self) -> Option<Vec<String>> {
274        let needles = self.preview_needles();
275        (!needles.is_empty()).then_some(needles)
276    }
277
278    // ── Async preview loading ──────────────────────────────────────────────
279
280    fn schedule_preview(&mut self, path: VaultPath) {
281        if let Some(handle) = self.preview_task.take() {
282            handle.abort();
283        }
284        let vault = Arc::clone(&self.vault);
285        let tx = self.tx.clone();
286        let (result_tx, result_rx) = std::sync::mpsc::channel();
287        self.preview_rx = Some(result_rx);
288
289        let handle = tokio::spawn(async move {
290            let text = vault.get_note_text(&path).await.unwrap_or_default();
291            result_tx.send(text).ok();
292            tx.send(AppEvent::Redraw).ok();
293        });
294        self.preview_task = Some(handle);
295    }
296
297    fn poll_preview(&mut self) {
298        let Some(rx) = &self.preview_rx else { return };
299        match rx.try_recv() {
300            Ok(text) => {
301                self.preview_text = text;
302                self.preview_rx = None;
303                self.preview_task = None;
304            }
305            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
306                self.preview_rx = None;
307            }
308            Err(std::sync::mpsc::TryRecvError::Empty) => {}
309        }
310    }
311
312    /// Called after selection changes to kick off a preview load for the
313    /// highlighted note, or clear the preview if a non-note entry is selected.
314    fn refresh_preview(&mut self, selected: Option<&FileListEntry>) {
315        let maybe_path = selected.and_then(|e| match e {
316            FileListEntry::Note { path, .. } => Some(path.clone()),
317            _ => None,
318        });
319        if let Some(path) = maybe_path {
320            self.schedule_preview(path);
321        } else {
322            self.preview_text.clear();
323            if let Some(h) = self.preview_task.take() {
324                h.abort();
325            }
326        }
327    }
328
329    /// The note path the engine currently has selected, if the selected row is
330    /// a note (non-note rows yield `None`).
331    fn selected_note_path(&self) -> Option<VaultPath> {
332        self.list.selected_row().and_then(|e| match e {
333            FileListEntry::Note { path, .. } => Some(path.clone()),
334            _ => None,
335        })
336    }
337
338    /// Refresh the preview for whatever the engine currently has selected.
339    fn refresh_preview_from_list(&mut self) {
340        let path = self.selected_note_path();
341        self.preview_path = path.clone();
342        match path {
343            Some(path) => self.schedule_preview(path),
344            None => {
345                self.preview_text.clear();
346                if let Some(h) = self.preview_task.take() {
347                    h.abort();
348                }
349            }
350        }
351    }
352
353    /// Open the engine's selected row: create-then-open for a `CreateNote`,
354    /// or open directly for an existing `Note`. Emits only `OpenPath`; the
355    /// editor's `OpenPath` handler closes this overlay (restoring focus to the
356    /// editor), so no separate `CloseOverlay` is sent.
357    fn open_selected(&self, tx: &AppTx) {
358        let Some(entry) = self.list.selected_row() else {
359            return;
360        };
361        if let FileListEntry::CreateNote { path, .. } = entry {
362            let path = path.clone();
363            let vault = Arc::clone(&self.vault);
364            let tx = tx.clone();
365            tokio::spawn(async move {
366                match vault.load_or_create_note(&path, None).await {
367                    Ok((_, created)) => tx.announce_and_open(path, created),
368                    Err(e) => {
369                        tx.send(AppEvent::OverlayData(OverlayData::Error(e.to_string())))
370                            .ok();
371                    }
372                }
373            });
374            return;
375        }
376        let path = entry.path().clone();
377        tx.send(AppEvent::OpenPath {
378            path,
379            emphasis: self.emphasis(),
380        })
381        .ok();
382    }
383
384    fn is_sortable(&self) -> bool {
385        self.scope == BrowserScope::Query || self.row_sort.is_some()
386    }
387
388    /// The saved-search breadcrumb label for the search border, or `None` when
389    /// no saved search is active.
390    #[cfg(test)]
391    fn saved_search_breadcrumb(&self) -> Option<String> {
392        self.saved_search.label(self.list.query())
393    }
394
395    // ── Test-only accessors ────────────────────────────────────────────────
396
397    /// Returns the current search input text. Test-only.
398    #[cfg(test)]
399    pub(super) fn query_text(&self) -> &str {
400        self.list.query()
401    }
402}
403
404// ---------------------------------------------------------------------------
405// SortableList impl (search scope — see `Overlay::as_sortable`)
406// ---------------------------------------------------------------------------
407
408impl SortableList for NoteBrowserModal {
409    /// The query's order directive (search browser), or the row sort
410    /// (file finder; Name ascending until one is picked — reported as
411    /// unsorted, see [`SortableList::is_unsorted`]).
412    fn sort_state(&self) -> SortState {
413        if let Some(rs) = &self.row_sort {
414            return rs.state.clone().unwrap_or(SortState {
415                field: SortField::Name,
416                order: SortOrder::Ascending,
417                group_dirs: None,
418            });
419        }
420        let (field, order) = order_of_query(self.list.query());
421        SortState {
422            field,
423            order,
424            group_dirs: None,
425        }
426    }
427
428    /// Search browser: rewrite the query's order directive and reload, so
429    /// the results re-sort live behind the sort dialog. Like the Query panel,
430    /// the saved-search breadcrumb stays pinned and shows `• edited` (the
431    /// stored query is saved verbatim). File finder: re-sort the rows; a
432    /// property sort on a new key fetches its values and re-sorts when they
433    /// land (the same key reuses them).
434    fn apply_sort(&mut self, state: &SortState, tx: &AppTx) {
435        if let Some(rs) = &mut self.row_sort {
436            if is_blank_property(&state.field) {
437                return;
438            }
439            rs.state = Some(state.clone());
440            rs.property.sync(&self.vault, &state.field, tx);
441            self.reorder_rows();
442            return;
443        }
444        if let Some(rewritten) = query_with_sort(self.list.query(), &state.field, state.order) {
445            self.list.set_query(rewritten);
446            self.refresh_preview_from_list();
447        }
448    }
449
450    fn allows_property(&self) -> bool {
451        true
452    }
453
454    /// File finder: until a sort is picked (rows in recency / fuzzy order).
455    /// Search browser: on the empty query, which lists the recent notes in
456    /// recency order.
457    fn is_unsorted(&self) -> bool {
458        match &self.row_sort {
459            Some(rs) => rs.state.is_none(),
460            None => self.scope == BrowserScope::Query && self.list.query().trim().is_empty(),
461        }
462    }
463}
464
465// ---------------------------------------------------------------------------
466// Overlay impl
467// ---------------------------------------------------------------------------
468
469impl NoteBrowserModal {
470    /// After the query changed (a key, or a clicked suggestion): forward the
471    /// event to the breadcrumb — a `?name` expansion pins it, an emptied
472    /// field clears it, a manual edit keeps it (sticky) — and refresh the
473    /// preview.
474    fn after_query_edit(&mut self) {
475        let accepted = self.list.take_accepted_saved_search();
476        let blank = self.list.query().trim().is_empty();
477        self.saved_search
478            .on_query_consumed(accepted, self.list.query(), blank);
479        self.refresh_preview_from_list();
480    }
481}
482
483impl Overlay for NoteBrowserModal {
484    fn kind(&self) -> OverlayKind {
485        OverlayKind::NoteBrowser
486    }
487
488    fn query(&self) -> Option<&str> {
489        Some(self.list.query())
490    }
491
492    fn saved_search_provenance(&self) -> Option<&str> {
493        self.saved_search.name()
494    }
495
496    /// The search browser sorts through its query's order directive; the
497    /// file finder re-sorts its rows ([`NoteBrowserModal::with_row_sort`]).
498    /// The link-results list does not sort.
499    fn as_sortable(&self) -> Option<&dyn SortableList> {
500        self.is_sortable().then_some(self as &dyn SortableList)
501    }
502
503    fn takes_query_syntax(&self) -> bool {
504        self.scope == BrowserScope::Query
505    }
506
507    fn as_sortable_mut(&mut self) -> Option<&mut dyn SortableList> {
508        if self.is_sortable() { Some(self) } else { None }
509    }
510
511    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
512        match event {
513            InputEvent::Mouse(mouse) if let Some(chip) = self.chip_at(mouse) => {
514                self.click_chip(chip, tx)
515            }
516            InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
517                SearchMouse::Activated(_) | SearchMouse::DoubleClicked { repeat: false, .. } => {
518                    self.open_selected(tx);
519                    EventState::Consumed
520                }
521                SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
522                    self.refresh_preview_from_list();
523                    EventState::Consumed
524                }
525                // No content sub-region is recorded by this host, so these
526                // are unreachable.
527                SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
528                    EventState::Consumed
529                }
530                SearchMouse::Autocomplete { edited: true } => {
531                    self.after_query_edit();
532                    EventState::Consumed
533                }
534                // The repeat half of a double-click whose first press already
535                // opened: acting again would open twice.
536                SearchMouse::InputFocused
537                | SearchMouse::Autocomplete { edited: false }
538                | SearchMouse::DoubleClicked { repeat: true, .. } => EventState::Consumed,
539                SearchMouse::None => EventState::NotConsumed,
540            },
541            InputEvent::Key(key) => {
542                // Any keypress clears a stale create/open error.
543                self.error = None;
544                match self.list.handle_key(key) {
545                    KeyReaction::Submit => {
546                        self.open_selected(tx);
547                        EventState::Consumed
548                    }
549                    KeyReaction::Cancel => {
550                        tx.send(AppEvent::CloseOverlay).ok();
551                        EventState::Consumed
552                    }
553                    KeyReaction::Consumed => {
554                        self.after_query_edit();
555                        EventState::Consumed
556                    }
557                    KeyReaction::Yank(target) => {
558                        crate::components::yank_row(target, tx);
559                        EventState::Consumed
560                    }
561                    KeyReaction::Intercepted(_)
562                    | KeyReaction::ListVerb(_)
563                    | KeyReaction::Unhandled => EventState::NotConsumed,
564                }
565            }
566            _ => EventState::NotConsumed,
567        }
568    }
569
570    fn handle_data(
571        &mut self,
572        data: &OverlayData,
573        _vault: &Arc<NoteVault>,
574        _tx: &AppTx,
575    ) -> OverlayMsg {
576        // A failed `Create: …` (or other open error) surfaces here while the
577        // modal stays open, so the user sees why nothing happened.
578        if let OverlayData::Error(text) = data {
579            self.error = Some(text.clone());
580            OverlayMsg::Consumed
581        } else {
582            OverlayMsg::NotConsumed
583        }
584    }
585
586    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
587        self.poll_preview();
588        self.poll_row_sort();
589
590        let popup_rect = crate::components::centered_rect(75, 75, area);
591
592        // Modal chrome: hard background, focus-green border.
593        let modal_style = Style::default()
594            .fg(theme.fg.to_ratatui())
595            .bg(theme.bg_hard.to_ratatui());
596        let title = format!(" {} ", self.title);
597        let inner = modal_chrome(
598            f,
599            popup_rect,
600            theme,
601            ModalSpec {
602                title: Some(&title),
603                bg: ModalBg::Hard,
604                ..Default::default()
605            },
606        );
607
608        let rows = Layout::default()
609            .direction(Direction::Vertical)
610            .constraints([
611                Constraint::Length(3),
612                Constraint::Min(0),
613                Constraint::Length(1),
614            ])
615            .split(inner);
616
617        // ── Search box ────────────────────────────────────────────────────
618        // A saved-search breadcrumb (`‹ name ›` / `‹ name • edited ›`) titles
619        // the search box when a `?name` expansion is active.
620        let search_title = self
621            .saved_search
622            .border_title(self.list.query(), " Search ");
623        let result_count = self.list.match_count();
624        let mut search_block = Block::default().title(search_title).title(
625            ratatui::text::Line::from(ratatui::text::Span::styled(
626                format!(" {result_count} results "),
627                Style::default().fg(theme.gray.to_ratatui()),
628            ))
629            .right_aligned(),
630        );
631        // The sort label, added last so it is the rightmost right-aligned
632        // title (ratatui stacks them leftward). A list still in its natural
633        // order says so instead of naming a field.
634        if self.is_sortable() {
635            let line = if self.is_unsorted() {
636                ratatui::text::Line::from(ratatui::text::Span::styled(" Unsorted ", theme.action()))
637            } else {
638                let state = self.sort_state();
639                crate::components::sortable::sort_chip_line(&state.field, state.order, theme)
640            };
641            search_block = self.sort_chip.place(
642                search_block,
643                rows[0],
644                crate::components::clickable::Edge::Top,
645                line,
646            );
647        } else {
648            self.sort_chip.hide();
649        }
650        let search_block = search_block
651            .borders(Borders::ALL)
652            .border_style(theme.border_style(true))
653            .style(modal_style);
654        let search_inner = search_block.inner(rows[0]);
655        f.render_widget(search_block, rows[0]);
656        // Scope prefix glyph to the input's left, the input shifted past it.
657        let prefix = format!("{} ", self.prefix_glyph);
658        let prefix_w = unicode_width::UnicodeWidthStr::width(prefix.as_str()) as u16;
659        f.render_widget(
660            Paragraph::new(prefix).style(
661                Style::default()
662                    .fg(theme.yellow.to_ratatui())
663                    .bg(theme.bg_hard.to_ratatui()),
664            ),
665            Rect {
666                width: prefix_w.min(search_inner.width),
667                ..search_inner
668            },
669        );
670        let input_rect = Rect {
671            x: search_inner.x.saturating_add(prefix_w),
672            width: search_inner.width.saturating_sub(prefix_w),
673            ..search_inner
674        };
675        self.list.render_query(f, input_rect, theme, true);
676
677        // ── List + Preview ────────────────────────────────────────────────
678        let columns = Layout::default()
679            .direction(Direction::Horizontal)
680            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
681            .split(rows[1]);
682
683        // The engine hit-tests a click as `row - rect.y` against the recorded
684        // rect, where row 0 is the first item. The list renders into the block's
685        // INNER area, so record that same inner rect.
686        let list_block = Block::default()
687            .borders(Borders::ALL)
688            .border_style(theme.border_style(false))
689            .style(modal_style);
690        let list_inner = list_block.inner(columns[0]);
691        f.render_widget(list_block, columns[0]);
692        self.list.render(f, list_inner, theme, false);
693        self.list.set_list_rect(list_inner);
694        // The whole popup is wheel-scrollable (search box and preview included).
695        self.list.set_panel_rect(popup_rect);
696
697        // Authoritative preview trigger: `list.render` just polled, which is
698        // where an async server-side reload lands and may auto-select a new
699        // row 0. If the selected note path differs from what the preview is
700        // showing, refresh. Guarded by the path diff so there's no redraw loop.
701        if self.selected_note_path() != self.preview_path {
702            self.refresh_preview_from_list();
703        }
704
705        // Preview header: filename, plus the match count when the query
706        // carries text terms (`filename · N matches`).
707        let needles = self.preview_needles();
708        let match_count = count_matches(&self.preview_text, &needles);
709        let preview_title = match (&self.preview_path, match_count) {
710            (Some(path), Some(n)) => {
711                format!(" {} · {} matches ", path.get_name(), n)
712            }
713            (Some(path), None) => format!(" {} ", path.get_name()),
714            (None, _) => " Preview ".to_string(),
715        };
716        let preview_block = Block::default()
717            .title(preview_title)
718            .borders(Borders::ALL)
719            .border_style(theme.border_style(false))
720            .style(modal_style);
721        let preview_inner = preview_block.inner(columns[1]);
722        f.render_widget(preview_block, columns[1]);
723        f.render_widget(
724            Paragraph::new(highlight_matches(
725                &self.preview_text,
726                &needles,
727                theme,
728                modal_style,
729            )),
730            preview_inner,
731        );
732
733        // ── Hint bar (or last error) ──────────────────────────────────────
734        match &self.error {
735            Some(err) => {
736                self.hints.hide();
737                f.render_widget(
738                    Paragraph::new(format!("⚠ {err}"))
739                        .style(Style::default().fg(theme.red.to_ratatui())),
740                    rows[2],
741                )
742            }
743            None => self.hints.render(
744                f,
745                rows[2],
746                Style::default().fg(theme.fg_secondary.to_ratatui()),
747                theme,
748            ),
749        }
750
751        // ── Autocomplete popup ───────────────────────────────────────────
752        // Clamp to the modal's bounds so it never spills past the border.
753        self.list.render_autocomplete(f, popup_rect, theme);
754    }
755
756    fn hint_shortcuts(&self) -> Vec<(String, String)> {
757        let mut hints = vec![
758            ("↑↓".to_string(), "navigate".to_string()),
759            ("Enter".to_string(), "open".to_string()),
760            ("Esc".to_string(), "close".to_string()),
761        ];
762        if let Some(k) = self
763            .key_bindings
764            .first_combo_for(&ActionShortcuts::SaveCurrentQuery)
765        {
766            hints.push((k, "save query".to_string()));
767        }
768        hints
769    }
770}
771
772// ---------------------------------------------------------------------------
773// Shared helpers
774// ---------------------------------------------------------------------------
775
776/// A click target on the browser's chrome (not a list row).
777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
778enum BrowserChip {
779    /// A hint chip that is its key (`[⏎] Open`, `[Esc] Close`).
780    Key(ratatui::crossterm::event::KeyEvent),
781    /// `[F1] Query syntax` — the screen opens the reference over the
782    /// browser.
783    Syntax,
784    /// The sort label — the screen opens the sort dialog over the browser
785    /// (the mouse's Ctrl+R).
786    Sort,
787}
788
789impl NoteBrowserModal {
790    /// Run a chrome chip: a hint chip is its key; the syntax and sort chips
791    /// ask the screen to open their dialog over this browser.
792    fn click_chip(&mut self, chip: BrowserChip, tx: &AppTx) -> EventState {
793        let event = match chip {
794            BrowserChip::Key(key) => return self.handle_input(&InputEvent::Key(key), tx),
795            BrowserChip::Syntax => AppEvent::OpenQueryHelp,
796            BrowserChip::Sort => {
797                AppEvent::OpenSortDialog(crate::components::events::SortTarget::Browser)
798            }
799        };
800        self.list.close_autocomplete();
801        tx.send(event).ok();
802        EventState::Consumed
803    }
804
805    /// The chrome target under a left press, if any. The autocomplete
806    /// popup draws over the chrome, so a press on it is never a chip's.
807    fn chip_at(&self, m: &ratatui::crossterm::event::MouseEvent) -> Option<BrowserChip> {
808        if self.list.popup_contains(m.column, m.row) {
809            return None;
810        }
811        if let Some(key) = self.hints.hit(m) {
812            return Some(if key.code == ratatui::crossterm::event::KeyCode::F(1) {
813                BrowserChip::Syntax
814            } else {
815                BrowserChip::Key(key)
816            });
817        }
818        self.sort_chip.hit(m).then_some(BrowserChip::Sort)
819    }
820}
821
822/// The hint bar's chips. The query browser adds `[F1] Query syntax`; the
823/// file finder takes plain fuzzy input and has no syntax to explain.
824fn browser_hints(scope: BrowserScope) -> crate::components::hint_row::HintRow {
825    use ratatui::crossterm::event::KeyCode;
826    let mut chips = vec![
827        (KeyCode::Null, "↑↓", "Navigate"),
828        (KeyCode::Enter, "⏎", "Open"),
829        (KeyCode::Esc, "Esc", "Close"),
830    ];
831    if scope == BrowserScope::Query {
832        chips.push((KeyCode::F(1), "F1", "Query syntax"));
833    }
834    crate::components::hint_row::HintRow::new(&chips).with_indent(0)
835}
836
837pub(crate) fn format_journal_date(date: NaiveDate) -> String {
838    date.format("%A, %B %-d, %Y").to_string()
839}
840
841// ---------------------------------------------------------------------------
842// Tests
843// ---------------------------------------------------------------------------
844
845/// The number of highlighted matches in `text`, or `None` when there are no
846/// needles (the preview header shows a count only for queries with text terms).
847/// Counts the same ranges [`highlight_matches`] bolds — via the shared
848/// [`preview_highlight::match_ranges`] — so the header never disagrees with the
849/// visible highlights (overlapping needles are deduped, folds counted).
850fn count_matches(text: &str, needles: &[String]) -> Option<usize> {
851    if needles.is_empty() {
852        return None;
853    }
854    Some(preview_highlight::match_ranges(text, needles).len())
855}
856
857/// The preview text with needle matches emphasized in `yellow`.
858/// Matching is byte-safe via [`preview_highlight::match_ranges`], so non-ASCII
859/// case folds (e.g. `İ`, `ẞ`) are highlighted too, not dropped.
860fn highlight_matches<'a>(
861    text: &'a str,
862    needles: &[String],
863    theme: &Theme,
864    base: Style,
865) -> ratatui::text::Text<'a> {
866    use ratatui::text::{Line, Span};
867    if needles.is_empty() {
868        return ratatui::text::Text::styled(text, base);
869    }
870    let emphasis = base.patch(
871        Style::default()
872            .fg(theme.color_search_match.to_ratatui())
873            .add_modifier(ratatui::style::Modifier::BOLD),
874    );
875    let mut lines = Vec::new();
876    for line in text.lines() {
877        let ranges = preview_highlight::match_ranges(line, needles);
878        if ranges.is_empty() {
879            lines.push(Line::styled(line, base));
880            continue;
881        }
882        // Borrowed spans into `text` ('a) — zero-copy; shared segment walk.
883        let spans = preview_highlight::style_ranges(line, &ranges, |s, hit| {
884            Span::styled(s, if hit { emphasis } else { base })
885        });
886        lines.push(Line::from(spans));
887    }
888    ratatui::text::Text::from(lines)
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894    use crate::components::file_list::{SortField, SortOrder};
895    use crate::components::search_list::{Emit, RowSource};
896    use crate::components::sortable::{SortState, SortableList};
897    use crate::settings::AppSettings;
898    use crate::test_support::temp_vault;
899    use async_trait::async_trait;
900    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
901    use tokio::sync::mpsc::unbounded_channel;
902
903    #[test]
904    fn count_matches_matches_highlighted_ranges() {
905        // No needles → no header count.
906        assert_eq!(count_matches("anything", &[]), None);
907        // Overlapping needles count once (deduped, longest-first) — the header
908        // must equal the number of bolded ranges, not the raw per-needle sum.
909        let needles = vec!["foo".to_string(), "foobar".to_string()];
910        assert_eq!(count_matches("foobar", &needles), Some(1));
911        // Distinct occurrences each count.
912        assert_eq!(count_matches("foo and foo", &["foo".to_string()]), Some(2));
913    }
914
915    /// A one-shot source that yields a single existing note so submit has
916    /// something to open.
917    struct OneNoteSource {
918        path: VaultPath,
919    }
920
921    #[async_trait]
922    impl RowSource<FileListEntry> for OneNoteSource {
923        async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
924            emit.replace(vec![FileListEntry::Note {
925                path: self.path.clone(),
926                title: "Note".to_string(),
927                filename: self.path.to_string(),
928                journal_date: None,
929                is_open: false,
930            }]);
931        }
932    }
933
934    async fn make_modal_with(source: impl RowSource<FileListEntry>, tx: AppTx) -> NoteBrowserModal {
935        let vault = temp_vault("modal").await;
936        let settings = AppSettings::default();
937        NoteBrowserModal::new(
938            "test",
939            BrowserScope::Query,
940            source,
941            vault,
942            settings.key_bindings.clone(),
943            settings.icons(),
944            tx,
945        )
946    }
947
948    #[tokio::test]
949    async fn dialog_error_surfaces_then_clears_on_keystroke() {
950        let (tx, _rx) = unbounded_channel();
951        let path = VaultPath::note_path_from("/a.md");
952        let mut modal = make_modal_with(OneNoteSource { path }, tx.clone()).await;
953        let vault = temp_vault("modal_err").await;
954
955        let consumed = modal.handle_data(&OverlayData::Error("boom".to_string()), &vault, &tx);
956        assert!(matches!(consumed, OverlayMsg::Consumed));
957        assert_eq!(modal.error.as_deref(), Some("boom"));
958
959        // Any keystroke clears the error.
960        modal.handle_input(
961            &InputEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
962            &tx,
963        );
964        assert_eq!(modal.error, None, "keystroke should clear the error");
965    }
966
967    #[tokio::test]
968    async fn modal_constructed_with_initial_query_prefills_input() {
969        let vault = temp_vault("modal_iq").await;
970        let settings = AppSettings::default();
971        let (tx, _rx) = unbounded_channel();
972        let modal = NoteBrowserModal::with_initial_query(
973            "test",
974            BrowserScope::Query,
975            OneNoteSource {
976                path: VaultPath::note_path_from("/a.md"),
977            },
978            vault,
979            settings.key_bindings.clone(),
980            settings.icons(),
981            tx,
982            "#important",
983        );
984        assert_eq!(modal.query_text(), "#important");
985    }
986
987    /// Pressing Enter on a selected note emits OpenPath only. The editor's
988    /// OpenPath handler closes the overlay, so the modal does NOT also emit
989    /// CloseOverlay (that would be redundant).
990    #[tokio::test]
991    async fn submit_opens_selected_note() {
992        let (tx, mut rx) = unbounded_channel();
993        let path = VaultPath::note_path_from("/a.md");
994        let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
995        // Let the one-shot load deliver its row and the engine select it.
996        modal.list.poll_until_idle().await;
997
998        Overlay::handle_input(
999            &mut modal,
1000            &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
1001            &tx,
1002        );
1003
1004        let mut events = Vec::new();
1005        while let Ok(ev) = rx.try_recv() {
1006            events.push(ev);
1007        }
1008        assert!(
1009            events
1010                .iter()
1011                .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if *p == path)),
1012            "expected OpenPath, got {events:?}"
1013        );
1014        assert!(
1015            !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
1016            "select must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
1017        );
1018    }
1019
1020    /// Selecting a note row updates the tracked `preview_path`; this is the
1021    /// state the render-time diff compares against to detect stale previews
1022    /// after an async reload.
1023    #[tokio::test]
1024    async fn refresh_preview_tracks_selected_path() {
1025        let (tx, _rx) = unbounded_channel();
1026        let path = VaultPath::note_path_from("/a.md");
1027        let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
1028        modal.list.poll_until_idle().await;
1029        assert_eq!(modal.preview_path, None, "no path tracked before refresh");
1030
1031        modal.refresh_preview_from_list();
1032        assert_eq!(
1033            modal.preview_path,
1034            Some(path),
1035            "preview_path should track the selected note"
1036        );
1037    }
1038
1039    /// Pressing Esc closes the modal.
1040    #[tokio::test]
1041    async fn esc_closes_modal() {
1042        let (tx, mut rx) = unbounded_channel();
1043        let mut modal = make_modal_with(
1044            OneNoteSource {
1045                path: VaultPath::note_path_from("/a.md"),
1046            },
1047            tx.clone(),
1048        )
1049        .await;
1050        Overlay::handle_input(
1051            &mut modal,
1052            &InputEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
1053            &tx,
1054        );
1055        let mut sent = false;
1056        while let Ok(ev) = rx.try_recv() {
1057            if matches!(ev, AppEvent::CloseOverlay) {
1058                sent = true;
1059            }
1060        }
1061        assert!(sent, "expected CloseOverlay on Esc");
1062    }
1063
1064    /// Accepting a `?name` expansion in the Ctrl+K browser pins the saved-search
1065    /// breadcrumb and runs the stored query.
1066    #[tokio::test(flavor = "multi_thread")]
1067    async fn accepting_saved_search_pins_breadcrumb() {
1068        let vault = temp_vault("modal-ss").await;
1069        vault.validate_and_init().await.unwrap();
1070        vault.save_search("todo-week", "#todo").await.unwrap();
1071        let settings = AppSettings::default();
1072        let (tx, _rx) = unbounded_channel();
1073        let mut modal = NoteBrowserModal::new(
1074            "test",
1075            BrowserScope::Query,
1076            OneNoteSource {
1077                path: VaultPath::note_path_from("/a.md"),
1078            },
1079            vault,
1080            settings.key_bindings.clone(),
1081            settings.icons(),
1082            tx.clone(),
1083        );
1084
1085        // Type a leading `?` and a prefix, draining the async popup between
1086        // keystrokes so the suggestion lands before we accept.
1087        for ch in ['?', 't', 'o'] {
1088            Overlay::handle_input(
1089                &mut modal,
1090                &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
1091                &tx,
1092            );
1093            for _ in 0..30 {
1094                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1095                modal.list.poll();
1096            }
1097        }
1098        Overlay::handle_input(
1099            &mut modal,
1100            &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
1101            &tx,
1102        );
1103
1104        assert_eq!(modal.query_text(), "#todo");
1105        assert_eq!(
1106            modal.saved_search_breadcrumb().as_deref(),
1107            Some("todo-week")
1108        );
1109        // The overlay exposes the provenance so the save-search dialog can
1110        // pre-fill its name field.
1111        assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
1112    }
1113
1114    // ── Sorting (Ctrl+R over the search browser) ──────────────────────────
1115
1116    /// An error line replaces the hint chips; the chips it hides must not
1117    /// stay clickable underneath it.
1118    #[tokio::test]
1119    async fn an_error_line_hides_the_hint_chips() {
1120        use ratatui::{Terminal, backend::TestBackend};
1121        let (mut modal, _tx) = search_modal(BrowserScope::Query, "#x").await;
1122        let theme = crate::settings::themes::Theme::default();
1123        let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
1124        t.draw(|f| modal.render(f, f.area(), &theme)).unwrap();
1125        let (x, y) = crate::test_support::find_text(t.backend().buffer(), "[Esc] Close")
1126            .expect("chips drawn");
1127        modal.error = Some("could not create".into());
1128        t.draw(|f| modal.render(f, f.area(), &theme)).unwrap();
1129        let (tx, mut rx) = unbounded_channel();
1130        modal.handle_input(&crate::test_support::mouse_down_at(x, y), &tx);
1131        assert!(
1132            !crate::test_support::drain(&mut rx)
1133                .iter()
1134                .any(|e| matches!(e, AppEvent::CloseOverlay)),
1135            "the hidden [Esc] chip did not fire"
1136        );
1137    }
1138
1139    async fn search_modal(scope: BrowserScope, query: &str) -> (NoteBrowserModal, AppTx) {
1140        let vault = temp_vault("modal_sort").await;
1141        let settings = AppSettings::default();
1142        let (tx, _rx) = unbounded_channel();
1143        let modal = NoteBrowserModal::with_initial_query(
1144            "test",
1145            scope,
1146            OneNoteSource {
1147                path: VaultPath::note_path_from("/a.md"),
1148            },
1149            vault,
1150            settings.key_bindings.clone(),
1151            settings.icons(),
1152            tx.clone(),
1153            query,
1154        );
1155        (modal, tx)
1156    }
1157
1158    #[tokio::test]
1159    async fn apply_sort_rewrites_the_query_order_directive() {
1160        let (mut modal, tx) = search_modal(BrowserScope::Query, "#work").await;
1161        let state = SortState {
1162            field: SortField::Property("due".into()),
1163            order: SortOrder::Descending,
1164            group_dirs: None,
1165        };
1166        modal.apply_sort(&state, &tx);
1167        assert_eq!(modal.query_text(), "#work -or:prop:due");
1168        assert_eq!(modal.sort_state(), state, "sort_state reads it back");
1169    }
1170
1171    /// Ctrl+K over the recent notes: picking a Title sort in the dialog keeps
1172    /// the same recent set and only reorders it.
1173    #[tokio::test]
1174    async fn title_sort_reorders_the_recent_notes_in_place() {
1175        use crate::components::note_browser::search_provider::resolving_search_source;
1176        let vault = temp_vault("modal_sort_recents").await;
1177        vault.validate_and_init().await.unwrap();
1178        for (file, body) in [
1179            ("a", "# Charlie\nx"),
1180            ("b", "# Alpha\nx"),
1181            ("c", "# Bravo\nx"),
1182        ] {
1183            vault
1184                .create_note(&VaultPath::note_path_from(file), body)
1185                .await
1186                .unwrap();
1187        }
1188        vault
1189            .create_note(&VaultPath::note_path_from("other"), "# Aaa\nx")
1190            .await
1191            .unwrap();
1192        let recents: Vec<VaultPath> = ["c", "a", "b"]
1193            .into_iter()
1194            .map(VaultPath::note_path_from)
1195            .collect();
1196        let settings = AppSettings::default();
1197        let (tx, _rx) = unbounded_channel();
1198        let mut modal = NoteBrowserModal::new(
1199            "test",
1200            BrowserScope::Query,
1201            resolving_search_source(vault.clone(), recents, None),
1202            vault,
1203            settings.key_bindings.clone(),
1204            settings.icons(),
1205            tx.clone(),
1206        );
1207        let listed = |m: &NoteBrowserModal| -> Vec<String> {
1208            m.list
1209                .visible_rows()
1210                .iter()
1211                .filter_map(|r| match r {
1212                    FileListEntry::Note { path, .. } => Some(path.get_clean_name()),
1213                    _ => None,
1214                })
1215                .collect()
1216        };
1217        modal.list.poll_until_idle().await;
1218        assert_eq!(listed(&modal), ["c", "a", "b"]);
1219
1220        modal.apply_sort(
1221            &SortState {
1222                field: SortField::Title,
1223                order: SortOrder::Ascending,
1224                group_dirs: None,
1225            },
1226            &tx,
1227        );
1228        modal.list.poll_until_idle().await;
1229        assert_eq!(modal.query_text(), "or:title");
1230        assert_eq!(listed(&modal), ["b", "c", "a"], "same set, by title");
1231    }
1232
1233    #[tokio::test]
1234    async fn apply_sort_ignores_an_empty_property_key() {
1235        let (mut modal, tx) = search_modal(BrowserScope::Query, "x -or:title").await;
1236        for key in ["", "  "] {
1237            let state = SortState {
1238                field: SortField::Property(key.into()),
1239                order: SortOrder::Ascending,
1240                group_dirs: None,
1241            };
1242            modal.apply_sort(&state, &tx);
1243        }
1244        assert_eq!(modal.query_text(), "x -or:title");
1245    }
1246
1247    #[tokio::test]
1248    async fn sort_state_defaults_to_name_ascending() {
1249        let (modal, _tx) = search_modal(BrowserScope::Query, "#work").await;
1250        assert_eq!(
1251            modal.sort_state(),
1252            SortState {
1253                field: SortField::Name,
1254                order: SortOrder::Ascending,
1255                group_dirs: None,
1256            }
1257        );
1258        assert!(modal.allows_property());
1259    }
1260
1261    /// The search browser and the file finder sort; a plain Files-scope
1262    /// list (link results) does not.
1263    #[tokio::test]
1264    async fn the_search_browser_and_the_file_finder_are_sortable() {
1265        let (mut search, _) = search_modal(BrowserScope::Query, "").await;
1266        let (mut links, _) = search_modal(BrowserScope::Files, "").await;
1267        let (finder, _) = search_modal(BrowserScope::Files, "").await;
1268        let mut finder = finder.with_row_sort();
1269        assert!(search.as_sortable().is_some());
1270        assert!(search.as_sortable_mut().is_some());
1271        assert!(links.as_sortable().is_none());
1272        assert!(links.as_sortable_mut().is_none());
1273        assert!(finder.as_sortable().is_some());
1274        assert!(finder.as_sortable_mut().is_some());
1275        assert!(finder.allows_property());
1276    }
1277
1278    /// A sort counts as an edit of a pinned saved search: the breadcrumb
1279    /// shows `• edited` once the order directive is rewritten.
1280    #[tokio::test]
1281    async fn apply_sort_marks_the_saved_search_breadcrumb_edited() {
1282        let (mut modal, tx) = search_modal(BrowserScope::Query, "#todo").await;
1283        modal.saved_search.set(Some("todo".into()), "#todo");
1284        assert_eq!(modal.saved_search_breadcrumb().as_deref(), Some("todo"));
1285        modal.apply_sort(
1286            &SortState {
1287                field: SortField::Title,
1288                order: SortOrder::Ascending,
1289                group_dirs: None,
1290            },
1291            &tx,
1292        );
1293        assert_eq!(
1294            modal.saved_search_breadcrumb().as_deref(),
1295            Some("todo • edited")
1296        );
1297    }
1298
1299    /// The Ctrl+O finder over a real vault, with row sorting on.
1300    async fn finder_modal(prefix: &str) -> (NoteBrowserModal, AppTx) {
1301        use crate::components::note_browser::file_finder_provider::FileFinderProvider;
1302        let vault = temp_vault(prefix).await;
1303        vault.validate_and_init().await.unwrap();
1304        for (name, body) in [
1305            ("alpha", "---\nrank: 3\n---\nbody"),
1306            ("bravo", "---\nrank: 1\n---\nbody"),
1307            ("charlie", "body"),
1308        ] {
1309            vault
1310                .create_note(&VaultPath::note_path_from(name), body)
1311                .await
1312                .unwrap();
1313        }
1314        let settings = AppSettings::default();
1315        let (tx, _rx) = unbounded_channel();
1316        let provider = FileFinderProvider::new(vault.clone(), VaultPath::root());
1317        let mut modal = NoteBrowserModal::new(
1318            "Find Note",
1319            BrowserScope::Files,
1320            provider,
1321            vault,
1322            settings.key_bindings.clone(),
1323            settings.icons(),
1324            tx.clone(),
1325        )
1326        .with_row_sort();
1327        modal.list.poll_until_idle().await;
1328        (modal, tx)
1329    }
1330
1331    fn finder_names(modal: &NoteBrowserModal) -> Vec<String> {
1332        modal
1333            .list
1334            .visible_rows()
1335            .iter()
1336            .map(|r| r.path().get_name())
1337            .collect()
1338    }
1339
1340    fn sort(field: SortField, order: SortOrder) -> SortState {
1341        SortState {
1342            field,
1343            order,
1344            group_dirs: None,
1345        }
1346    }
1347
1348    /// Poll until the finder's property values have landed.
1349    async fn poll_finder_values(modal: &mut NoteBrowserModal) {
1350        for _ in 0..100 {
1351            modal.poll_row_sort();
1352            if !modal.row_sort_pending() {
1353                return;
1354            }
1355            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1356        }
1357        panic!("property values never arrived");
1358    }
1359
1360    /// Ctrl+R on the finder re-sorts its rows: by name, then by a property
1361    /// once the values arrive (the note without the key last).
1362    #[tokio::test(flavor = "multi_thread")]
1363    async fn file_finder_sorts_rows_by_name_and_property() {
1364        let (mut modal, tx) = finder_modal("finder-sort").await;
1365        modal.apply_sort(&sort(SortField::Name, SortOrder::Descending), &tx);
1366        assert_eq!(finder_names(&modal), ["charlie.md", "bravo.md", "alpha.md"]);
1367        assert_eq!(
1368            modal.sort_state(),
1369            sort(SortField::Name, SortOrder::Descending)
1370        );
1371
1372        let rank = SortField::Property("rank".into());
1373        modal.apply_sort(&sort(rank.clone(), SortOrder::Ascending), &tx);
1374        assert_eq!(
1375            finder_names(&modal),
1376            ["charlie.md", "bravo.md", "alpha.md"],
1377            "current order kept until the values arrive"
1378        );
1379        poll_finder_values(&mut modal).await;
1380        assert_eq!(finder_names(&modal), ["bravo.md", "alpha.md", "charlie.md"]);
1381        modal.apply_sort(&sort(rank.clone(), SortOrder::Descending), &tx);
1382        assert_eq!(finder_names(&modal), ["alpha.md", "bravo.md", "charlie.md"]);
1383        assert_eq!(modal.sort_state(), sort(rank, SortOrder::Descending));
1384    }
1385
1386    /// Values for a key the finder no longer sorts by are dropped.
1387    #[tokio::test(flavor = "multi_thread")]
1388    async fn file_finder_ignores_stale_property_values() {
1389        use kimun_core::PropertySortValue::Number;
1390        let (mut modal, tx) = finder_modal("finder-stale").await;
1391        modal.apply_sort(&sort(SortField::Name, SortOrder::Ascending), &tx);
1392        modal.apply_sort(
1393            &sort(SortField::Property("rank".into()), SortOrder::Ascending),
1394            &tx,
1395        );
1396        let stale = Arc::new(std::collections::HashMap::from([(
1397            VaultPath::note_path_from("/charlie"),
1398            Number(0.0),
1399        )]));
1400        assert!(!modal.on_property_sort_values("other", stale));
1401        assert_eq!(finder_names(&modal), ["alpha.md", "bravo.md", "charlie.md"]);
1402    }
1403
1404    /// The finder reports "unsorted" (recency / match order) until a sort
1405    /// is picked; then it reports the pick.
1406    #[tokio::test(flavor = "multi_thread")]
1407    async fn file_finder_is_unsorted_until_a_sort_is_picked() {
1408        let (mut modal, tx) = finder_modal("finder-unsorted").await;
1409        assert!(modal.is_unsorted());
1410        modal.apply_sort(&sort(SortField::Name, SortOrder::Ascending), &tx);
1411        assert!(!modal.is_unsorted());
1412    }
1413
1414    /// The Ctrl+K browser shows the recent notes in recency order for an
1415    /// empty query: unsorted. A term or an order directive is not.
1416    #[tokio::test]
1417    async fn search_browser_is_unsorted_only_on_the_recents() {
1418        let (empty, _) = search_modal(BrowserScope::Query, "").await;
1419        assert!(empty.is_unsorted());
1420        let (term, _) = search_modal(BrowserScope::Query, "#work").await;
1421        assert!(!term.is_unsorted());
1422        let (sorted, _) = search_modal(BrowserScope::Query, "or:title").await;
1423        assert!(!sorted.is_unsorted());
1424    }
1425
1426    /// Toggling the order of the same property key reuses the cached
1427    /// values; another key fetches.
1428    #[tokio::test(flavor = "multi_thread")]
1429    async fn file_finder_order_toggles_reuse_the_property_values() {
1430        let (mut modal, tx) = finder_modal("finder-prop-cache").await;
1431        let rank = SortField::Property("rank".into());
1432        modal.apply_sort(&sort(rank.clone(), SortOrder::Ascending), &tx);
1433        poll_finder_values(&mut modal).await;
1434        let fetches = |m: &NoteBrowserModal| m.row_sort.as_ref().unwrap().property.fetches;
1435        assert_eq!(fetches(&modal), 1);
1436        modal.apply_sort(&sort(rank.clone(), SortOrder::Descending), &tx);
1437        modal.apply_sort(&sort(rank, SortOrder::Ascending), &tx);
1438        assert_eq!(fetches(&modal), 1, "order toggles: no refetch");
1439        assert!(!modal.row_sort_pending());
1440        assert_eq!(finder_names(&modal), ["bravo.md", "alpha.md", "charlie.md"]);
1441        modal.apply_sort(
1442            &sort(SortField::Property("due".into()), SortOrder::Ascending),
1443            &tx,
1444        );
1445        assert_eq!(fetches(&modal), 2, "a new key fetches");
1446        modal.apply_sort(&sort(SortField::Title, SortOrder::Ascending), &tx);
1447        modal.apply_sort(
1448            &sort(SortField::Property("due".into()), SortOrder::Ascending),
1449            &tx,
1450        );
1451        assert_eq!(fetches(&modal), 3, "back to the key after Title refetches");
1452    }
1453}