Skip to main content

kimun_notes/components/
ask_sources.rs

1//! `SourcesPanel` — the Ask workspace's drawer view (CONTEXT.md: **Sources
2//! view**, **Source reader**; adr/0030): a ranked per-turn source list that
3//! reveals the full note — the retrieved section highlighted — in an inline
4//! preview, without leaving the answer.
5//!
6//! Composes the shared list engine ([`SearchList`]) the same way the FIND
7//! drawer (`query_panel.rs`) does — the panel no longer hand-rolls
8//! `List`/`ListState`, a cursor, selection styling, plain-letter matching, or a
9//! chord pre-intercept. The engine owns navigation, the (new) filter input,
10//! selection, list scroll, the list-focus verbs (`l`/`h`/`o`/`y`), the
11//! FollowLink / `Ctrl+Y` intercepts, and mouse hit-testing; on top of it the
12//! panel composes the shared [`PreviewPane`] reveal (the **Source reader**) and
13//! the per-turn note-load lifecycle.
14//!
15//! Unlike FIND (which opens on its query input), the Sources view opens on the
16//! list ([`Focus::List`]) — the first production user of `opening_focus`. Its
17//! rows are per-turn in-memory sources, so it composes `SearchList` directly
18//! (built synchronously over a [`StaticRowSource`]) rather than through
19//! `QueryListPanel`:
20//! `QueryListPanel` is a bare list with no `PreviewPane` and it swallows the
21//! `ListVerb`/`Intercepted` reactions the reveal is driven by.
22//!
23//! The per-turn sources live in the engine's row set (there is no parallel
24//! copy): `set_turn`/`refresh`/`reset` rebuild the engine over the turn's rows,
25//! and the directed reveals (`open_reader`/`focus_source`) and note-load
26//! resolution all read them back through the engine.
27
28use std::ops::Range;
29use std::sync::Arc;
30
31use kimun_core::NoteVault;
32use kimun_core::nfs::VaultPath;
33
34use ratatui::Frame;
35use ratatui::crossterm::event::{
36    KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
37};
38use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
39use ratatui::style::{Modifier, Style};
40use ratatui::widgets::{Block, Borders, ListItem, Paragraph};
41
42use crate::ask::{AskSource, locate};
43use crate::components::event_state::EventState;
44use crate::components::events::{AppEvent, AppTx, AskData, InputEvent, redraw_callback};
45use crate::components::panel::panel_block;
46use crate::components::preview_pane::{Highlight, PreviewPane};
47use crate::components::rich_row::RichRow;
48use crate::components::search_list::{
49    Filter, Focus, KeyReaction, SearchList, SearchMouse, SearchRow, StaticRowSource,
50};
51use crate::keys::KeyBindings;
52use crate::keys::action_shortcuts::ActionShortcuts;
53use crate::keys::key_combo::KeyCombo;
54use crate::settings::icons::Icons;
55use crate::settings::themes::Theme;
56
57/// Rows a PageUp/PageDown leaves visible from the previous view (shared
58/// convention with the note preview and the Ask thread).
59const PAGE_OVERLAP: u16 = 2;
60
61/// The load state for the currently-anchored source's note text.
62enum ReaderContent {
63    /// The note load is in flight.
64    Loading,
65    /// The note loaded successfully. `highlight` is the byte range
66    /// `locate::section_range` resolved, if any.
67    Loaded {
68        text: String,
69        highlight: Option<Range<usize>>,
70    },
71    /// The note load failed.
72    Failed,
73}
74
75/// The async note load backing the preview. Keyed by `path` for stale-drop of
76/// an in-flight vault load (a selection change, or a new turn, before the load
77/// lands must not clobber the note anchored now), and additionally by the
78/// source `ordinal` so that selecting a *different section of the same note*
79/// re-resolves the highlight against the new heading without a refetch.
80struct LoadedNote {
81    path: VaultPath,
82    /// The anchored source's citation ordinal — the section identity within the
83    /// note. Distinguishes two sources sharing a `path` but a different heading.
84    ordinal: usize,
85    content: ReaderContent,
86}
87
88/// One list-engine row: a per-turn source plus its 1-based rank (its position
89/// in the turn's ranked list — kept on the row so it survives filtering). The
90/// [`SearchRow`] bridge draws it as the shared [`RichRow`] and exposes the
91/// heading + path as the fuzzy-filter haystack.
92#[derive(Clone)]
93struct SourceRow {
94    rank: usize,
95    source: AskSource,
96    /// The `heading path` haystack the list's `Filter::Fuzzy` matches, so the
97    /// new filter input narrows the turn's sources by heading or path text.
98    filter_text: String,
99}
100
101impl SourceRow {
102    fn new(rank: usize, source: AskSource) -> Self {
103        let filter_text = format!("{} {}", source.heading, source.path);
104        Self {
105            rank,
106            source,
107            filter_text,
108        }
109    }
110}
111
112impl SearchRow for SourceRow {
113    fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
114        source_row(self.rank, &self.source, theme).into_list_item(theme)
115    }
116
117    fn visual_height(&self) -> u16 {
118        // Title line + dim filename line (the date is inline on the title).
119        2
120    }
121
122    fn match_text(&self) -> Option<&str> {
123        Some(&self.filter_text)
124    }
125}
126
127/// The Ask workspace's Sources drawer view: a ranked source list (on the shared
128/// [`SearchList`]) with the shared [`PreviewPane`] revealing the selected
129/// source's note below/over it.
130pub struct SourcesPanel {
131    turn_id: Option<u64>,
132    /// The shared list engine: query/filter input, result list, selection,
133    /// list scroll, list-focus verbs and intercepts. Rebuilt per turn,
134    /// synchronously, over the turn's in-memory rows (a [`StaticRowSource`]).
135    list: SearchList<SourceRow>,
136    /// The note-preview surface (expand cycle + content scroll + content
137    /// render), shared with the FIND drawer. Anchored by the located section
138    /// byte range as the highlight.
139    preview: PreviewPane,
140    /// The note load for the currently-anchored source. `None` until a preview
141    /// first opens.
142    loaded: Option<LoadedNote>,
143    /// Vault handle for the preview's note load (`ensure_note_load` spawns a
144    /// `vault.get_note_text`). Owned here so `handle_input` needs no vault
145    /// passed in.
146    vault: Arc<NoteVault>,
147    icons: Icons,
148    /// Combos the engine intercepts: FollowLink (open) plus `Ctrl+Y` (yank).
149    /// Registered on every rebuilt list.
150    intercept: Vec<KeyCombo>,
151    /// The `Ctrl+Y` combo, kept to route an [`KeyReaction::Intercepted`] to
152    /// yank (any other intercepted combo is a FollowLink → open).
153    ctrl_y_combo: Option<KeyCombo>,
154    /// The preview content viewport height from the last render — the page size
155    /// for PageUp/PageDown content scrolling in the Full preview.
156    preview_page: u16,
157}
158
159impl SourcesPanel {
160    pub fn new(vault: Arc<NoteVault>, key_bindings: &KeyBindings) -> Self {
161        let map = key_bindings.to_hashmap();
162        let follow = map
163            .get(&ActionShortcuts::FollowLink)
164            .cloned()
165            .unwrap_or_default();
166        let ctrl_y_combo = crate::keys::key_event_to_combo(&KeyEvent::new(
167            KeyCode::Char('y'),
168            KeyModifiers::CONTROL,
169        ));
170        let mut intercept = follow;
171        if let Some(c) = ctrl_y_combo {
172            intercept.push(c);
173        }
174        let icons = Icons::new(false);
175        // The initial (turn-less) list is empty and built synchronously
176        // ([`build_list`] uses `build_with_rows`), so the redraw callback is
177        // never fired — a no-op is harmless by construction.
178        let list = build_list(Vec::new(), &intercept, &icons, Arc::new(|| {}));
179        Self {
180            turn_id: None,
181            list,
182            preview: PreviewPane::new(),
183            loaded: None,
184            vault,
185            icons,
186            intercept,
187            ctrl_y_combo,
188            preview_page: 0,
189        }
190    }
191
192    /// Repopulates the list for `turn_id` and collapses the preview. A repeated
193    /// call with the same `turn_id` is a no-op — it keeps the selection (and the
194    /// preview state) exactly as-is when a selection sync re-points the drawer
195    /// at the already-shown turn. Regeneration replaces a turn's sources with
196    /// the fresh ones on completion, but that goes through
197    /// [`refresh`](Self::refresh) (which never short-circuits), not here.
198    pub fn set_turn(&mut self, turn_id: u64, sources: Vec<AskSource>, tx: &AppTx) {
199        if self.turn_id == Some(turn_id) {
200            return;
201        }
202        self.refresh(turn_id, sources, tx);
203    }
204
205    /// Force the source list for `turn_id` to `sources`, even when it's the
206    /// turn already shown — the answer-completion path, where a `Thinking`
207    /// turn (empty sources) gains its sources once the answer lands. Unlike
208    /// [`set_turn`](Self::set_turn), it never short-circuits on a matching id.
209    /// Collapses the preview and resets to the top.
210    pub fn refresh(&mut self, turn_id: u64, sources: Vec<AskSource>, tx: &AppTx) {
211        self.turn_id = Some(turn_id);
212        self.rebuild_list(sources, tx);
213        self.preview.reset();
214        self.loaded = None;
215    }
216
217    /// Clear the panel back to its empty, collapsed state — the "new
218    /// conversation" action (leader `a n`) drops the old turn's sources.
219    pub fn reset(&mut self, tx: &AppTx) {
220        self.turn_id = None;
221        self.rebuild_list(Vec::new(), tx);
222        self.preview.reset();
223        self.loaded = None;
224    }
225
226    /// (Re)build the list engine over `sources` (rank = 1-based position), the
227    /// engine-per-turn pattern: the turn's rows live only in the engine. The
228    /// rows are applied synchronously ([`build_list`] uses `build_with_rows`),
229    /// so they are present the instant this returns — no async load to wake a
230    /// redraw for. `tx` is still threaded to the engine's (never-fired) redraw
231    /// callback, harmless by construction.
232    fn rebuild_list(&mut self, sources: Vec<AskSource>, tx: &AppTx) {
233        let rows: Vec<SourceRow> = sources
234            .into_iter()
235            .enumerate()
236            .map(|(i, s)| SourceRow::new(i + 1, s))
237            .collect();
238        self.list = build_list(
239            rows,
240            &self.intercept,
241            &self.icons,
242            redraw_callback(tx.clone()),
243        );
244    }
245
246    /// Whether the current turn has any sources — read from the engine's row
247    /// set (the panel keeps no parallel copy).
248    fn has_sources(&self) -> bool {
249        !self.list.rows().is_empty()
250    }
251
252    /// The source at rank position `index` (0-based), from the engine's full
253    /// (unfiltered) row set.
254    fn source_at(&self, index: usize) -> Option<&AskSource> {
255        self.list.rows().get(index).map(|r| &r.source)
256    }
257
258    /// Point the list selection at the source with citation `ordinal` and
259    /// collapse the preview — a citation click in the thread asks the drawer to
260    /// reveal that exact source in the list. This is the ordinal→row boundary:
261    /// the panel lists sources in rank order, so it resolves the ordinal to a
262    /// position by matching the engine's rows, never by assuming `ordinal - 1`.
263    /// An ordinal with no matching source is ignored. Clears any active filter
264    /// so the target is never hidden.
265    pub fn focus_source(&mut self, ordinal: usize) {
266        self.list.set_query(""); // clear any filter so the target is never hidden
267        self.preview.reset();
268        self.loaded = None;
269        // The turn's rows are applied synchronously on rebuild, so the target is
270        // present now: resolve the ordinal to its visible position and select it
271        // inline — no deferral, even for a cross-turn citation click that ran
272        // `set_turn` in the same tick. An unknown ordinal is ignored.
273        if let Some(pos) = self
274            .list
275            .visible_rows()
276            .iter()
277            .position(|r| r.source.ordinal == ordinal)
278        {
279            self.list.select(pos);
280        }
281    }
282
283    /// Reveal `sources[source_index]` in the preview (leader `a s`): point the
284    /// selection at it and make sure the preview ends *revealed* on that source.
285    /// Collapsed opens to the half-height Context preview; an already-open
286    /// Context/Full stays at its expand level and re-points onto the new source
287    /// (never collapsing the way a plain selection move would). Spawns/refreshes
288    /// the note load. No-op for an out-of-range index.
289    pub fn open_reader(&mut self, source_index: usize, tx: &AppTx) {
290        self.list.set_query("");
291        // The turn's rows are present synchronously after `set_turn`, so this
292        // resolves on the first press — even when leader `a s` runs `set_turn`
293        // then `open_reader(0)` in the same tick.
294        let Some(source) = self.source_at(source_index).cloned() else {
295            return;
296        };
297        self.list.select(source_index);
298        let sel = Some(source.path.clone());
299        if self.preview.is_collapsed() {
300            self.preview.toggle(sel); // Collapsed -> Context
301        } else {
302            self.preview.repoint(sel); // keep the expand level, re-anchor here
303        }
304        self.ensure_note_load(
305            source.path.clone(),
306            source.ordinal,
307            source.match_heading().to_string(),
308            source.text.clone(),
309            tx,
310        );
311    }
312
313    /// Accepts a `ReaderNote` only when the panel is currently awaiting that
314    /// exact path (stale-drop: a source switch, or a new turn, before the load
315    /// lands must not clobber whatever is anchored now). Any other `AskData`
316    /// variant is addressed elsewhere and ignored.
317    pub fn handle_data(&mut self, data: AskData) {
318        let AskData::ReaderNote { path, text } = data else {
319            return;
320        };
321        if self.loaded.as_ref().map(|l| &l.path) != Some(&path) {
322            return;
323        }
324        // Resolve the highlight against the anchored source (prefer the one with
325        // the loaded ordinal; fall back to any source with this path) — read
326        // back from the engine's row set.
327        let ord = self.loaded.as_ref().map(|l| l.ordinal);
328        let rows = self.list.rows();
329        let hl_src = rows
330            .iter()
331            .map(|r| &r.source)
332            .find(|s| s.path == path && Some(s.ordinal) == ord)
333            .or_else(|| rows.iter().map(|r| &r.source).find(|s| s.path == path))
334            .map(|s| (s.match_heading().to_string(), s.text.clone()));
335        let content = match text {
336            Some(loaded) => {
337                let highlight = hl_src
338                    .and_then(|(heading, chunk)| locate::section_range(&loaded, &heading, &chunk));
339                ReaderContent::Loaded {
340                    text: loaded,
341                    highlight,
342                }
343            }
344            None => ReaderContent::Failed,
345        };
346        if let Some(l) = &mut self.loaded {
347            l.content = content;
348        }
349    }
350
351    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
352        if self.list.focus() == Focus::Input {
353            return vec![
354                ("Esc".into(), "list".into()),
355                ("type".into(), "filter".into()),
356            ];
357        }
358        if self.preview.is_collapsed() {
359            vec![
360                ("j/k".into(), "Select".into()),
361                ("Enter/l".into(), "Preview".into()),
362                ("o/^N".into(), "Open".into()),
363                ("y".into(), "Yank".into()),
364                ("i".into(), "Filter".into()),
365            ]
366        } else {
367            vec![
368                ("j/k".into(), "Select".into()),
369                ("Enter/l".into(), "Expand".into()),
370                ("h/Esc".into(), "Back".into()),
371                ("o/^N".into(), "Open".into()),
372            ]
373        }
374    }
375
376    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
377        let key = match event {
378            InputEvent::Key(key) => key,
379            // The mouse wheel scrolls the open preview's content or the list
380            // (converged with FIND, where the engine routes the wheel).
381            InputEvent::Mouse(mouse) => return self.handle_mouse(mouse, tx),
382            _ => return EventState::NotConsumed,
383        };
384
385        // Full takes over the arrow/page keys for content scroll BEFORE the
386        // engine sees them (mirrors FIND). `j`/`k` reach the engine so the list
387        // cursor stays reachable under the full preview.
388        if self.preview.is_full() {
389            match key.code {
390                KeyCode::Up => {
391                    self.preview.scroll_up();
392                    return EventState::Consumed;
393                }
394                KeyCode::Down => {
395                    self.preview.scroll_down();
396                    return EventState::Consumed;
397                }
398                KeyCode::PageUp => {
399                    self.scroll_preview_page(true);
400                    return EventState::Consumed;
401                }
402                KeyCode::PageDown => {
403                    self.scroll_preview_page(false);
404                    return EventState::Consumed;
405                }
406                _ => {}
407            }
408        }
409
410        // Esc ladder: in list focus with the preview revealed, Esc steps the
411        // reveal back (Full → Context → Collapsed) and is consumed; from a
412        // collapsed list the engine's `Cancel` bubbles so the drawer host
413        // returns focus to the thread. (In input focus, Esc first returns to
414        // the list — the engine handles that.)
415        if key.code == KeyCode::Esc
416            && self.list.focus() == Focus::List
417            && !self.preview.is_collapsed()
418        {
419            self.preview.collapse_step(self.selected_path());
420            return EventState::Consumed;
421        }
422
423        match self.list.handle_key(key) {
424            // FollowLink opens; `Ctrl+Y` yanks — the canonical chords, now via
425            // the engine's intercept mechanism instead of a hand-rolled
426            // pre-check. From any focus / reveal state.
427            KeyReaction::Intercepted(c) => {
428                if Some(c) == self.ctrl_y_combo {
429                    self.yank_selected_path(tx);
430                } else {
431                    self.open_selected(tx);
432                }
433                EventState::Consumed
434            }
435            // Enter (with no autocomplete open) cycles the reveal, like `l`.
436            KeyReaction::Submit => {
437                if self.has_sources() {
438                    self.preview.toggle(self.selected_path());
439                    self.ensure_loaded(tx);
440                }
441                EventState::Consumed
442            }
443            // List-focus verbs: `l`/`h` cycle the reveal, `o` opens, `y` yanks.
444            KeyReaction::ListVerb(c) => {
445                match c {
446                    'l' => {
447                        if self.has_sources() {
448                            self.preview.toggle(self.selected_path());
449                            self.ensure_loaded(tx);
450                        }
451                    }
452                    'h' => self.preview.collapse_step(self.selected_path()),
453                    'o' => self.open_selected(tx),
454                    'y' => self.yank_selected_path(tx),
455                    _ => {}
456                }
457                EventState::Consumed
458            }
459            // A consumed navigation / filter keystroke: re-anchor the preview on
460            // the new selection and refresh its note load (Context sticks across
461            // moves, Full collapses — see [`PreviewPane::sync`]).
462            KeyReaction::Consumed => {
463                self.sync_preview();
464                self.ensure_loaded(tx);
465                EventState::Consumed
466            }
467            // Esc from a collapsed list bubbles so the host returns focus to the
468            // thread.
469            KeyReaction::Cancel | KeyReaction::Unhandled => EventState::NotConsumed,
470        }
471    }
472
473    /// Route a mouse event through the engine (converged with FIND): the wheel
474    /// scrolls the open preview's content (inside its region) or the list
475    /// (elsewhere in the panel); a click selects a row, a second click on the
476    /// selected row cycles the reveal.
477    fn handle_mouse(&mut self, mouse: &MouseEvent, tx: &AppTx) -> EventState {
478        let was_full = self.preview.is_full();
479        self.sync_preview();
480        // In Full the list is not rendered (its recorded rect is stale), so only
481        // the wheel may reach the engine; a click on the header collapses the
482        // reveal, anything else is swallowed.
483        if was_full {
484            match mouse.kind {
485                MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
486                MouseEventKind::Down(MouseButton::Left)
487                    if self.preview.full_header_rect().contains(Position {
488                        x: mouse.column,
489                        y: mouse.row,
490                    }) =>
491                {
492                    self.preview.toggle(self.selected_path());
493                    return EventState::Consumed;
494                }
495                _ => return EventState::Consumed,
496            }
497        }
498        match self.list.handle_mouse(mouse) {
499            SearchMouse::ContentScrollUp => {
500                self.preview.scroll_up();
501                EventState::Consumed
502            }
503            SearchMouse::ContentScrollDown => {
504                self.preview.scroll_down();
505                EventState::Consumed
506            }
507            SearchMouse::Activated(_) => {
508                self.preview.toggle(self.selected_path());
509                self.ensure_loaded(tx);
510                EventState::Consumed
511            }
512            SearchMouse::Selected(_) | SearchMouse::Scrolled | SearchMouse::Context(_) => {
513                self.sync_preview();
514                self.ensure_loaded(tx);
515                EventState::Consumed
516            }
517            SearchMouse::None => EventState::NotConsumed,
518        }
519    }
520
521    /// Scroll the Full preview by a page (last render's content viewport, less
522    /// a small overlap), `up` toward the top. Single-tick scrolls under the
523    /// hood so the anchor-takeover/clamp rules hold.
524    fn scroll_preview_page(&mut self, up: bool) {
525        let page = self.preview_page.saturating_sub(PAGE_OVERLAP).max(1);
526        for _ in 0..page {
527            if up {
528                self.preview.scroll_up();
529            } else {
530                self.preview.scroll_down();
531            }
532        }
533    }
534
535    /// The selected source (read from the engine's selected row, so it is
536    /// correct even under an active filter).
537    fn selected_source(&self) -> Option<&AskSource> {
538        self.list.selected_row().map(|r| &r.source)
539    }
540
541    /// The selected source's path, for preview anchoring and open/yank.
542    fn selected_path(&self) -> Option<VaultPath> {
543        self.selected_source().map(|s| s.path.clone())
544    }
545
546    /// Re-anchor the preview onto the current selection (Context sticks across
547    /// moves, Full collapses — see [`PreviewPane::sync`]).
548    fn sync_preview(&mut self) {
549        let sel = self.selected_path();
550        self.preview.sync(sel);
551    }
552
553    /// Ensure the preview is backed by the *selected* source's note (the
554    /// interactive path — `open_reader` calls [`ensure_note_load`] directly with
555    /// its directed source). No-op while collapsed or with nothing selected.
556    fn ensure_loaded(&mut self, tx: &AppTx) {
557        if self.preview.is_collapsed() {
558            return;
559        }
560        let Some(source) = self.selected_source() else {
561            return;
562        };
563        let path = source.path.clone();
564        let ordinal = source.ordinal;
565        let heading = source.match_heading().to_string();
566        let chunk = source.text.clone();
567        self.ensure_note_load(path, ordinal, heading, chunk, tx);
568    }
569
570    /// Ensure the preview is backed by the given source's note. Three cases,
571    /// keyed on the source identity (`path` + `ordinal`), not `path` alone:
572    ///
573    /// - **Same source** (same path and ordinal): nothing to do.
574    /// - **Same note, different section** (same path, new ordinal): reuse the
575    ///   already-loaded text, re-resolve the highlight against the new heading,
576    ///   and re-anchor — no vault refetch.
577    /// - **Different note**: spawn the load, re-keying `loaded` so an earlier
578    ///   path's late `ReaderNote` is dropped on arrival.
579    fn ensure_note_load(
580        &mut self,
581        path: VaultPath,
582        ordinal: usize,
583        heading: String,
584        chunk: String,
585        tx: &AppTx,
586    ) {
587        match &self.loaded {
588            Some(l) if l.path == path && l.ordinal == ordinal => return,
589            Some(l) if l.path == path => {
590                // Same note, new section: re-resolve the highlight in place and
591                // re-anchor the preview, without a fresh vault load.
592                if let Some(l) = &mut self.loaded {
593                    l.ordinal = ordinal;
594                    if let ReaderContent::Loaded { text, highlight } = &mut l.content {
595                        *highlight = locate::section_range(text, &heading, &chunk);
596                    }
597                }
598                self.preview.re_anchor();
599                return;
600            }
601            _ => {}
602        }
603
604        self.loaded = Some(LoadedNote {
605            path: path.clone(),
606            ordinal,
607            content: ReaderContent::Loading,
608        });
609        let vault = self.vault.clone();
610        let tx = tx.clone();
611        tokio::spawn(async move {
612            let text = vault.get_note_text(&path).await.ok();
613            let _ = tx.send(AppEvent::Ask(AskData::ReaderNote { path, text }));
614        });
615    }
616
617    /// Open the selected source's note in the editor (plain `o`, or the
618    /// FollowLink intercept) — from any reveal state.
619    fn open_selected(&self, tx: &AppTx) {
620        if let Some(source) = self.selected_source() {
621            tx.send(AppEvent::open(source.path.clone())).ok();
622        }
623    }
624
625    /// Copy the selected source's path to the OS clipboard, reusing the
626    /// shared [`crate::components::yank`] seam `ThreadPanel` and the FIND
627    /// drawer use.
628    fn yank_selected_path(&self, tx: &AppTx) {
629        let Some(source) = self.selected_source() else {
630            return;
631        };
632        crate::components::yank(source.path.to_string(), "path copied", tx);
633    }
634
635    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
636        self.list.poll();
637        // Keep the preview anchored to the selection every frame (Context sticks
638        // across moves; Full collapses on a change) before laying anything out.
639        self.sync_preview();
640        // The whole panel is wheel-scrollable; the content sub-region is
641        // re-recorded (or cleared) by the branch that draws a preview.
642        self.list.set_panel_rect(rect);
643        self.list.set_content_rect(Rect::default());
644        self.preview.clear_header();
645
646        let block = panel_block("Sources", theme, focused);
647        let inner = block.inner(rect);
648        f.render_widget(block, rect);
649
650        // No sources at all for this turn: the prompt. The row set is applied
651        // synchronously on rebuild, so there is no in-flight load to wait on —
652        // an empty list here means the turn genuinely has no sources.
653        if self.list.rows().is_empty() {
654            let style = Style::default().fg(theme.gray.to_ratatui());
655            f.render_widget(
656                Paragraph::new("no sources — ask something").style(style),
657                inner,
658            );
659            return;
660        }
661
662        // A bordered filter box on top, converged with FIND's query searchbox
663        // (query_panel.rs): same `Length(3)` box chrome, same `render_query`
664        // call. Always visible — like FIND — rather than only once the user
665        // has left list focus, so the `/` filter affordance always reads.
666        // `render_query` itself dims the field and hides the cursor outside
667        // Input sub-focus (the List-focus work), so no extra styling is
668        // needed here beyond the shared border-focus style.
669        let rows = Layout::default()
670            .direction(Direction::Vertical)
671            .constraints([Constraint::Length(3), Constraint::Min(0)])
672            .split(inner);
673        let filter_block = Block::default()
674            .title(" filter ")
675            .borders(Borders::ALL)
676            .border_style(theme.border_style(focused))
677            .style(theme.panel_style());
678        let filter_inner = filter_block.inner(rows[0]);
679        f.render_widget(filter_block, rows[0]);
680        self.list.render_query(f, filter_inner, theme, focused);
681        let body = rows[1];
682
683        // A zero-match filter: mirror FIND's "No results" (query_panel.rs) so a
684        // narrowed-to-nothing filter reads as a result, not a silent blank.
685        if self.list.visible_rows().is_empty() {
686            let gray = theme.gray.to_ratatui();
687            let bg = theme.bg_panel.to_ratatui();
688            f.render_widget(
689                Paragraph::new("  No results").style(Style::default().fg(gray).bg(bg)),
690                body,
691            );
692            return;
693        }
694
695        // Full: the preview takes the whole body, no list visible. The wheel
696        // scrolls the content from anywhere in the panel.
697        if self.preview.is_full() {
698            self.list.set_content_rect(rect);
699            self.render_preview(f, body, true, theme);
700            return;
701        }
702
703        // Context: list on top, half-height preview below, divider between.
704        if self.preview.is_context() {
705            let max_list = body.height / 2;
706            // Rows are two lines each; cap the list at half the panel but shrink
707            // for a short (or filtered) list so the preview gets the rest. Use
708            // the VISIBLE (filtered) count, like FIND (query_panel.rs), so a
709            // narrowing filter shrinks the list pane.
710            let visible = self.list.visible_rows().len();
711            let list_height = (visible as u16 * 2).min(max_list).max(1);
712            let areas = Layout::default()
713                .direction(Direction::Vertical)
714                .constraints([
715                    Constraint::Length(list_height),
716                    Constraint::Length(1),
717                    Constraint::Min(0),
718                ])
719                .split(body);
720            self.list.render(f, areas[0], theme, focused);
721            self.list.set_list_rect(areas[0]);
722            let gray = theme.gray.to_ratatui();
723            let bg = theme.bg_panel.to_ratatui();
724            f.render_widget(
725                Paragraph::new("\u{2500}".repeat(areas[1].width as usize))
726                    .style(Style::default().fg(gray).bg(bg)),
727                areas[1],
728            );
729            self.render_preview(f, areas[2], false, theme);
730            self.list.set_content_rect(areas[2]);
731            return;
732        }
733
734        // Collapsed: list only.
735        self.list.render(f, body, theme, focused);
736        self.list.set_list_rect(body);
737    }
738
739    /// Feed the anchored source's loaded note into the preview surface (Context
740    /// or Full), or show the load's placeholder.
741    fn render_preview(&mut self, f: &mut Frame, area: Rect, full: bool, theme: &Theme) {
742        // Record the content viewport for page scrolling: Full spends two rows
743        // on the fixed title + divider chrome; Context uses the whole area.
744        self.preview_page = area.height.saturating_sub(if full { 2 } else { 0 });
745
746        let title_fn = self
747            .list
748            .selected_row()
749            .map(|r| (r.source.display_heading(), r.source.path.to_string()));
750        let Self {
751            loaded, preview, ..
752        } = self;
753        match loaded {
754            Some(LoadedNote {
755                content: ReaderContent::Loaded { text, highlight },
756                ..
757            }) => {
758                if full {
759                    let (title, filename) =
760                        title_fn.unwrap_or_else(|| ("Source".to_string(), String::new()));
761                    preview.render_full(
762                        f,
763                        area,
764                        &title,
765                        &filename,
766                        text,
767                        Highlight::Range(highlight.as_ref()),
768                        theme,
769                    );
770                } else {
771                    preview.render_context(
772                        f,
773                        area,
774                        text,
775                        Highlight::Range(highlight.as_ref()),
776                        theme,
777                    );
778                }
779            }
780            Some(LoadedNote {
781                content: ReaderContent::Failed,
782                ..
783            }) => {
784                let red = Style::default().fg(theme.red.to_ratatui());
785                f.render_widget(Paragraph::new("failed to load note").style(red), area);
786            }
787            None
788            | Some(LoadedNote {
789                content: ReaderContent::Loading,
790                ..
791            }) => {
792                let dim = Style::default().fg(theme.gray.to_ratatui());
793                f.render_widget(Paragraph::new("loading\u{2026}").style(dim), area);
794            }
795        }
796    }
797
798    #[cfg(test)]
799    pub(crate) async fn settle(&mut self) {
800        // The static list is applied synchronously at build, so it is already
801        // idle; this is a no-op kept so tests read the same as the FIND drawer.
802        self.list.poll_until_idle().await;
803    }
804
805    #[cfg(test)]
806    pub(crate) fn match_count(&self) -> usize {
807        self.list.match_count()
808    }
809}
810
811/// (Re)build a [`SearchList`] over the given per-turn rows, wired the same way
812/// on every turn: fuzzy local filter, opening on the list, the `l`/`h`/`o`/`y`
813/// verbs, and the FollowLink / `Ctrl+Y` intercepts. The rows are applied
814/// synchronously (`build_with_rows` over a [`StaticRowSource`]), so they are
815/// live the instant this returns; `redraw` is threaded to the engine but never
816/// fired on this path.
817fn build_list(
818    rows: Vec<SourceRow>,
819    intercept: &[KeyCombo],
820    icons: &Icons,
821    redraw: Arc<dyn Fn() + Send + Sync>,
822) -> SearchList<SourceRow> {
823    SearchList::builder(StaticRowSource, redraw)
824        .icons(icons.clone())
825        .filter(Filter::Fuzzy)
826        .opening_focus(Focus::List)
827        .intercept(intercept.to_vec())
828        .list_verb('l')
829        .list_verb('h')
830        .list_verb('o')
831        .list_verb('y')
832        .build_with_rows(rows)
833}
834
835/// The similarity as a whole-percent integer (`score` is the server's
836/// normalized `0.0..=1.0` similarity — clamped defensively).
837fn score_percent(score: f64) -> u32 {
838    (score.clamp(0.0, 1.0) * 100.0).round() as u32
839}
840
841/// Build the shared [`RichRow`] for a source: the 1-based `rank` as the leading
842/// glyph, the journal date and heading kept as distinct spaced elements (never
843/// the wire's glued `2026-04-08Afternoon`), the score percentage as dim meta,
844/// and the path on the dim filename line.
845fn source_row(rank: usize, source: &AskSource, theme: &Theme) -> RichRow {
846    let bold = Style::default()
847        .fg(theme.fg_bright.to_ratatui())
848        .add_modifier(Modifier::BOLD);
849    let date_style = Style::default().fg(theme.color_journal_date.to_ratatui());
850    let rank_style = Style::default()
851        .fg(theme.accent.to_ratatui())
852        .add_modifier(Modifier::BOLD);
853    let pct = format!("{}%", score_percent(source.score));
854
855    let mut row = if source.heading.is_empty() {
856        // A bare-date chunk (empty heading) shows just the date as its title,
857        // in the date color, so there is no dangling separator.
858        match &source.date {
859            Some(date) => RichRow::new(rank.to_string(), date.clone()).title_style(date_style),
860            None => RichRow::new(rank.to_string(), String::new()).title_style(bold),
861        }
862    } else {
863        let mut r = RichRow::new(rank.to_string(), source.heading.clone()).title_style(bold);
864        if let Some(date) = &source.date {
865            r = r.date(date.clone(), Some(date_style));
866        }
867        r
868    };
869    row = row.glyph_style(rank_style).meta(pct);
870    row.filename(source.path.to_string())
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use kimun_core::VaultConfig;
877    use ratatui::Terminal;
878    use ratatui::backend::TestBackend;
879    use tempfile::TempDir;
880
881    fn source(path: &str, heading: &str, score: f64, text: &str) -> AskSource {
882        AskSource {
883            path: VaultPath::new(path),
884            heading: heading.to_string(),
885            date: None,
886            score,
887            text: text.to_string(),
888            ordinal: 0,
889        }
890    }
891
892    fn dated_source(path: &str, heading: &str, date: &str, score: f64) -> AskSource {
893        AskSource {
894            path: VaultPath::new(path),
895            heading: heading.to_string(),
896            date: Some(date.to_string()),
897            score,
898            text: String::new(),
899            ordinal: 0,
900        }
901    }
902
903    async fn test_vault() -> (TempDir, NoteVault) {
904        let dir = TempDir::new().unwrap();
905        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
906        (dir, vault)
907    }
908
909    fn key_bindings() -> KeyBindings {
910        crate::settings::AppSettings::default().key_bindings.clone()
911    }
912
913    /// A throwaway sender for `set_turn`/`refresh` in tests that do not inspect
914    /// the engine's redraw wake (its receiver is dropped, so the redraw send is
915    /// a harmless no-op). Tests that assert on the Redraw event use a live
916    /// channel instead.
917    fn noop_tx() -> AppTx {
918        tokio::sync::mpsc::unbounded_channel().0
919    }
920
921    /// A panel over a throwaway vault, for tests that never touch the note load.
922    /// The backing dir is leaked so the vault stays valid for the test's
923    /// lifetime.
924    async fn test_panel() -> SourcesPanel {
925        let (dir, vault) = test_vault().await;
926        std::mem::forget(dir);
927        SourcesPanel::new(Arc::new(vault), &key_bindings())
928    }
929
930    fn key(code: KeyCode) -> KeyEvent {
931        KeyEvent::new(code, KeyModifiers::NONE)
932    }
933
934    fn ctrl(code: KeyCode) -> KeyEvent {
935        KeyEvent::new(code, KeyModifiers::CONTROL)
936    }
937
938    /// Populate `p` with two sources and drain the engine's initial load so the
939    /// rows (and the seeded selection) are live.
940    async fn two_source_panel(p: &mut SourcesPanel) {
941        p.set_turn(
942            1,
943            vec![
944                source("a.md", "A", 0.9, "alpha body"),
945                source("b.md", "B", 0.5, "beta body"),
946            ],
947            &noop_tx(),
948        );
949        p.settle().await;
950    }
951
952    /// Move the list selection to visible index `i` by driving the engine.
953    async fn select_index(p: &mut SourcesPanel, i: usize) {
954        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
955        for _ in 0..i {
956            p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
957        }
958    }
959
960    fn selected_heading(p: &SourcesPanel) -> Option<String> {
961        p.selected_source().map(|s| s.heading.clone())
962    }
963
964    /// Heading of the source at rank position `i` (0-based) in the engine's row
965    /// set — the panel keeps no parallel sources copy.
966    fn nth_heading(p: &SourcesPanel, i: usize) -> Option<String> {
967        p.source_at(i).map(|s| s.heading.clone())
968    }
969
970    #[test]
971    fn score_percent_rounds_and_clamps() {
972        assert_eq!(score_percent(0.874), 87);
973        assert_eq!(score_percent(1.5), 100);
974        assert_eq!(score_percent(-0.2), 0);
975    }
976
977    #[test]
978    fn dated_source_display_heading_separates_date_and_heading() {
979        let s = dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9);
980        assert_eq!(s.display_heading(), "2026-04-08 \u{b7} Afternoon");
981        assert_eq!(source("n.md", "Ideas", 0.5, "").display_heading(), "Ideas");
982    }
983
984    #[tokio::test]
985    async fn new_panel_starts_empty_and_collapsed() {
986        let p = test_panel().await;
987        assert_eq!(p.match_count(), 0);
988        assert!(p.preview.is_collapsed());
989    }
990
991    #[tokio::test]
992    async fn set_turn_populates_and_collapses() {
993        let mut p = test_panel().await;
994        p.set_turn(1, vec![source("a.md", "A", 0.9, "text a")], &noop_tx());
995        p.settle().await;
996        assert_eq!(p.turn_id, Some(1));
997        assert_eq!(p.match_count(), 1, "the engine mirrors the turn's rows");
998        assert!(p.preview.is_collapsed());
999    }
1000
1001    #[tokio::test]
1002    async fn set_turn_same_id_is_a_noop_and_keeps_selection() {
1003        let mut p = test_panel().await;
1004        two_source_panel(&mut p).await;
1005        select_index(&mut p, 1).await;
1006        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1007        p.set_turn(1, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1008        p.settle().await;
1009        assert_eq!(
1010            selected_heading(&p).as_deref(),
1011            Some("B"),
1012            "selection must survive a same-id set_turn"
1013        );
1014        assert_eq!(p.match_count(), 2, "rows must not be replaced");
1015        assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1016    }
1017
1018    #[tokio::test]
1019    async fn set_turn_new_id_resets_selection_and_collapses() {
1020        let mut p = test_panel().await;
1021        two_source_panel(&mut p).await;
1022        select_index(&mut p, 1).await;
1023        p.preview.toggle(Some(VaultPath::new("a.md")));
1024        p.set_turn(2, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1025        p.settle().await;
1026        assert_eq!(selected_heading(&p).as_deref(), Some("C"));
1027        assert_eq!(p.match_count(), 1);
1028        assert!(p.preview.is_collapsed());
1029    }
1030
1031    #[tokio::test]
1032    async fn focus_source_points_selection_by_ordinal_through_the_engine() {
1033        let mut p = test_panel().await;
1034        let mut a = source("a.md", "A", 0.9, "a");
1035        a.ordinal = 3;
1036        let mut b = source("b.md", "B", 0.5, "b");
1037        b.ordinal = 7;
1038        p.set_turn(1, vec![a, b], &noop_tx());
1039        p.settle().await;
1040        p.preview.toggle(Some(VaultPath::new("a.md")));
1041        p.focus_source(7);
1042        assert_eq!(
1043            p.selected_source().map(|s| s.ordinal),
1044            Some(7),
1045            "resolved ordinal 7 to its row through the engine, not ordinal-1"
1046        );
1047        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1048        assert!(p.preview.is_collapsed());
1049        // An unknown ordinal is ignored.
1050        p.focus_source(99);
1051        assert_eq!(p.selected_source().map(|s| s.ordinal), Some(7));
1052    }
1053
1054    /// `refresh` (the answer-completion path) applies the turn's rows
1055    /// synchronously — they are present the instant it returns, with ZERO prior
1056    /// interaction and no `poll`/`settle`. There is no async row load to wait
1057    /// on, so the drawer paints the freshly-ranked sources on the next frame
1058    /// without needing a Redraw wake.
1059    #[tokio::test]
1060    async fn refresh_applies_rows_synchronously_no_redraw_needed() {
1061        let mut p = test_panel().await;
1062        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1063        p.refresh(1, vec![source("a.md", "A", 0.9, "alpha body")], &tx);
1064        // No poll, no settle: the rows are live now.
1065        assert_eq!(
1066            p.match_count(),
1067            1,
1068            "refresh's rows are applied synchronously"
1069        );
1070        assert!(!p.list.is_loading(), "no async load is in flight");
1071        assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1072        // The synchronous path never fires the engine's redraw callback — the
1073        // render loop repaints on its own; no Redraw event is required.
1074        let mut redraws = 0;
1075        while let Ok(ev) = rx.try_recv() {
1076            if matches!(ev, AppEvent::Redraw) {
1077                redraws += 1;
1078            }
1079        }
1080        assert_eq!(redraws, 0, "no Redraw wake is needed for the sync row set");
1081    }
1082
1083    /// A cross-turn citation click runs `set_turn` (rebuild) then `focus_source`
1084    /// in the SAME tick. Because the rows are applied synchronously, the ordinal
1085    /// jump lands immediately — no deferral, no `settle` needed.
1086    #[tokio::test]
1087    async fn cross_turn_focus_source_applies_immediately() {
1088        let mut p = test_panel().await;
1089        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1090        let mut a = source("a.md", "A", 0.9, "a");
1091        a.ordinal = 3;
1092        let mut b = source("b.md", "B", 0.5, "b");
1093        b.ordinal = 7;
1094        // New turn + citation focus in the same tick: rows are present now.
1095        p.set_turn(2, vec![a, b], &tx);
1096        p.focus_source(7);
1097        assert_eq!(
1098            p.selected_source().map(|s| s.ordinal),
1099            Some(7),
1100            "citation focus applied in the same tick as set_turn"
1101        );
1102        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1103    }
1104
1105    /// Leader `a s` shape: `set_turn(new id)` then `open_reader(0)` in the SAME
1106    /// tick. The rows are synchronous, so the preview opens on the requested
1107    /// source on the FIRST press (the pre-fix bug needed two presses because the
1108    /// rows had not landed when `open_reader` read `source_at(0)`).
1109    #[tokio::test]
1110    async fn set_turn_then_open_reader_same_tick_opens_first_press() {
1111        let (_dir, vault) = test_vault().await;
1112        vault
1113            .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1114            .await
1115            .unwrap();
1116        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1117        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1118        // Fresh turn + open the top source in the same tick — no settle between.
1119        p.set_turn(9, vec![source("a.md", "ha", 0.9, "alpha text")], &tx);
1120        p.open_reader(0, &tx);
1121        assert!(
1122            p.preview.is_context(),
1123            "open_reader opens the preview on the first press"
1124        );
1125        assert_eq!(
1126            selected_heading(&p).as_deref(),
1127            Some("ha"),
1128            "the requested source is selected"
1129        );
1130        assert_eq!(
1131            p.loaded.as_ref().map(|l| l.path.clone()),
1132            Some(VaultPath::new("a.md")),
1133            "the note load is anchored to the opened source"
1134        );
1135    }
1136
1137    // ── New filter input (in-memory, heading/path text) ───────────────────
1138
1139    #[tokio::test]
1140    async fn filter_input_narrows_sources_by_heading_or_path_text() {
1141        let mut p = test_panel().await;
1142        p.set_turn(
1143            1,
1144            vec![
1145                source("alpha.md", "Alpha section", 0.9, "a"),
1146                source("beta.md", "Beta section", 0.5, "b"),
1147                source("gamma.md", "Gamma section", 0.3, "g"),
1148            ],
1149            &noop_tx(),
1150        );
1151        p.settle().await;
1152        assert_eq!(p.match_count(), 3, "no filter shows every source");
1153        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1154        // `i` reveals the filter input; typing filters by heading text.
1155        assert_eq!(p.list.focus(), Focus::List);
1156        p.handle_input(&InputEvent::Key(key(KeyCode::Char('i'))), &tx);
1157        assert_eq!(p.list.focus(), Focus::Input, "`i` reveals the filter input");
1158        for c in ['B', 'e', 't', 'a'] {
1159            p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1160        }
1161        p.settle().await;
1162        assert_eq!(p.match_count(), 1, "typed filter narrows to the match");
1163        assert_eq!(selected_heading(&p).as_deref(), Some("Beta section"));
1164    }
1165
1166    #[tokio::test]
1167    async fn slash_also_reveals_the_filter_and_matches_path_text() {
1168        let mut p = test_panel().await;
1169        p.set_turn(
1170            1,
1171            vec![
1172                source("notes/alpha.md", "One", 0.9, "a"),
1173                source("journal/beta.md", "Two", 0.5, "b"),
1174            ],
1175            &noop_tx(),
1176        );
1177        p.settle().await;
1178        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1179        p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &tx);
1180        assert_eq!(p.list.focus(), Focus::Input, "`/` reveals the filter input");
1181        for c in ['j', 'o', 'u', 'r'] {
1182            p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1183        }
1184        p.settle().await;
1185        assert_eq!(p.match_count(), 1, "path text filters too");
1186        assert_eq!(selected_heading(&p).as_deref(), Some("Two"));
1187    }
1188
1189    // ── Reveal cycle (Enter / l / h) ──────────────────────────────────────
1190
1191    #[tokio::test]
1192    async fn enter_and_l_cycle_forward_h_cycles_back() {
1193        let mut p = test_panel().await;
1194        two_source_panel(&mut p).await;
1195        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1196        assert!(p.preview.is_collapsed());
1197
1198        p.handle_input(&InputEvent::Key(key(KeyCode::Enter)), &tx);
1199        assert!(p.preview.is_context(), "Enter: Collapsed -> Context");
1200        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1201        assert!(p.preview.is_full(), "l: Context -> Full");
1202        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1203        assert!(p.preview.is_collapsed(), "l: Full -> Collapsed (wraps)");
1204
1205        // Back cycle with h stops at Collapsed.
1206        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx); // -> Context
1207        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx); // -> Full
1208        assert!(p.preview.is_full());
1209        p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1210        assert!(p.preview.is_context(), "h: Full -> Context");
1211        p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1212        assert!(p.preview.is_collapsed(), "h: Context -> Collapsed");
1213        p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1214        assert!(p.preview.is_collapsed(), "h at Collapsed stays Collapsed");
1215    }
1216
1217    #[tokio::test]
1218    async fn esc_steps_back_then_bubbles_to_thread() {
1219        let mut p = test_panel().await;
1220        two_source_panel(&mut p).await;
1221        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1222        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1223
1224        let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1225        assert_eq!(st, EventState::Consumed);
1226        assert!(p.preview.is_collapsed(), "Esc steps back one reveal state");
1227
1228        // From Collapsed (list focus), Esc bubbles so the host returns focus to
1229        // the thread.
1230        let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1231        assert_eq!(
1232            st,
1233            EventState::NotConsumed,
1234            "Collapsed Esc -> back to thread"
1235        );
1236    }
1237
1238    #[tokio::test]
1239    async fn jk_moves_selection_within_bounds() {
1240        let mut p = test_panel().await;
1241        two_source_panel(&mut p).await;
1242        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1243
1244        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1245        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1246        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1247        assert_eq!(
1248            selected_heading(&p).as_deref(),
1249            Some("B"),
1250            "clamped at the last row"
1251        );
1252        p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1253        assert_eq!(selected_heading(&p).as_deref(), Some("A"));
1254        p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1255        assert_eq!(
1256            selected_heading(&p).as_deref(),
1257            Some("A"),
1258            "clamped at the first row"
1259        );
1260    }
1261
1262    // ── Open (o / FollowLink) — from any reveal state ─────────────────────
1263
1264    async fn assert_opens_selected(setup: impl Fn(&mut SourcesPanel), open: KeyEvent) {
1265        let mut p = test_panel().await;
1266        two_source_panel(&mut p).await;
1267        select_index(&mut p, 1).await;
1268        setup(&mut p);
1269        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1270        let st = p.handle_input(&InputEvent::Key(open), &tx);
1271        assert_eq!(st, EventState::Consumed);
1272        let mut opened = None;
1273        while let Ok(ev) = rx.try_recv() {
1274            if let AppEvent::OpenPath { path, .. } = ev {
1275                opened = Some(path);
1276            }
1277        }
1278        assert_eq!(
1279            opened,
1280            Some(VaultPath::new("b.md")),
1281            "opened the selected source"
1282        );
1283    }
1284
1285    #[tokio::test]
1286    async fn o_opens_selected_from_every_reveal_state() {
1287        // Collapsed, Context, Full — `o` opens the selected source each time.
1288        assert_opens_selected(|_p| {}, key(KeyCode::Char('o'))).await;
1289        assert_opens_selected(
1290            |p| p.preview.toggle(Some(VaultPath::new("b.md"))),
1291            key(KeyCode::Char('o')),
1292        )
1293        .await;
1294        assert_opens_selected(
1295            |p| {
1296                p.preview.toggle(Some(VaultPath::new("b.md")));
1297                p.preview.toggle(Some(VaultPath::new("b.md")));
1298            },
1299            key(KeyCode::Char('o')),
1300        )
1301        .await;
1302    }
1303
1304    #[tokio::test]
1305    async fn followlink_ctrl_n_opens_selected() {
1306        assert_opens_selected(|_p| {}, ctrl(KeyCode::Char('n'))).await;
1307        // Also from Full.
1308        assert_opens_selected(
1309            |p| {
1310                p.preview.toggle(Some(VaultPath::new("b.md")));
1311                p.preview.toggle(Some(VaultPath::new("b.md")));
1312            },
1313            ctrl(KeyCode::Char('n')),
1314        )
1315        .await;
1316    }
1317
1318    // ── Yank (y / Ctrl+Y) ─────────────────────────────────────────────────
1319
1320    async fn assert_yanks(k: KeyEvent) {
1321        let mut p = test_panel().await;
1322        two_source_panel(&mut p).await;
1323        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1324        let st = p.handle_input(&InputEvent::Key(k), &tx);
1325        assert_eq!(st, EventState::Consumed);
1326        let mut flashed = false;
1327        while let Ok(ev) = rx.try_recv() {
1328            if matches!(ev, AppEvent::FlashMessage(_)) {
1329                flashed = true;
1330            }
1331        }
1332        assert!(
1333            flashed,
1334            "yank emits a flash message (ok or clipboard error)"
1335        );
1336    }
1337
1338    #[tokio::test]
1339    async fn plain_y_and_ctrl_y_both_yank() {
1340        assert_yanks(key(KeyCode::Char('y'))).await;
1341        assert_yanks(ctrl(KeyCode::Char('y'))).await;
1342    }
1343
1344    // ── Async note load + stale-drop ──────────────────────────────────────
1345
1346    #[tokio::test]
1347    async fn reader_note_for_the_wrong_path_is_dropped() {
1348        let mut p = test_panel().await;
1349        p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1350        p.loaded = Some(LoadedNote {
1351            path: VaultPath::new("a.md"),
1352            ordinal: 0,
1353            content: ReaderContent::Loading,
1354        });
1355        p.handle_data(AskData::ReaderNote {
1356            path: VaultPath::new("other.md"),
1357            text: Some("nope".to_string()),
1358        });
1359        assert!(
1360            matches!(p.loaded.as_ref().unwrap().content, ReaderContent::Loading),
1361            "wrong-path ReaderNote must be dropped, not accepted"
1362        );
1363    }
1364
1365    #[tokio::test]
1366    async fn reader_note_for_the_right_path_loads_and_highlights() {
1367        let mut p = test_panel().await;
1368        p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1369        p.settle().await;
1370        p.loaded = Some(LoadedNote {
1371            path: VaultPath::new("a.md"),
1372            ordinal: 0,
1373            content: ReaderContent::Loading,
1374        });
1375        p.handle_data(AskData::ReaderNote {
1376            path: VaultPath::new("a.md"),
1377            text: Some("# a\nalpha body\n# b\nbeta body\n".to_string()),
1378        });
1379        match &p.loaded.as_ref().unwrap().content {
1380            ReaderContent::Loaded { text, highlight } => {
1381                let r = highlight.clone().expect("chunk resolves");
1382                assert_eq!(&text[r], "beta body");
1383            }
1384            _ => panic!("expected Loaded"),
1385        }
1386    }
1387
1388    #[tokio::test]
1389    async fn reader_note_load_failure_is_recorded() {
1390        let mut p = test_panel().await;
1391        p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1392        p.loaded = Some(LoadedNote {
1393            path: VaultPath::new("a.md"),
1394            ordinal: 0,
1395            content: ReaderContent::Loading,
1396        });
1397        p.handle_data(AskData::ReaderNote {
1398            path: VaultPath::new("a.md"),
1399            text: None,
1400        });
1401        assert!(matches!(
1402            p.loaded.as_ref().unwrap().content,
1403            ReaderContent::Failed
1404        ));
1405    }
1406
1407    #[tokio::test]
1408    async fn handle_data_ignores_answer_ready() {
1409        let mut p = test_panel().await;
1410        p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1411        p.loaded = Some(LoadedNote {
1412            path: VaultPath::new("a.md"),
1413            ordinal: 0,
1414            content: ReaderContent::Loading,
1415        });
1416        p.handle_data(AskData::AnswerReady {
1417            turn_id: 1,
1418            result: Ok(("x".into(), vec![])),
1419        });
1420        assert!(matches!(
1421            p.loaded.as_ref().unwrap().content,
1422            ReaderContent::Loading
1423        ));
1424    }
1425
1426    #[tokio::test]
1427    async fn open_reader_opens_preview_and_round_trips_a_real_vault() {
1428        let (_dir, vault) = test_vault().await;
1429        let path = VaultPath::new("note.md");
1430        vault.create_note(&path, "# h\nbody text\n").await.unwrap();
1431
1432        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1433        p.set_turn(
1434            1,
1435            vec![source("note.md", "h", 0.9, "body text")],
1436            &noop_tx(),
1437        );
1438        p.settle().await;
1439        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1440        p.open_reader(0, &tx);
1441        assert!(
1442            p.preview.is_context(),
1443            "open_reader opens the Context preview"
1444        );
1445
1446        let event = rx.recv().await.expect("open_reader spawns a ReaderNote");
1447        let AppEvent::Ask(data) = event else {
1448            panic!("expected an Ask event");
1449        };
1450        p.handle_data(data);
1451        match &p.loaded.as_ref().unwrap().content {
1452            ReaderContent::Loaded { text, .. } => assert_eq!(text, "# h\nbody text\n"),
1453            _ => panic!("expected Loaded"),
1454        }
1455    }
1456
1457    #[tokio::test]
1458    async fn navigating_in_context_reloads_for_the_new_source() {
1459        let (_dir, vault) = test_vault().await;
1460        std::mem::forget(_dir);
1461        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1462        two_source_panel(&mut p).await;
1463        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1464        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1465        p.ensure_loaded(&tx);
1466        assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("a.md"));
1467        // Move down while the preview is open: the load re-keys to b.md, so a
1468        // late a.md ReaderNote would now be dropped.
1469        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1470        assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("b.md"));
1471    }
1472
1473    // ── Rendering ─────────────────────────────────────────────────────────
1474
1475    fn buffer_text(p: &mut SourcesPanel, w: u16, h: u16) -> String {
1476        let theme = Theme::default();
1477        let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
1478        term.draw(|f| {
1479            let area = f.area();
1480            p.render(f, area, &theme, true);
1481        })
1482        .unwrap();
1483        let buf = term.backend().buffer().clone();
1484        (0..buf.area.height)
1485            .map(|y| {
1486                (0..buf.area.width)
1487                    .map(|x| buf[(x, y)].symbol())
1488                    .collect::<String>()
1489            })
1490            .collect::<Vec<_>>()
1491            .join("\n")
1492    }
1493
1494    #[tokio::test]
1495    async fn row_render_carries_rank_and_score() {
1496        let mut p = test_panel().await;
1497        p.set_turn(
1498            1,
1499            vec![
1500                dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1501                source("b.md", "Beta section", 0.42, "beta body"),
1502            ],
1503            &noop_tx(),
1504        );
1505        p.settle().await;
1506        // Height grown to fit the always-visible filter box (Length(3)) above
1507        // the list plus both two-line rows.
1508        let text = buffer_text(&mut p, 60, 11);
1509        assert!(text.contains("1 "), "rank 1 leads the first row: {text}");
1510        assert!(text.contains("2 "), "rank 2 leads the second row: {text}");
1511        assert!(text.contains("90%"), "score percent shown: {text}");
1512        assert!(text.contains("42%"), "second score shown: {text}");
1513        assert!(text.contains("2026-04-08"), "date kept: {text}");
1514        assert!(
1515            text.contains('\u{b7}'),
1516            "date \u{b7} heading separation: {text}"
1517        );
1518        assert!(text.contains("Afternoon"), "heading kept: {text}");
1519    }
1520
1521    /// Converged with FIND (query_panel.rs): the filter field is a bordered
1522    /// box titled "filter", always visible — even before the user leaves list
1523    /// focus for the input — not a bare text line that only appears once `/`
1524    /// or `i` is pressed.
1525    #[tokio::test]
1526    async fn filter_box_is_bordered_and_always_visible() {
1527        let mut p = test_panel().await;
1528        p.set_turn(
1529            1,
1530            vec![source("a.md", "Alpha", 0.9, "alpha body")],
1531            &noop_tx(),
1532        );
1533        p.settle().await;
1534
1535        // Sources opens on the list (CONTEXT.md "List focus"), but the filter
1536        // box must already be on screen — the pre-convergence behavior only
1537        // rendered it once focus moved to Input.
1538        assert_eq!(p.list.focus(), Focus::List, "Sources opens on the list");
1539        let text = buffer_text(&mut p, 40, 10);
1540        assert!(
1541            text.contains("filter"),
1542            "filter box shows in list focus, before `/`/`i`: {text}"
1543        );
1544        assert!(
1545            text.contains('\u{250c}') || text.contains('\u{2500}'),
1546            "filter field is boxed (bordered), not a bare line: {text}"
1547        );
1548
1549        // Same boxed chrome once the user reveals the input — no layout
1550        // change, just the (already-existing) focused/unfocused input style.
1551        p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &noop_tx());
1552        assert_eq!(p.list.focus(), Focus::Input);
1553        let text = buffer_text(&mut p, 40, 10);
1554        assert!(
1555            text.contains("filter"),
1556            "filter box stays visible in input focus: {text}"
1557        );
1558    }
1559
1560    /// F3: a filter that matches nothing must render FIND's "No results"
1561    /// message, not a silent blank — the row set is non-empty, only the visible
1562    /// (filtered) set is empty.
1563    #[tokio::test]
1564    async fn zero_match_filter_shows_no_results() {
1565        let mut p = test_panel().await;
1566        p.set_turn(1, vec![source("a.md", "Alpha", 0.9, "body")], &noop_tx());
1567        p.settle().await;
1568        p.list.set_query("zzznomatch");
1569        assert_eq!(p.list.visible_rows().len(), 0, "filter narrows to nothing");
1570        let text = buffer_text(&mut p, 40, 10);
1571        assert!(
1572            text.contains("No results"),
1573            "zero-match filter shows the No results message: {text}"
1574        );
1575    }
1576
1577    /// F2: the Context list pane is sized by the VISIBLE (filtered) count, so a
1578    /// narrowing filter shrinks the list and the preview gets the reclaimed
1579    /// space (more of the note is shown).
1580    #[tokio::test]
1581    async fn context_list_pane_shrinks_when_filter_narrows() {
1582        let mut p = test_panel().await;
1583        let srcs: Vec<_> = (0..10)
1584            .map(|i| source(&format!("n{i}.md"), &format!("Alpha{i}"), 0.9, "body"))
1585            .collect();
1586        p.set_turn(1, srcs, &noop_tx());
1587        p.settle().await;
1588        // Open Context on the first source with a long note and NO highlight, so
1589        // the preview renders from the top and a taller pane shows more lines.
1590        p.preview.toggle(Some(VaultPath::new("n0.md")));
1591        let mut text = String::new();
1592        for i in 0..40 {
1593            text.push_str(&format!("noteline{i}\n"));
1594        }
1595        p.loaded = Some(LoadedNote {
1596            path: VaultPath::new("n0.md"),
1597            ordinal: 0,
1598            content: ReaderContent::Loaded {
1599                text,
1600                highlight: None,
1601            },
1602        });
1603        let count_lines = |p: &mut SourcesPanel| buffer_text(p, 40, 20).matches("noteline").count();
1604        let before = count_lines(&mut p);
1605        // Narrow to a single source: the list pane shrinks, the preview grows.
1606        p.list.set_query("Alpha3");
1607        assert_eq!(p.list.visible_rows().len(), 1, "filter narrows to one");
1608        let after = count_lines(&mut p);
1609        assert!(
1610            after > before,
1611            "preview gained the space the shrunken list gave up: before={before} after={after}"
1612        );
1613    }
1614
1615    #[tokio::test]
1616    async fn render_does_not_panic_across_states_and_sizes() {
1617        let mut p = test_panel().await;
1618        buffer_text(&mut p, 40, 10); // empty list
1619
1620        p.set_turn(
1621            1,
1622            vec![
1623                dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1624                source("b.md", "Beta section", 0.4, "beta body"),
1625            ],
1626            &noop_tx(),
1627        );
1628        p.settle().await;
1629        buffer_text(&mut p, 40, 10); // collapsed list
1630        select_index(&mut p, 1).await;
1631        buffer_text(&mut p, 40, 3); // tiny viewport
1632
1633        // Context with a loaded note.
1634        p.preview.toggle(Some(VaultPath::new("b.md")));
1635        p.loaded = Some(LoadedNote {
1636            path: VaultPath::new("b.md"),
1637            ordinal: 0,
1638            content: ReaderContent::Loaded {
1639                text: "# Beta\nbeta body\nmore\n".to_string(),
1640                highlight: Some(7..16),
1641            },
1642        });
1643        buffer_text(&mut p, 40, 12); // context + preview
1644        p.preview.toggle(Some(VaultPath::new("b.md"))); // -> Full
1645        buffer_text(&mut p, 40, 12); // full preview
1646
1647        // Loading / Failed placeholders.
1648        p.loaded = Some(LoadedNote {
1649            path: VaultPath::new("b.md"),
1650            ordinal: 0,
1651            content: ReaderContent::Loading,
1652        });
1653        buffer_text(&mut p, 40, 12);
1654        p.loaded = Some(LoadedNote {
1655            path: VaultPath::new("b.md"),
1656            ordinal: 0,
1657            content: ReaderContent::Failed,
1658        });
1659        buffer_text(&mut p, 40, 12);
1660
1661        buffer_text(&mut p, 3, 3); // degenerate
1662        buffer_text(&mut p, 0, 0); // zero rect
1663    }
1664
1665    #[tokio::test]
1666    async fn full_preview_anchors_scroll_to_the_highlighted_section() {
1667        let mut p = test_panel().await;
1668        p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1669        p.settle().await;
1670        // Open to Full and load a note where the section is several lines down.
1671        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1672        p.preview.toggle(Some(VaultPath::new("a.md"))); // Full
1673        // Section is deep enough that anchoring scrolls past the top (the "two
1674        // lines of context above the section" rule needs room above it).
1675        let mut body = String::new();
1676        for i in 0..8 {
1677            body.push_str(&format!("line{i}\n"));
1678        }
1679        body.push_str("beta body\n");
1680        for i in 0..8 {
1681            body.push_str(&format!("tail{i}\n"));
1682        }
1683        let start = body.find("beta body").unwrap();
1684        p.loaded = Some(LoadedNote {
1685            path: VaultPath::new("a.md"),
1686            ordinal: 0,
1687            content: ReaderContent::Loaded {
1688                text: body,
1689                highlight: Some(start..start + "beta body".len()),
1690            },
1691        });
1692        // Full mode: title(1) + divider(1) + content; a short content viewport so
1693        // the section (line 2) is scrollable into view.
1694        buffer_text(&mut p, 40, 6);
1695        assert!(
1696            p.preview.scroll_offset() > 0,
1697            "preview anchored the scroll to the section, offset={}",
1698            p.preview.scroll_offset()
1699        );
1700    }
1701
1702    // ── Full-preview content scroll (F1) ──────────────────────────────────
1703
1704    #[tokio::test]
1705    async fn full_down_scrolls_content_not_the_list() {
1706        let mut p = test_panel().await;
1707        two_source_panel(&mut p).await;
1708        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1709        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1710        p.preview.toggle(Some(VaultPath::new("a.md"))); // Full
1711        // A note taller than the viewport with the section at the very top, so
1712        // the anchor sits at offset 0 with room to scroll down.
1713        let mut body = String::from("alpha body\n");
1714        for i in 0..20 {
1715            body.push_str(&format!("line{i}\n"));
1716        }
1717        p.loaded = Some(LoadedNote {
1718            path: VaultPath::new("a.md"),
1719            ordinal: 0,
1720            content: ReaderContent::Loaded {
1721                text: body,
1722                highlight: Some(0.."alpha body".len()),
1723            },
1724        });
1725        buffer_text(&mut p, 40, 6); // render sets max; anchor at the top
1726        assert_eq!(p.preview.scroll_offset(), 0);
1727        // Down scrolls the preview content; the list selection stays put.
1728        p.handle_input(&InputEvent::Key(key(KeyCode::Down)), &tx);
1729        assert_eq!(
1730            selected_heading(&p).as_deref(),
1731            Some("A"),
1732            "Down in Full scrolls content, not the list"
1733        );
1734        assert!(
1735            p.preview.scroll_offset() > 0,
1736            "Full + Down scrolled the content, offset={}",
1737            p.preview.scroll_offset()
1738        );
1739    }
1740
1741    #[tokio::test]
1742    async fn full_j_still_moves_the_list_selection() {
1743        let mut p = test_panel().await;
1744        two_source_panel(&mut p).await;
1745        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1746        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1747        p.preview.toggle(Some(VaultPath::new("a.md"))); // Full
1748        assert!(p.preview.is_full());
1749        // `j` is left for list navigation even under the full preview.
1750        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1751        assert_eq!(
1752            selected_heading(&p).as_deref(),
1753            Some("B"),
1754            "j moves the list selection in Full"
1755        );
1756    }
1757
1758    #[tokio::test]
1759    async fn wheel_scrolls_the_open_preview_and_is_ignored_when_collapsed() {
1760        let mut p = test_panel().await;
1761        two_source_panel(&mut p).await;
1762        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1763        // Collapsed with no list rect recorded yet: the wheel misses everything
1764        // and is left unconsumed for the host.
1765        let wheel = |kind| {
1766            InputEvent::Mouse(MouseEvent {
1767                kind,
1768                column: 0,
1769                row: 0,
1770                modifiers: KeyModifiers::NONE,
1771            })
1772        };
1773        assert_eq!(
1774            p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1775            EventState::NotConsumed,
1776            "collapsed preview with no recorded rect does not eat the wheel"
1777        );
1778        // Open to Full with scrollable content.
1779        p.preview.toggle(Some(VaultPath::new("a.md")));
1780        p.preview.toggle(Some(VaultPath::new("a.md")));
1781        let mut body = String::from("alpha body\n");
1782        for i in 0..20 {
1783            body.push_str(&format!("line{i}\n"));
1784        }
1785        p.loaded = Some(LoadedNote {
1786            path: VaultPath::new("a.md"),
1787            ordinal: 0,
1788            content: ReaderContent::Loaded {
1789                text: body,
1790                highlight: Some(0.."alpha body".len()),
1791            },
1792        });
1793        buffer_text(&mut p, 40, 6);
1794        assert_eq!(
1795            p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1796            EventState::Consumed,
1797            "open preview consumes the wheel"
1798        );
1799        assert!(p.preview.scroll_offset() > 0, "wheel scrolled the content");
1800    }
1801
1802    // ── Same-note, different-section re-anchor (F2) ───────────────────────
1803
1804    #[tokio::test]
1805    async fn same_note_different_heading_recomputes_highlight_without_reload() {
1806        let (_dir, vault) = test_vault().await;
1807        std::mem::forget(_dir);
1808        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1809        // Two sources in the SAME note, different sections (distinct ordinals).
1810        let mut s0 = source("doc.md", "Alpha", 0.9, "alpha body");
1811        s0.ordinal = 1;
1812        let mut s1 = source("doc.md", "Beta", 0.8, "beta body");
1813        s1.ordinal = 2;
1814        p.set_turn(1, vec![s0, s1], &noop_tx());
1815        p.settle().await;
1816        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1817        p.preview.toggle(Some(VaultPath::new("doc.md"))); // Context
1818        p.ensure_loaded(&tx); // spawns a load for doc.md, ordinal 1
1819        // Deliver the note text (simulating the load landing).
1820        let note = "# Alpha\nalpha body\n# Beta\nbeta body\n".to_string();
1821        p.handle_data(AskData::ReaderNote {
1822            path: VaultPath::new("doc.md"),
1823            text: Some(note),
1824        });
1825        let first = match &p.loaded.as_ref().unwrap().content {
1826            ReaderContent::Loaded { text, highlight } => {
1827                let r = highlight.clone().expect("section resolves");
1828                assert_eq!(&text[r.clone()], "alpha body");
1829                r
1830            }
1831            _ => panic!("expected Loaded"),
1832        };
1833        // Move to the second source (same note): the highlight re-resolves to
1834        // the new section and the loaded note is REUSED (no drop to Loading).
1835        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1836        match &p.loaded.as_ref().unwrap().content {
1837            ReaderContent::Loaded { text, highlight } => {
1838                let r = highlight.clone().expect("re-resolved");
1839                assert_eq!(&text[r.clone()], "beta body");
1840                assert_ne!(r, first, "highlight moved to the new section");
1841            }
1842            _ => panic!("must reuse the loaded note, not reload"),
1843        }
1844        assert_eq!(
1845            p.loaded.as_ref().unwrap().ordinal,
1846            2,
1847            "re-keyed to the new source"
1848        );
1849    }
1850
1851    // ── open_reader keeps the reveal (F5) ─────────────────────────────────
1852
1853    #[tokio::test]
1854    async fn open_reader_stays_full_and_re_points_to_the_source() {
1855        let (_dir, vault) = test_vault().await;
1856        vault
1857            .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1858            .await
1859            .unwrap();
1860        vault
1861            .create_note(&VaultPath::new("b.md"), "# hb\nbeta text\n")
1862            .await
1863            .unwrap();
1864        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1865        let mut s0 = source("a.md", "ha", 0.9, "alpha text");
1866        s0.ordinal = 1;
1867        let mut s1 = source("b.md", "hb", 0.8, "beta text");
1868        s1.ordinal = 2;
1869        p.set_turn(1, vec![s0, s1], &noop_tx());
1870        p.settle().await;
1871        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1872        // Reveal source 1 in Full.
1873        select_index(&mut p, 1).await;
1874        p.preview.toggle(Some(VaultPath::new("b.md"))); // Context
1875        p.preview.toggle(Some(VaultPath::new("b.md"))); // Full
1876        assert!(p.preview.is_full());
1877        // Directed reveal of source 0 must STAY Full (not collapse) and re-point.
1878        p.open_reader(0, &tx);
1879        assert!(p.preview.is_full(), "open_reader keeps the Full reveal");
1880        assert_eq!(selected_heading(&p).as_deref(), Some("ha"));
1881        // It spawned a load for source 0's note; deliver it and check the section.
1882        let ev = rx.recv().await.expect("open_reader spawns a ReaderNote");
1883        let AppEvent::Ask(data) = ev else {
1884            panic!("expected an Ask event");
1885        };
1886        p.handle_data(data);
1887        match &p.loaded.as_ref().unwrap().content {
1888            ReaderContent::Loaded { text, highlight } => {
1889                assert_eq!(text, "# ha\nalpha text\n", "source 0's note is shown");
1890                let r = highlight.clone().expect("section resolves");
1891                assert_eq!(&text[r], "alpha text");
1892            }
1893            _ => panic!("expected Loaded for source 0"),
1894        }
1895    }
1896}