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