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' => {
452                        if self.has_sources() {
453                            self.preview.toggle(self.selected_path());
454                            self.ensure_loaded(tx);
455                        }
456                    }
457                    'h' => self.preview.collapse_step(self.selected_path()),
458                    'o' => self.open_selected(tx),
459                    'y' => self.yank_selected_path(tx),
460                    _ => {}
461                }
462                EventState::Consumed
463            }
464            // A consumed navigation / filter keystroke: re-anchor the preview on
465            // the new selection and refresh its note load (Context sticks across
466            // moves, Full collapses — see [`PreviewPane::sync`]).
467            KeyReaction::Consumed => {
468                self.sync_preview();
469                self.ensure_loaded(tx);
470                EventState::Consumed
471            }
472            KeyReaction::Yank(target) => {
473                crate::components::yank_row(target, tx);
474                EventState::Consumed
475            }
476            // Esc from a collapsed list bubbles so the host returns focus to the
477            // thread.
478            KeyReaction::Cancel | KeyReaction::Unhandled => EventState::NotConsumed,
479        }
480    }
481
482    /// Route a mouse event through the engine (converged with FIND): the wheel
483    /// scrolls the open preview's content (inside its region) or the list
484    /// (elsewhere in the panel); a click selects a row, a second click on the
485    /// selected row cycles the reveal.
486    fn handle_mouse(&mut self, mouse: &MouseEvent, tx: &AppTx) -> EventState {
487        let was_full = self.preview.is_full();
488        self.sync_preview();
489        // In Full the list is not rendered (its recorded rect is stale), so only
490        // the wheel may reach the engine; a click on the header collapses the
491        // reveal, anything else is swallowed.
492        if was_full {
493            match mouse.kind {
494                MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
495                MouseEventKind::Down(MouseButton::Left)
496                    if self.preview.full_header_rect().contains(Position {
497                        x: mouse.column,
498                        y: mouse.row,
499                    }) =>
500                {
501                    self.preview.toggle(self.selected_path());
502                    return EventState::Consumed;
503                }
504                _ => return EventState::Consumed,
505            }
506        }
507        match self.list.handle_mouse(mouse) {
508            SearchMouse::ContentScrollUp => {
509                self.preview.scroll_up();
510                EventState::Consumed
511            }
512            SearchMouse::ContentScrollDown => {
513                self.preview.scroll_down();
514                EventState::Consumed
515            }
516            SearchMouse::Activated(_) => {
517                self.preview.toggle(self.selected_path());
518                self.ensure_loaded(tx);
519                EventState::Consumed
520            }
521            SearchMouse::Selected(_) | SearchMouse::Scrolled | SearchMouse::Context(_) => {
522                self.sync_preview();
523                self.ensure_loaded(tx);
524                EventState::Consumed
525            }
526            SearchMouse::None => EventState::NotConsumed,
527        }
528    }
529
530    /// Scroll the Full preview by a page (last render's content viewport, less
531    /// a small overlap), `up` toward the top. Single-tick scrolls under the
532    /// hood so the anchor-takeover/clamp rules hold.
533    fn scroll_preview_page(&mut self, up: bool) {
534        let page = self.preview_page.saturating_sub(PAGE_OVERLAP).max(1);
535        for _ in 0..page {
536            if up {
537                self.preview.scroll_up();
538            } else {
539                self.preview.scroll_down();
540            }
541        }
542    }
543
544    /// The selected source (read from the engine's selected row, so it is
545    /// correct even under an active filter).
546    fn selected_source(&self) -> Option<&AskSource> {
547        self.list.selected_row().map(|r| &r.source)
548    }
549
550    /// The selected source's path, for preview anchoring and open/yank.
551    fn selected_path(&self) -> Option<VaultPath> {
552        self.selected_source().map(|s| s.path.clone())
553    }
554
555    /// Re-anchor the preview onto the current selection (Context sticks across
556    /// moves, Full collapses — see [`PreviewPane::sync`]).
557    fn sync_preview(&mut self) {
558        let sel = self.selected_path();
559        self.preview.sync(sel);
560    }
561
562    /// Ensure the preview is backed by the *selected* source's note (the
563    /// interactive path — `open_reader` calls [`Self::ensure_note_load`] directly with
564    /// its directed source). No-op while collapsed or with nothing selected.
565    fn ensure_loaded(&mut self, tx: &AppTx) {
566        if self.preview.is_collapsed() {
567            return;
568        }
569        let Some(source) = self.selected_source() else {
570            return;
571        };
572        let path = source.path.clone();
573        let ordinal = source.ordinal;
574        let heading = source.match_heading().to_string();
575        let chunk = source.text.clone();
576        self.ensure_note_load(path, ordinal, heading, chunk, tx);
577    }
578
579    /// Ensure the preview is backed by the given source's note. Three cases,
580    /// keyed on the source identity (`path` + `ordinal`), not `path` alone:
581    ///
582    /// - **Same source** (same path and ordinal): nothing to do.
583    /// - **Same note, different section** (same path, new ordinal): reuse the
584    ///   already-loaded text, re-resolve the highlight against the new heading,
585    ///   and re-anchor — no vault refetch.
586    /// - **Different note**: spawn the load, re-keying `loaded` so an earlier
587    ///   path's late `ReaderNote` is dropped on arrival.
588    fn ensure_note_load(
589        &mut self,
590        path: VaultPath,
591        ordinal: usize,
592        heading: String,
593        chunk: String,
594        tx: &AppTx,
595    ) {
596        match &self.loaded {
597            Some(l) if l.path == path && l.ordinal == ordinal => return,
598            Some(l) if l.path == path => {
599                // Same note, new section: re-resolve the highlight in place and
600                // re-anchor the preview, without a fresh vault load.
601                if let Some(l) = &mut self.loaded {
602                    l.ordinal = ordinal;
603                    if let ReaderContent::Loaded { text, highlight } = &mut l.content {
604                        *highlight = locate::section_range(text, &heading, &chunk);
605                    }
606                }
607                self.preview.re_anchor();
608                return;
609            }
610            _ => {}
611        }
612
613        self.loaded = Some(LoadedNote {
614            path: path.clone(),
615            ordinal,
616            content: ReaderContent::Loading,
617        });
618        let vault = self.vault.clone();
619        let tx = tx.clone();
620        tokio::spawn(async move {
621            let text = vault.get_note_text(&path).await.ok();
622            let _ = tx.send(AppEvent::Ask(AskData::ReaderNote { path, text }));
623        });
624    }
625
626    /// Open the selected source's note in the editor (plain `o`, or the
627    /// FollowLink intercept) — from any reveal state.
628    fn open_selected(&self, tx: &AppTx) {
629        if let Some(source) = self.selected_source() {
630            tx.send(AppEvent::open(source.path.clone())).ok();
631        }
632    }
633
634    /// Copy the selected source's path to the OS clipboard, reusing the
635    /// shared [`crate::components::yank`] seam `ThreadPanel` and the FIND
636    /// drawer use.
637    fn yank_selected_path(&self, tx: &AppTx) {
638        let Some(source) = self.selected_source() else {
639            return;
640        };
641        crate::components::yank(source.path.to_string(), "path copied", tx);
642    }
643
644    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
645        self.list.poll();
646        // Keep the preview anchored to the selection every frame (Context sticks
647        // across moves; Full collapses on a change) before laying anything out.
648        self.sync_preview();
649        // The whole panel is wheel-scrollable; the content sub-region is
650        // re-recorded (or cleared) by the branch that draws a preview.
651        self.list.set_panel_rect(rect);
652        self.list.set_content_rect(Rect::default());
653        self.preview.clear_header();
654
655        let block = panel_block("Sources", theme, focused);
656        let inner = block.inner(rect);
657        f.render_widget(block, rect);
658
659        // No sources at all for this turn: the prompt. The row set is applied
660        // synchronously on rebuild, so there is no in-flight load to wait on —
661        // an empty list here means the turn genuinely has no sources.
662        if self.list.rows().is_empty() {
663            let style = Style::default().fg(theme.gray.to_ratatui());
664            f.render_widget(
665                Paragraph::new("no sources — ask something").style(style),
666                inner,
667            );
668            return;
669        }
670
671        // A bordered filter box on top, converged with FIND's query searchbox
672        // (query_panel.rs): same `Length(3)` box chrome, same `render_query`
673        // call. Always visible — like FIND — rather than only once the user
674        // has left list focus, so the `/` filter affordance always reads.
675        // `render_query` itself dims the field and hides the cursor outside
676        // Input sub-focus (the List-focus work), so no extra styling is
677        // needed here beyond the shared border-focus style.
678        let rows = Layout::default()
679            .direction(Direction::Vertical)
680            .constraints([Constraint::Length(3), Constraint::Min(0)])
681            .split(inner);
682        let filter_block = Block::default()
683            .title(" filter ")
684            .borders(Borders::ALL)
685            .border_style(theme.border_style(focused))
686            .style(theme.panel_style());
687        let filter_inner = filter_block.inner(rows[0]);
688        f.render_widget(filter_block, rows[0]);
689        self.list.render_query(f, filter_inner, theme, focused);
690        let body = rows[1];
691
692        // A zero-match filter: mirror FIND's "No results" (query_panel.rs) so a
693        // narrowed-to-nothing filter reads as a result, not a silent blank.
694        if self.list.visible_rows().is_empty() {
695            let gray = theme.gray.to_ratatui();
696            let bg = theme.bg_panel.to_ratatui();
697            f.render_widget(
698                Paragraph::new("  No results").style(Style::default().fg(gray).bg(bg)),
699                body,
700            );
701            return;
702        }
703
704        // Full: the preview takes the whole body, no list visible. The wheel
705        // scrolls the content from anywhere in the panel.
706        if self.preview.is_full() {
707            self.list.set_content_rect(rect);
708            self.render_preview(f, body, true, theme);
709            return;
710        }
711
712        // Context: list on top, half-height preview below, divider between.
713        if self.preview.is_context() {
714            let max_list = body.height / 2;
715            // Rows are two lines each; cap the list at half the panel but shrink
716            // for a short (or filtered) list so the preview gets the rest. Use
717            // the VISIBLE (filtered) count, like FIND (query_panel.rs), so a
718            // narrowing filter shrinks the list pane.
719            let visible = self.list.visible_rows().len();
720            let list_height = (visible as u16 * 2).min(max_list).max(1);
721            let areas = Layout::default()
722                .direction(Direction::Vertical)
723                .constraints([
724                    Constraint::Length(list_height),
725                    Constraint::Length(1),
726                    Constraint::Min(0),
727                ])
728                .split(body);
729            self.list.render(f, areas[0], theme, focused);
730            self.list.set_list_rect(areas[0]);
731            let gray = theme.gray.to_ratatui();
732            let bg = theme.bg_panel.to_ratatui();
733            f.render_widget(
734                Paragraph::new("\u{2500}".repeat(areas[1].width as usize))
735                    .style(Style::default().fg(gray).bg(bg)),
736                areas[1],
737            );
738            self.render_preview(f, areas[2], false, theme);
739            self.list.set_content_rect(areas[2]);
740            return;
741        }
742
743        // Collapsed: list only.
744        self.list.render(f, body, theme, focused);
745        self.list.set_list_rect(body);
746    }
747
748    /// Feed the anchored source's loaded note into the preview surface (Context
749    /// or Full), or show the load's placeholder.
750    fn render_preview(&mut self, f: &mut Frame, area: Rect, full: bool, theme: &Theme) {
751        // Record the content viewport for page scrolling: Full spends two rows
752        // on the fixed title + divider chrome; Context uses the whole area.
753        self.preview_page = area.height.saturating_sub(if full { 2 } else { 0 });
754
755        let title_fn = self
756            .list
757            .selected_row()
758            .map(|r| (r.source.display_heading(), r.source.path.to_string()));
759        let Self {
760            loaded, preview, ..
761        } = self;
762        match loaded {
763            Some(LoadedNote {
764                content: ReaderContent::Loaded { text, highlight },
765                ..
766            }) => {
767                if full {
768                    let (title, filename) =
769                        title_fn.unwrap_or_else(|| ("Source".to_string(), String::new()));
770                    preview.render_full(
771                        f,
772                        area,
773                        &title,
774                        &filename,
775                        text,
776                        Highlight::Range(highlight.as_ref()),
777                        theme,
778                    );
779                } else {
780                    preview.render_context(
781                        f,
782                        area,
783                        text,
784                        Highlight::Range(highlight.as_ref()),
785                        theme,
786                    );
787                }
788            }
789            Some(LoadedNote {
790                content: ReaderContent::Failed,
791                ..
792            }) => {
793                let red = Style::default().fg(theme.red.to_ratatui());
794                f.render_widget(Paragraph::new("failed to load note").style(red), area);
795            }
796            None
797            | Some(LoadedNote {
798                content: ReaderContent::Loading,
799                ..
800            }) => {
801                let dim = Style::default().fg(theme.gray.to_ratatui());
802                f.render_widget(Paragraph::new("loading\u{2026}").style(dim), area);
803            }
804        }
805    }
806
807    #[cfg(test)]
808    pub(crate) async fn settle(&mut self) {
809        // The static list is applied synchronously at build, so it is already
810        // idle; this is a no-op kept so tests read the same as the FIND drawer.
811        self.list.poll_until_idle().await;
812    }
813
814    #[cfg(test)]
815    pub(crate) fn match_count(&self) -> usize {
816        self.list.match_count()
817    }
818}
819
820/// (Re)build a [`SearchList`] over the given per-turn rows, wired the same way
821/// on every turn: fuzzy local filter, opening on the list, the `l`/`h`/`o`/`y`
822/// verbs, and the FollowLink / `Ctrl+Y` intercepts. The rows are applied
823/// synchronously (`build_with_rows` over a [`StaticRowSource`]), so they are
824/// live the instant this returns; `redraw` is threaded to the engine but never
825/// fired on this path.
826fn build_list(
827    rows: Vec<SourceRow>,
828    intercept: &[KeyCombo],
829    yank_combos: &[KeyCombo],
830    icons: &Icons,
831    redraw: Arc<dyn Fn() + Send + Sync>,
832) -> SearchList<SourceRow> {
833    SearchList::builder(StaticRowSource, redraw)
834        .yank_combos(yank_combos.to_vec())
835        .icons(icons.clone())
836        .filter(Filter::Fuzzy)
837        .opening_focus(Focus::List)
838        .intercept(intercept.to_vec())
839        .list_verb('l')
840        .list_verb('h')
841        .list_verb('o')
842        .list_verb('y')
843        .build_with_rows(rows)
844}
845
846/// The similarity as a whole-percent integer (`score` is the server's
847/// normalized `0.0..=1.0` similarity — clamped defensively).
848fn score_percent(score: f64) -> u32 {
849    (score.clamp(0.0, 1.0) * 100.0).round() as u32
850}
851
852/// Build the shared [`RichRow`] for a source: the 1-based `rank` as the leading
853/// glyph, the journal date and heading kept as distinct spaced elements (never
854/// the wire's glued `2026-04-08Afternoon`), the score percentage as dim meta,
855/// and the path on the dim filename line.
856fn source_row(rank: usize, source: &AskSource, theme: &Theme) -> RichRow {
857    let bold = Style::default()
858        .fg(theme.fg_bright.to_ratatui())
859        .add_modifier(Modifier::BOLD);
860    let date_style = Style::default().fg(theme.color_journal_date.to_ratatui());
861    let rank_style = Style::default()
862        .fg(theme.accent.to_ratatui())
863        .add_modifier(Modifier::BOLD);
864    let pct = format!("{}%", score_percent(source.score));
865
866    let mut row = if source.heading.is_empty() {
867        // A bare-date chunk (empty heading) shows just the date as its title,
868        // in the date color, so there is no dangling separator.
869        match &source.date {
870            Some(date) => RichRow::new(rank.to_string(), date.clone()).title_style(date_style),
871            None => RichRow::new(rank.to_string(), String::new()).title_style(bold),
872        }
873    } else {
874        let mut r = RichRow::new(rank.to_string(), source.heading.clone()).title_style(bold);
875        if let Some(date) = &source.date {
876            r = r.date(date.clone(), Some(date_style));
877        }
878        r
879    };
880    row = row.glyph_style(rank_style).meta(pct);
881    row.filename(source.path.to_string())
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use kimun_core::VaultConfig;
888    use ratatui::Terminal;
889    use ratatui::backend::TestBackend;
890    use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
891    use tempfile::TempDir;
892
893    fn source(path: &str, heading: &str, score: f64, text: &str) -> AskSource {
894        AskSource {
895            path: VaultPath::new(path),
896            heading: heading.to_string(),
897            date: None,
898            score,
899            text: text.to_string(),
900            ordinal: 0,
901        }
902    }
903
904    fn dated_source(path: &str, heading: &str, date: &str, score: f64) -> AskSource {
905        AskSource {
906            path: VaultPath::new(path),
907            heading: heading.to_string(),
908            date: Some(date.to_string()),
909            score,
910            text: String::new(),
911            ordinal: 0,
912        }
913    }
914
915    async fn test_vault() -> (TempDir, NoteVault) {
916        let dir = TempDir::new().unwrap();
917        let vault = NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
918            .await
919            .unwrap();
920        (dir, vault)
921    }
922
923    fn key_bindings() -> KeyBindings {
924        crate::settings::AppSettings::default().key_bindings.clone()
925    }
926
927    /// A throwaway sender for `set_turn`/`refresh` in tests that do not inspect
928    /// the engine's redraw wake (its receiver is dropped, so the redraw send is
929    /// a harmless no-op). Tests that assert on the Redraw event use a live
930    /// channel instead.
931    fn noop_tx() -> AppTx {
932        tokio::sync::mpsc::unbounded_channel().0
933    }
934
935    /// A panel over a throwaway vault, for tests that never touch the note load.
936    /// The backing dir is leaked so the vault stays valid for the test's
937    /// lifetime.
938    async fn test_panel() -> SourcesPanel {
939        let (dir, vault) = test_vault().await;
940        std::mem::forget(dir);
941        SourcesPanel::new(Arc::new(vault), &key_bindings())
942    }
943
944    fn key(code: KeyCode) -> KeyEvent {
945        KeyEvent::new(code, KeyModifiers::NONE)
946    }
947
948    fn ctrl(code: KeyCode) -> KeyEvent {
949        KeyEvent::new(code, KeyModifiers::CONTROL)
950    }
951
952    /// Populate `p` with two sources and drain the engine's initial load so the
953    /// rows (and the seeded selection) are live.
954    async fn two_source_panel(p: &mut SourcesPanel) {
955        p.set_turn(
956            1,
957            vec![
958                source("a.md", "A", 0.9, "alpha body"),
959                source("b.md", "B", 0.5, "beta body"),
960            ],
961            &noop_tx(),
962        );
963        p.settle().await;
964    }
965
966    /// Move the list selection to visible index `i` by driving the engine.
967    async fn select_index(p: &mut SourcesPanel, i: usize) {
968        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
969        for _ in 0..i {
970            p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
971        }
972    }
973
974    fn selected_heading(p: &SourcesPanel) -> Option<String> {
975        p.selected_source().map(|s| s.heading.clone())
976    }
977
978    /// Heading of the source at rank position `i` (0-based) in the engine's row
979    /// set — the panel keeps no parallel sources copy.
980    fn nth_heading(p: &SourcesPanel, i: usize) -> Option<String> {
981        p.source_at(i).map(|s| s.heading.clone())
982    }
983
984    #[test]
985    fn score_percent_rounds_and_clamps() {
986        assert_eq!(score_percent(0.874), 87);
987        assert_eq!(score_percent(1.5), 100);
988        assert_eq!(score_percent(-0.2), 0);
989    }
990
991    #[test]
992    fn dated_source_display_heading_separates_date_and_heading() {
993        let s = dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9);
994        assert_eq!(s.display_heading(), "2026-04-08 \u{b7} Afternoon");
995        assert_eq!(source("n.md", "Ideas", 0.5, "").display_heading(), "Ideas");
996    }
997
998    #[tokio::test]
999    async fn new_panel_starts_empty_and_collapsed() {
1000        let p = test_panel().await;
1001        assert_eq!(p.match_count(), 0);
1002        assert!(p.preview.is_collapsed());
1003    }
1004
1005    #[tokio::test]
1006    async fn set_turn_populates_and_collapses() {
1007        let mut p = test_panel().await;
1008        p.set_turn(1, vec![source("a.md", "A", 0.9, "text a")], &noop_tx());
1009        p.settle().await;
1010        assert_eq!(p.turn_id, Some(1));
1011        assert_eq!(p.match_count(), 1, "the engine mirrors the turn's rows");
1012        assert!(p.preview.is_collapsed());
1013    }
1014
1015    #[tokio::test]
1016    async fn set_turn_same_id_is_a_noop_and_keeps_selection() {
1017        let mut p = test_panel().await;
1018        two_source_panel(&mut p).await;
1019        select_index(&mut p, 1).await;
1020        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1021        p.set_turn(1, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1022        p.settle().await;
1023        assert_eq!(
1024            selected_heading(&p).as_deref(),
1025            Some("B"),
1026            "selection must survive a same-id set_turn"
1027        );
1028        assert_eq!(p.match_count(), 2, "rows must not be replaced");
1029        assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1030    }
1031
1032    #[tokio::test]
1033    async fn set_turn_new_id_resets_selection_and_collapses() {
1034        let mut p = test_panel().await;
1035        two_source_panel(&mut p).await;
1036        select_index(&mut p, 1).await;
1037        p.preview.toggle(Some(VaultPath::new("a.md")));
1038        p.set_turn(2, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1039        p.settle().await;
1040        assert_eq!(selected_heading(&p).as_deref(), Some("C"));
1041        assert_eq!(p.match_count(), 1);
1042        assert!(p.preview.is_collapsed());
1043    }
1044
1045    #[tokio::test]
1046    async fn focus_source_points_selection_by_ordinal_through_the_engine() {
1047        let mut p = test_panel().await;
1048        let mut a = source("a.md", "A", 0.9, "a");
1049        a.ordinal = 3;
1050        let mut b = source("b.md", "B", 0.5, "b");
1051        b.ordinal = 7;
1052        p.set_turn(1, vec![a, b], &noop_tx());
1053        p.settle().await;
1054        p.preview.toggle(Some(VaultPath::new("a.md")));
1055        p.focus_source(7);
1056        assert_eq!(
1057            p.selected_source().map(|s| s.ordinal),
1058            Some(7),
1059            "resolved ordinal 7 to its row through the engine, not ordinal-1"
1060        );
1061        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1062        assert!(p.preview.is_collapsed());
1063        // An unknown ordinal is ignored.
1064        p.focus_source(99);
1065        assert_eq!(p.selected_source().map(|s| s.ordinal), Some(7));
1066    }
1067
1068    /// `refresh` (the answer-completion path) applies the turn's rows
1069    /// synchronously — they are present the instant it returns, with ZERO prior
1070    /// interaction and no `poll`/`settle`. There is no async row load to wait
1071    /// on, so the drawer paints the freshly-ranked sources on the next frame
1072    /// without needing a Redraw wake.
1073    #[tokio::test]
1074    async fn refresh_applies_rows_synchronously_no_redraw_needed() {
1075        let mut p = test_panel().await;
1076        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1077        p.refresh(1, vec![source("a.md", "A", 0.9, "alpha body")], &tx);
1078        // No poll, no settle: the rows are live now.
1079        assert_eq!(
1080            p.match_count(),
1081            1,
1082            "refresh's rows are applied synchronously"
1083        );
1084        assert!(!p.list.is_loading(), "no async load is in flight");
1085        assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1086        // The synchronous path never fires the engine's redraw callback — the
1087        // render loop repaints on its own; no Redraw event is required.
1088        let mut redraws = 0;
1089        while let Ok(ev) = rx.try_recv() {
1090            if matches!(ev, AppEvent::Redraw) {
1091                redraws += 1;
1092            }
1093        }
1094        assert_eq!(redraws, 0, "no Redraw wake is needed for the sync row set");
1095    }
1096
1097    /// A cross-turn citation click runs `set_turn` (rebuild) then `focus_source`
1098    /// in the SAME tick. Because the rows are applied synchronously, the ordinal
1099    /// jump lands immediately — no deferral, no `settle` needed.
1100    #[tokio::test]
1101    async fn cross_turn_focus_source_applies_immediately() {
1102        let mut p = test_panel().await;
1103        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1104        let mut a = source("a.md", "A", 0.9, "a");
1105        a.ordinal = 3;
1106        let mut b = source("b.md", "B", 0.5, "b");
1107        b.ordinal = 7;
1108        // New turn + citation focus in the same tick: rows are present now.
1109        p.set_turn(2, vec![a, b], &tx);
1110        p.focus_source(7);
1111        assert_eq!(
1112            p.selected_source().map(|s| s.ordinal),
1113            Some(7),
1114            "citation focus applied in the same tick as set_turn"
1115        );
1116        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1117    }
1118
1119    /// Leader `a s` shape: `set_turn(new id)` then `open_reader(0)` in the SAME
1120    /// tick. The rows are synchronous, so the preview opens on the requested
1121    /// source on the FIRST press (the pre-fix bug needed two presses because the
1122    /// rows had not landed when `open_reader` read `source_at(0)`).
1123    #[tokio::test]
1124    async fn set_turn_then_open_reader_same_tick_opens_first_press() {
1125        let (_dir, vault) = test_vault().await;
1126        vault
1127            .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1128            .await
1129            .unwrap();
1130        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1131        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1132        // Fresh turn + open the top source in the same tick — no settle between.
1133        p.set_turn(9, vec![source("a.md", "ha", 0.9, "alpha text")], &tx);
1134        p.open_reader(0, &tx);
1135        assert!(
1136            p.preview.is_context(),
1137            "open_reader opens the preview on the first press"
1138        );
1139        assert_eq!(
1140            selected_heading(&p).as_deref(),
1141            Some("ha"),
1142            "the requested source is selected"
1143        );
1144        assert_eq!(
1145            p.loaded.as_ref().map(|l| l.path.clone()),
1146            Some(VaultPath::new("a.md")),
1147            "the note load is anchored to the opened source"
1148        );
1149    }
1150
1151    // ── New filter input (in-memory, heading/path text) ───────────────────
1152
1153    #[tokio::test]
1154    async fn filter_input_narrows_sources_by_heading_or_path_text() {
1155        let mut p = test_panel().await;
1156        p.set_turn(
1157            1,
1158            vec![
1159                source("alpha.md", "Alpha section", 0.9, "a"),
1160                source("beta.md", "Beta section", 0.5, "b"),
1161                source("gamma.md", "Gamma section", 0.3, "g"),
1162            ],
1163            &noop_tx(),
1164        );
1165        p.settle().await;
1166        assert_eq!(p.match_count(), 3, "no filter shows every source");
1167        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1168        // `i` reveals the filter input; typing filters by heading text.
1169        assert_eq!(p.list.focus(), Focus::List);
1170        p.handle_input(&InputEvent::Key(key(KeyCode::Char('i'))), &tx);
1171        assert_eq!(p.list.focus(), Focus::Input, "`i` reveals the filter input");
1172        for c in ['B', 'e', 't', 'a'] {
1173            p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1174        }
1175        p.settle().await;
1176        assert_eq!(p.match_count(), 1, "typed filter narrows to the match");
1177        assert_eq!(selected_heading(&p).as_deref(), Some("Beta section"));
1178    }
1179
1180    #[tokio::test]
1181    async fn slash_also_reveals_the_filter_and_matches_path_text() {
1182        let mut p = test_panel().await;
1183        p.set_turn(
1184            1,
1185            vec![
1186                source("notes/alpha.md", "One", 0.9, "a"),
1187                source("journal/beta.md", "Two", 0.5, "b"),
1188            ],
1189            &noop_tx(),
1190        );
1191        p.settle().await;
1192        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1193        p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &tx);
1194        assert_eq!(p.list.focus(), Focus::Input, "`/` reveals the filter input");
1195        for c in ['j', 'o', 'u', 'r'] {
1196            p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1197        }
1198        p.settle().await;
1199        assert_eq!(p.match_count(), 1, "path text filters too");
1200        assert_eq!(selected_heading(&p).as_deref(), Some("Two"));
1201    }
1202
1203    // ── Reveal cycle (Enter / l / h) ──────────────────────────────────────
1204
1205    #[tokio::test]
1206    async fn enter_and_l_cycle_forward_h_cycles_back() {
1207        let mut p = test_panel().await;
1208        two_source_panel(&mut p).await;
1209        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1210        assert!(p.preview.is_collapsed());
1211
1212        p.handle_input(&InputEvent::Key(key(KeyCode::Enter)), &tx);
1213        assert!(p.preview.is_context(), "Enter: Collapsed -> Context");
1214        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1215        assert!(p.preview.is_full(), "l: Context -> Full");
1216        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1217        assert!(p.preview.is_collapsed(), "l: Full -> Collapsed (wraps)");
1218
1219        // Back cycle with h stops at Collapsed.
1220        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx); // -> Context
1221        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx); // -> Full
1222        assert!(p.preview.is_full());
1223        p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1224        assert!(p.preview.is_context(), "h: Full -> Context");
1225        p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1226        assert!(p.preview.is_collapsed(), "h: Context -> Collapsed");
1227        p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1228        assert!(p.preview.is_collapsed(), "h at Collapsed stays Collapsed");
1229    }
1230
1231    #[tokio::test]
1232    async fn esc_steps_back_then_bubbles_to_thread() {
1233        let mut p = test_panel().await;
1234        two_source_panel(&mut p).await;
1235        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1236        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1237
1238        let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1239        assert_eq!(st, EventState::Consumed);
1240        assert!(p.preview.is_collapsed(), "Esc steps back one reveal state");
1241
1242        // From Collapsed (list focus), Esc bubbles so the host returns focus to
1243        // the thread.
1244        let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1245        assert_eq!(
1246            st,
1247            EventState::NotConsumed,
1248            "Collapsed Esc -> back to thread"
1249        );
1250    }
1251
1252    #[tokio::test]
1253    async fn jk_moves_selection_within_bounds() {
1254        let mut p = test_panel().await;
1255        two_source_panel(&mut p).await;
1256        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1257
1258        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1259        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1260        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1261        assert_eq!(
1262            selected_heading(&p).as_deref(),
1263            Some("B"),
1264            "clamped at the last row"
1265        );
1266        p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1267        assert_eq!(selected_heading(&p).as_deref(), Some("A"));
1268        p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1269        assert_eq!(
1270            selected_heading(&p).as_deref(),
1271            Some("A"),
1272            "clamped at the first row"
1273        );
1274    }
1275
1276    // ── Open (o / FollowLink) — from any reveal state ─────────────────────
1277
1278    async fn assert_opens_selected(setup: impl Fn(&mut SourcesPanel), open: KeyEvent) {
1279        let mut p = test_panel().await;
1280        two_source_panel(&mut p).await;
1281        select_index(&mut p, 1).await;
1282        setup(&mut p);
1283        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1284        let st = p.handle_input(&InputEvent::Key(open), &tx);
1285        assert_eq!(st, EventState::Consumed);
1286        let mut opened = None;
1287        while let Ok(ev) = rx.try_recv() {
1288            if let AppEvent::OpenPath { path, .. } = ev {
1289                opened = Some(path);
1290            }
1291        }
1292        assert_eq!(
1293            opened,
1294            Some(VaultPath::new("b.md")),
1295            "opened the selected source"
1296        );
1297    }
1298
1299    #[tokio::test]
1300    async fn o_opens_selected_from_every_reveal_state() {
1301        // Collapsed, Context, Full — `o` opens the selected source each time.
1302        assert_opens_selected(|_p| {}, key(KeyCode::Char('o'))).await;
1303        assert_opens_selected(
1304            |p| p.preview.toggle(Some(VaultPath::new("b.md"))),
1305            key(KeyCode::Char('o')),
1306        )
1307        .await;
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            key(KeyCode::Char('o')),
1314        )
1315        .await;
1316    }
1317
1318    #[tokio::test]
1319    async fn followlink_ctrl_n_opens_selected() {
1320        assert_opens_selected(|_p| {}, ctrl(KeyCode::Char('n'))).await;
1321        // Also from Full.
1322        assert_opens_selected(
1323            |p| {
1324                p.preview.toggle(Some(VaultPath::new("b.md")));
1325                p.preview.toggle(Some(VaultPath::new("b.md")));
1326            },
1327            ctrl(KeyCode::Char('n')),
1328        )
1329        .await;
1330    }
1331
1332    // ── Yank (y / Ctrl+Y) ─────────────────────────────────────────────────
1333
1334    async fn assert_yanks(k: KeyEvent) {
1335        let mut p = test_panel().await;
1336        two_source_panel(&mut p).await;
1337        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1338        let st = p.handle_input(&InputEvent::Key(k), &tx);
1339        assert_eq!(st, EventState::Consumed);
1340        let mut flashed = false;
1341        while let Ok(ev) = rx.try_recv() {
1342            if matches!(ev, AppEvent::FlashMessage(_)) {
1343                flashed = true;
1344            }
1345        }
1346        assert!(
1347            flashed,
1348            "yank emits a flash message (ok or clipboard error)"
1349        );
1350    }
1351
1352    #[tokio::test]
1353    async fn plain_y_and_ctrl_y_both_yank() {
1354        assert_yanks(key(KeyCode::Char('y'))).await;
1355        assert_yanks(ctrl(KeyCode::Char('y'))).await;
1356    }
1357
1358    // ── Async note load + stale-drop ──────────────────────────────────────
1359
1360    #[tokio::test]
1361    async fn reader_note_for_the_wrong_path_is_dropped() {
1362        let mut p = test_panel().await;
1363        p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1364        p.loaded = Some(LoadedNote {
1365            path: VaultPath::new("a.md"),
1366            ordinal: 0,
1367            content: ReaderContent::Loading,
1368        });
1369        p.handle_data(AskData::ReaderNote {
1370            path: VaultPath::new("other.md"),
1371            text: Some("nope".to_string()),
1372        });
1373        assert!(
1374            matches!(p.loaded.as_ref().unwrap().content, ReaderContent::Loading),
1375            "wrong-path ReaderNote must be dropped, not accepted"
1376        );
1377    }
1378
1379    #[tokio::test]
1380    async fn reader_note_for_the_right_path_loads_and_highlights() {
1381        let mut p = test_panel().await;
1382        p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1383        p.settle().await;
1384        p.loaded = Some(LoadedNote {
1385            path: VaultPath::new("a.md"),
1386            ordinal: 0,
1387            content: ReaderContent::Loading,
1388        });
1389        p.handle_data(AskData::ReaderNote {
1390            path: VaultPath::new("a.md"),
1391            text: Some("# a\nalpha body\n# b\nbeta body\n".to_string()),
1392        });
1393        match &p.loaded.as_ref().unwrap().content {
1394            ReaderContent::Loaded { text, highlight } => {
1395                let r = highlight.clone().expect("chunk resolves");
1396                assert_eq!(&text[r], "beta body");
1397            }
1398            _ => panic!("expected Loaded"),
1399        }
1400    }
1401
1402    #[tokio::test]
1403    async fn reader_note_load_failure_is_recorded() {
1404        let mut p = test_panel().await;
1405        p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1406        p.loaded = Some(LoadedNote {
1407            path: VaultPath::new("a.md"),
1408            ordinal: 0,
1409            content: ReaderContent::Loading,
1410        });
1411        p.handle_data(AskData::ReaderNote {
1412            path: VaultPath::new("a.md"),
1413            text: None,
1414        });
1415        assert!(matches!(
1416            p.loaded.as_ref().unwrap().content,
1417            ReaderContent::Failed
1418        ));
1419    }
1420
1421    #[tokio::test]
1422    async fn handle_data_ignores_answer_ready() {
1423        let mut p = test_panel().await;
1424        p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1425        p.loaded = Some(LoadedNote {
1426            path: VaultPath::new("a.md"),
1427            ordinal: 0,
1428            content: ReaderContent::Loading,
1429        });
1430        p.handle_data(AskData::AnswerReady {
1431            turn_id: 1,
1432            result: Ok(("x".into(), vec![])),
1433        });
1434        assert!(matches!(
1435            p.loaded.as_ref().unwrap().content,
1436            ReaderContent::Loading
1437        ));
1438    }
1439
1440    #[tokio::test]
1441    async fn open_reader_opens_preview_and_round_trips_a_real_vault() {
1442        let (_dir, vault) = test_vault().await;
1443        let path = VaultPath::new("note.md");
1444        vault.create_note(&path, "# h\nbody text\n").await.unwrap();
1445
1446        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1447        p.set_turn(
1448            1,
1449            vec![source("note.md", "h", 0.9, "body text")],
1450            &noop_tx(),
1451        );
1452        p.settle().await;
1453        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1454        p.open_reader(0, &tx);
1455        assert!(
1456            p.preview.is_context(),
1457            "open_reader opens the Context preview"
1458        );
1459
1460        let event = rx.recv().await.expect("open_reader spawns a ReaderNote");
1461        let AppEvent::Ask(data) = event else {
1462            panic!("expected an Ask event");
1463        };
1464        p.handle_data(data);
1465        match &p.loaded.as_ref().unwrap().content {
1466            ReaderContent::Loaded { text, .. } => assert_eq!(text, "# h\nbody text\n"),
1467            _ => panic!("expected Loaded"),
1468        }
1469    }
1470
1471    #[tokio::test]
1472    async fn navigating_in_context_reloads_for_the_new_source() {
1473        let (_dir, vault) = test_vault().await;
1474        std::mem::forget(_dir);
1475        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1476        two_source_panel(&mut p).await;
1477        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1478        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1479        p.ensure_loaded(&tx);
1480        assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("a.md"));
1481        // Move down while the preview is open: the load re-keys to b.md, so a
1482        // late a.md ReaderNote would now be dropped.
1483        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1484        assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("b.md"));
1485    }
1486
1487    // ── Rendering ─────────────────────────────────────────────────────────
1488
1489    fn buffer_text(p: &mut SourcesPanel, w: u16, h: u16) -> String {
1490        let theme = Theme::default();
1491        let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
1492        term.draw(|f| {
1493            let area = f.area();
1494            p.render(f, area, &theme, true);
1495        })
1496        .unwrap();
1497        let buf = term.backend().buffer().clone();
1498        (0..buf.area.height)
1499            .map(|y| {
1500                (0..buf.area.width)
1501                    .map(|x| buf[(x, y)].symbol())
1502                    .collect::<String>()
1503            })
1504            .collect::<Vec<_>>()
1505            .join("\n")
1506    }
1507
1508    #[tokio::test]
1509    async fn row_render_carries_rank_and_score() {
1510        let mut p = test_panel().await;
1511        p.set_turn(
1512            1,
1513            vec![
1514                dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1515                source("b.md", "Beta section", 0.42, "beta body"),
1516            ],
1517            &noop_tx(),
1518        );
1519        p.settle().await;
1520        // Height grown to fit the always-visible filter box (Length(3)) above
1521        // the list plus both two-line rows.
1522        let text = buffer_text(&mut p, 60, 11);
1523        assert!(text.contains("1 "), "rank 1 leads the first row: {text}");
1524        assert!(text.contains("2 "), "rank 2 leads the second row: {text}");
1525        assert!(text.contains("90%"), "score percent shown: {text}");
1526        assert!(text.contains("42%"), "second score shown: {text}");
1527        assert!(text.contains("2026-04-08"), "date kept: {text}");
1528        assert!(
1529            text.contains('\u{b7}'),
1530            "date \u{b7} heading separation: {text}"
1531        );
1532        assert!(text.contains("Afternoon"), "heading kept: {text}");
1533    }
1534
1535    /// Converged with FIND (query_panel.rs): the filter field is a bordered
1536    /// box titled "filter", always visible — even before the user leaves list
1537    /// focus for the input — not a bare text line that only appears once `/`
1538    /// or `i` is pressed.
1539    #[tokio::test]
1540    async fn filter_box_is_bordered_and_always_visible() {
1541        let mut p = test_panel().await;
1542        p.set_turn(
1543            1,
1544            vec![source("a.md", "Alpha", 0.9, "alpha body")],
1545            &noop_tx(),
1546        );
1547        p.settle().await;
1548
1549        // Sources opens on the list (CONTEXT.md "List focus"), but the filter
1550        // box must already be on screen — the pre-convergence behavior only
1551        // rendered it once focus moved to Input.
1552        assert_eq!(p.list.focus(), Focus::List, "Sources opens on the list");
1553        let text = buffer_text(&mut p, 40, 10);
1554        assert!(
1555            text.contains("filter"),
1556            "filter box shows in list focus, before `/`/`i`: {text}"
1557        );
1558        assert!(
1559            text.contains('\u{250c}') || text.contains('\u{2500}'),
1560            "filter field is boxed (bordered), not a bare line: {text}"
1561        );
1562
1563        // Same boxed chrome once the user reveals the input — no layout
1564        // change, just the (already-existing) focused/unfocused input style.
1565        p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &noop_tx());
1566        assert_eq!(p.list.focus(), Focus::Input);
1567        let text = buffer_text(&mut p, 40, 10);
1568        assert!(
1569            text.contains("filter"),
1570            "filter box stays visible in input focus: {text}"
1571        );
1572    }
1573
1574    /// F3: a filter that matches nothing must render FIND's "No results"
1575    /// message, not a silent blank — the row set is non-empty, only the visible
1576    /// (filtered) set is empty.
1577    #[tokio::test]
1578    async fn zero_match_filter_shows_no_results() {
1579        let mut p = test_panel().await;
1580        p.set_turn(1, vec![source("a.md", "Alpha", 0.9, "body")], &noop_tx());
1581        p.settle().await;
1582        p.list.set_query("zzznomatch");
1583        assert_eq!(p.list.visible_rows().len(), 0, "filter narrows to nothing");
1584        let text = buffer_text(&mut p, 40, 10);
1585        assert!(
1586            text.contains("No results"),
1587            "zero-match filter shows the No results message: {text}"
1588        );
1589    }
1590
1591    /// F2: the Context list pane is sized by the VISIBLE (filtered) count, so a
1592    /// narrowing filter shrinks the list and the preview gets the reclaimed
1593    /// space (more of the note is shown).
1594    #[tokio::test]
1595    async fn context_list_pane_shrinks_when_filter_narrows() {
1596        let mut p = test_panel().await;
1597        let srcs: Vec<_> = (0..10)
1598            .map(|i| source(&format!("n{i}.md"), &format!("Alpha{i}"), 0.9, "body"))
1599            .collect();
1600        p.set_turn(1, srcs, &noop_tx());
1601        p.settle().await;
1602        // Open Context on the first source with a long note and NO highlight, so
1603        // the preview renders from the top and a taller pane shows more lines.
1604        p.preview.toggle(Some(VaultPath::new("n0.md")));
1605        let mut text = String::new();
1606        for i in 0..40 {
1607            text.push_str(&format!("noteline{i}\n"));
1608        }
1609        p.loaded = Some(LoadedNote {
1610            path: VaultPath::new("n0.md"),
1611            ordinal: 0,
1612            content: ReaderContent::Loaded {
1613                text,
1614                highlight: None,
1615            },
1616        });
1617        let count_lines = |p: &mut SourcesPanel| buffer_text(p, 40, 20).matches("noteline").count();
1618        let before = count_lines(&mut p);
1619        // Narrow to a single source: the list pane shrinks, the preview grows.
1620        p.list.set_query("Alpha3");
1621        assert_eq!(p.list.visible_rows().len(), 1, "filter narrows to one");
1622        let after = count_lines(&mut p);
1623        assert!(
1624            after > before,
1625            "preview gained the space the shrunken list gave up: before={before} after={after}"
1626        );
1627    }
1628
1629    #[tokio::test]
1630    async fn render_does_not_panic_across_states_and_sizes() {
1631        let mut p = test_panel().await;
1632        buffer_text(&mut p, 40, 10); // empty list
1633
1634        p.set_turn(
1635            1,
1636            vec![
1637                dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1638                source("b.md", "Beta section", 0.4, "beta body"),
1639            ],
1640            &noop_tx(),
1641        );
1642        p.settle().await;
1643        buffer_text(&mut p, 40, 10); // collapsed list
1644        select_index(&mut p, 1).await;
1645        buffer_text(&mut p, 40, 3); // tiny viewport
1646
1647        // Context with a loaded note.
1648        p.preview.toggle(Some(VaultPath::new("b.md")));
1649        p.loaded = Some(LoadedNote {
1650            path: VaultPath::new("b.md"),
1651            ordinal: 0,
1652            content: ReaderContent::Loaded {
1653                text: "# Beta\nbeta body\nmore\n".to_string(),
1654                highlight: Some(7..16),
1655            },
1656        });
1657        buffer_text(&mut p, 40, 12); // context + preview
1658        p.preview.toggle(Some(VaultPath::new("b.md"))); // -> Full
1659        buffer_text(&mut p, 40, 12); // full preview
1660
1661        // Loading / Failed placeholders.
1662        p.loaded = Some(LoadedNote {
1663            path: VaultPath::new("b.md"),
1664            ordinal: 0,
1665            content: ReaderContent::Loading,
1666        });
1667        buffer_text(&mut p, 40, 12);
1668        p.loaded = Some(LoadedNote {
1669            path: VaultPath::new("b.md"),
1670            ordinal: 0,
1671            content: ReaderContent::Failed,
1672        });
1673        buffer_text(&mut p, 40, 12);
1674
1675        buffer_text(&mut p, 3, 3); // degenerate
1676        buffer_text(&mut p, 0, 0); // zero rect
1677    }
1678
1679    #[tokio::test]
1680    async fn full_preview_anchors_scroll_to_the_highlighted_section() {
1681        let mut p = test_panel().await;
1682        p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1683        p.settle().await;
1684        // Open to Full and load a note where the section is several lines down.
1685        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1686        p.preview.toggle(Some(VaultPath::new("a.md"))); // Full
1687        // Section is deep enough that anchoring scrolls past the top (the "two
1688        // lines of context above the section" rule needs room above it).
1689        let mut body = String::new();
1690        for i in 0..8 {
1691            body.push_str(&format!("line{i}\n"));
1692        }
1693        body.push_str("beta body\n");
1694        for i in 0..8 {
1695            body.push_str(&format!("tail{i}\n"));
1696        }
1697        let start = body.find("beta body").unwrap();
1698        p.loaded = Some(LoadedNote {
1699            path: VaultPath::new("a.md"),
1700            ordinal: 0,
1701            content: ReaderContent::Loaded {
1702                text: body,
1703                highlight: Some(start..start + "beta body".len()),
1704            },
1705        });
1706        // Full mode: title(1) + divider(1) + content; a short content viewport so
1707        // the section (line 2) is scrollable into view.
1708        buffer_text(&mut p, 40, 6);
1709        assert!(
1710            p.preview.scroll_offset() > 0,
1711            "preview anchored the scroll to the section, offset={}",
1712            p.preview.scroll_offset()
1713        );
1714    }
1715
1716    // ── Full-preview content scroll (F1) ──────────────────────────────────
1717
1718    #[tokio::test]
1719    async fn full_down_scrolls_content_not_the_list() {
1720        let mut p = test_panel().await;
1721        two_source_panel(&mut p).await;
1722        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1723        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1724        p.preview.toggle(Some(VaultPath::new("a.md"))); // Full
1725        // A note taller than the viewport with the section at the very top, so
1726        // the anchor sits at offset 0 with room to scroll down.
1727        let mut body = String::from("alpha body\n");
1728        for i in 0..20 {
1729            body.push_str(&format!("line{i}\n"));
1730        }
1731        p.loaded = Some(LoadedNote {
1732            path: VaultPath::new("a.md"),
1733            ordinal: 0,
1734            content: ReaderContent::Loaded {
1735                text: body,
1736                highlight: Some(0.."alpha body".len()),
1737            },
1738        });
1739        buffer_text(&mut p, 40, 6); // render sets max; anchor at the top
1740        assert_eq!(p.preview.scroll_offset(), 0);
1741        // Down scrolls the preview content; the list selection stays put.
1742        p.handle_input(&InputEvent::Key(key(KeyCode::Down)), &tx);
1743        assert_eq!(
1744            selected_heading(&p).as_deref(),
1745            Some("A"),
1746            "Down in Full scrolls content, not the list"
1747        );
1748        assert!(
1749            p.preview.scroll_offset() > 0,
1750            "Full + Down scrolled the content, offset={}",
1751            p.preview.scroll_offset()
1752        );
1753    }
1754
1755    #[tokio::test]
1756    async fn full_j_still_moves_the_list_selection() {
1757        let mut p = test_panel().await;
1758        two_source_panel(&mut p).await;
1759        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1760        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1761        p.preview.toggle(Some(VaultPath::new("a.md"))); // Full
1762        assert!(p.preview.is_full());
1763        // `j` is left for list navigation even under the full preview.
1764        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1765        assert_eq!(
1766            selected_heading(&p).as_deref(),
1767            Some("B"),
1768            "j moves the list selection in Full"
1769        );
1770    }
1771
1772    #[tokio::test]
1773    async fn wheel_scrolls_the_open_preview_and_is_ignored_when_collapsed() {
1774        let mut p = test_panel().await;
1775        two_source_panel(&mut p).await;
1776        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1777        // Collapsed with no list rect recorded yet: the wheel misses everything
1778        // and is left unconsumed for the host.
1779        let wheel = |kind| {
1780            InputEvent::Mouse(MouseEvent {
1781                kind,
1782                column: 0,
1783                row: 0,
1784                modifiers: KeyModifiers::NONE,
1785            })
1786        };
1787        assert_eq!(
1788            p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1789            EventState::NotConsumed,
1790            "collapsed preview with no recorded rect does not eat the wheel"
1791        );
1792        // Open to Full with scrollable content.
1793        p.preview.toggle(Some(VaultPath::new("a.md")));
1794        p.preview.toggle(Some(VaultPath::new("a.md")));
1795        let mut body = String::from("alpha body\n");
1796        for i in 0..20 {
1797            body.push_str(&format!("line{i}\n"));
1798        }
1799        p.loaded = Some(LoadedNote {
1800            path: VaultPath::new("a.md"),
1801            ordinal: 0,
1802            content: ReaderContent::Loaded {
1803                text: body,
1804                highlight: Some(0.."alpha body".len()),
1805            },
1806        });
1807        buffer_text(&mut p, 40, 6);
1808        assert_eq!(
1809            p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1810            EventState::Consumed,
1811            "open preview consumes the wheel"
1812        );
1813        assert!(p.preview.scroll_offset() > 0, "wheel scrolled the content");
1814    }
1815
1816    // ── Same-note, different-section re-anchor (F2) ───────────────────────
1817
1818    #[tokio::test]
1819    async fn same_note_different_heading_recomputes_highlight_without_reload() {
1820        let (_dir, vault) = test_vault().await;
1821        std::mem::forget(_dir);
1822        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1823        // Two sources in the SAME note, different sections (distinct ordinals).
1824        let mut s0 = source("doc.md", "Alpha", 0.9, "alpha body");
1825        s0.ordinal = 1;
1826        let mut s1 = source("doc.md", "Beta", 0.8, "beta body");
1827        s1.ordinal = 2;
1828        p.set_turn(1, vec![s0, s1], &noop_tx());
1829        p.settle().await;
1830        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1831        p.preview.toggle(Some(VaultPath::new("doc.md"))); // Context
1832        p.ensure_loaded(&tx); // spawns a load for doc.md, ordinal 1
1833        // Deliver the note text (simulating the load landing).
1834        let note = "# Alpha\nalpha body\n# Beta\nbeta body\n".to_string();
1835        p.handle_data(AskData::ReaderNote {
1836            path: VaultPath::new("doc.md"),
1837            text: Some(note),
1838        });
1839        let first = match &p.loaded.as_ref().unwrap().content {
1840            ReaderContent::Loaded { text, highlight } => {
1841                let r = highlight.clone().expect("section resolves");
1842                assert_eq!(&text[r.clone()], "alpha body");
1843                r
1844            }
1845            _ => panic!("expected Loaded"),
1846        };
1847        // Move to the second source (same note): the highlight re-resolves to
1848        // the new section and the loaded note is REUSED (no drop to Loading).
1849        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1850        match &p.loaded.as_ref().unwrap().content {
1851            ReaderContent::Loaded { text, highlight } => {
1852                let r = highlight.clone().expect("re-resolved");
1853                assert_eq!(&text[r.clone()], "beta body");
1854                assert_ne!(r, first, "highlight moved to the new section");
1855            }
1856            _ => panic!("must reuse the loaded note, not reload"),
1857        }
1858        assert_eq!(
1859            p.loaded.as_ref().unwrap().ordinal,
1860            2,
1861            "re-keyed to the new source"
1862        );
1863    }
1864
1865    // ── open_reader keeps the reveal (F5) ─────────────────────────────────
1866
1867    #[tokio::test]
1868    async fn open_reader_stays_full_and_re_points_to_the_source() {
1869        let (_dir, vault) = test_vault().await;
1870        vault
1871            .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1872            .await
1873            .unwrap();
1874        vault
1875            .create_note(&VaultPath::new("b.md"), "# hb\nbeta text\n")
1876            .await
1877            .unwrap();
1878        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1879        let mut s0 = source("a.md", "ha", 0.9, "alpha text");
1880        s0.ordinal = 1;
1881        let mut s1 = source("b.md", "hb", 0.8, "beta text");
1882        s1.ordinal = 2;
1883        p.set_turn(1, vec![s0, s1], &noop_tx());
1884        p.settle().await;
1885        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1886        // Reveal source 1 in Full.
1887        select_index(&mut p, 1).await;
1888        p.preview.toggle(Some(VaultPath::new("b.md"))); // Context
1889        p.preview.toggle(Some(VaultPath::new("b.md"))); // Full
1890        assert!(p.preview.is_full());
1891        // Directed reveal of source 0 must STAY Full (not collapse) and re-point.
1892        p.open_reader(0, &tx);
1893        assert!(p.preview.is_full(), "open_reader keeps the Full reveal");
1894        assert_eq!(selected_heading(&p).as_deref(), Some("ha"));
1895        // It spawned a load for source 0's note; deliver it and check the section.
1896        let ev = rx.recv().await.expect("open_reader spawns a ReaderNote");
1897        let AppEvent::Ask(data) = ev else {
1898            panic!("expected an Ask event");
1899        };
1900        p.handle_data(data);
1901        match &p.loaded.as_ref().unwrap().content {
1902            ReaderContent::Loaded { text, highlight } => {
1903                assert_eq!(text, "# ha\nalpha text\n", "source 0's note is shown");
1904                let r = highlight.clone().expect("section resolves");
1905                assert_eq!(&text[r], "alpha text");
1906            }
1907            _ => panic!("expected Loaded for source 0"),
1908        }
1909    }
1910}