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(dir.path())).await.unwrap();
918        (dir, vault)
919    }
920
921    fn key_bindings() -> KeyBindings {
922        crate::settings::AppSettings::default().key_bindings.clone()
923    }
924
925    /// A throwaway sender for `set_turn`/`refresh` in tests that do not inspect
926    /// the engine's redraw wake (its receiver is dropped, so the redraw send is
927    /// a harmless no-op). Tests that assert on the Redraw event use a live
928    /// channel instead.
929    fn noop_tx() -> AppTx {
930        tokio::sync::mpsc::unbounded_channel().0
931    }
932
933    /// A panel over a throwaway vault, for tests that never touch the note load.
934    /// The backing dir is leaked so the vault stays valid for the test's
935    /// lifetime.
936    async fn test_panel() -> SourcesPanel {
937        let (dir, vault) = test_vault().await;
938        std::mem::forget(dir);
939        SourcesPanel::new(Arc::new(vault), &key_bindings())
940    }
941
942    fn key(code: KeyCode) -> KeyEvent {
943        KeyEvent::new(code, KeyModifiers::NONE)
944    }
945
946    fn ctrl(code: KeyCode) -> KeyEvent {
947        KeyEvent::new(code, KeyModifiers::CONTROL)
948    }
949
950    /// Populate `p` with two sources and drain the engine's initial load so the
951    /// rows (and the seeded selection) are live.
952    async fn two_source_panel(p: &mut SourcesPanel) {
953        p.set_turn(
954            1,
955            vec![
956                source("a.md", "A", 0.9, "alpha body"),
957                source("b.md", "B", 0.5, "beta body"),
958            ],
959            &noop_tx(),
960        );
961        p.settle().await;
962    }
963
964    /// Move the list selection to visible index `i` by driving the engine.
965    async fn select_index(p: &mut SourcesPanel, i: usize) {
966        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
967        for _ in 0..i {
968            p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
969        }
970    }
971
972    fn selected_heading(p: &SourcesPanel) -> Option<String> {
973        p.selected_source().map(|s| s.heading.clone())
974    }
975
976    /// Heading of the source at rank position `i` (0-based) in the engine's row
977    /// set — the panel keeps no parallel sources copy.
978    fn nth_heading(p: &SourcesPanel, i: usize) -> Option<String> {
979        p.source_at(i).map(|s| s.heading.clone())
980    }
981
982    #[test]
983    fn score_percent_rounds_and_clamps() {
984        assert_eq!(score_percent(0.874), 87);
985        assert_eq!(score_percent(1.5), 100);
986        assert_eq!(score_percent(-0.2), 0);
987    }
988
989    #[test]
990    fn dated_source_display_heading_separates_date_and_heading() {
991        let s = dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9);
992        assert_eq!(s.display_heading(), "2026-04-08 \u{b7} Afternoon");
993        assert_eq!(source("n.md", "Ideas", 0.5, "").display_heading(), "Ideas");
994    }
995
996    #[tokio::test]
997    async fn new_panel_starts_empty_and_collapsed() {
998        let p = test_panel().await;
999        assert_eq!(p.match_count(), 0);
1000        assert!(p.preview.is_collapsed());
1001    }
1002
1003    #[tokio::test]
1004    async fn set_turn_populates_and_collapses() {
1005        let mut p = test_panel().await;
1006        p.set_turn(1, vec![source("a.md", "A", 0.9, "text a")], &noop_tx());
1007        p.settle().await;
1008        assert_eq!(p.turn_id, Some(1));
1009        assert_eq!(p.match_count(), 1, "the engine mirrors the turn's rows");
1010        assert!(p.preview.is_collapsed());
1011    }
1012
1013    #[tokio::test]
1014    async fn set_turn_same_id_is_a_noop_and_keeps_selection() {
1015        let mut p = test_panel().await;
1016        two_source_panel(&mut p).await;
1017        select_index(&mut p, 1).await;
1018        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1019        p.set_turn(1, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1020        p.settle().await;
1021        assert_eq!(
1022            selected_heading(&p).as_deref(),
1023            Some("B"),
1024            "selection must survive a same-id set_turn"
1025        );
1026        assert_eq!(p.match_count(), 2, "rows must not be replaced");
1027        assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1028    }
1029
1030    #[tokio::test]
1031    async fn set_turn_new_id_resets_selection_and_collapses() {
1032        let mut p = test_panel().await;
1033        two_source_panel(&mut p).await;
1034        select_index(&mut p, 1).await;
1035        p.preview.toggle(Some(VaultPath::new("a.md")));
1036        p.set_turn(2, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1037        p.settle().await;
1038        assert_eq!(selected_heading(&p).as_deref(), Some("C"));
1039        assert_eq!(p.match_count(), 1);
1040        assert!(p.preview.is_collapsed());
1041    }
1042
1043    #[tokio::test]
1044    async fn focus_source_points_selection_by_ordinal_through_the_engine() {
1045        let mut p = test_panel().await;
1046        let mut a = source("a.md", "A", 0.9, "a");
1047        a.ordinal = 3;
1048        let mut b = source("b.md", "B", 0.5, "b");
1049        b.ordinal = 7;
1050        p.set_turn(1, vec![a, b], &noop_tx());
1051        p.settle().await;
1052        p.preview.toggle(Some(VaultPath::new("a.md")));
1053        p.focus_source(7);
1054        assert_eq!(
1055            p.selected_source().map(|s| s.ordinal),
1056            Some(7),
1057            "resolved ordinal 7 to its row through the engine, not ordinal-1"
1058        );
1059        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1060        assert!(p.preview.is_collapsed());
1061        // An unknown ordinal is ignored.
1062        p.focus_source(99);
1063        assert_eq!(p.selected_source().map(|s| s.ordinal), Some(7));
1064    }
1065
1066    /// `refresh` (the answer-completion path) applies the turn's rows
1067    /// synchronously — they are present the instant it returns, with ZERO prior
1068    /// interaction and no `poll`/`settle`. There is no async row load to wait
1069    /// on, so the drawer paints the freshly-ranked sources on the next frame
1070    /// without needing a Redraw wake.
1071    #[tokio::test]
1072    async fn refresh_applies_rows_synchronously_no_redraw_needed() {
1073        let mut p = test_panel().await;
1074        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1075        p.refresh(1, vec![source("a.md", "A", 0.9, "alpha body")], &tx);
1076        // No poll, no settle: the rows are live now.
1077        assert_eq!(
1078            p.match_count(),
1079            1,
1080            "refresh's rows are applied synchronously"
1081        );
1082        assert!(!p.list.is_loading(), "no async load is in flight");
1083        assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1084        // The synchronous path never fires the engine's redraw callback — the
1085        // render loop repaints on its own; no Redraw event is required.
1086        let mut redraws = 0;
1087        while let Ok(ev) = rx.try_recv() {
1088            if matches!(ev, AppEvent::Redraw) {
1089                redraws += 1;
1090            }
1091        }
1092        assert_eq!(redraws, 0, "no Redraw wake is needed for the sync row set");
1093    }
1094
1095    /// A cross-turn citation click runs `set_turn` (rebuild) then `focus_source`
1096    /// in the SAME tick. Because the rows are applied synchronously, the ordinal
1097    /// jump lands immediately — no deferral, no `settle` needed.
1098    #[tokio::test]
1099    async fn cross_turn_focus_source_applies_immediately() {
1100        let mut p = test_panel().await;
1101        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1102        let mut a = source("a.md", "A", 0.9, "a");
1103        a.ordinal = 3;
1104        let mut b = source("b.md", "B", 0.5, "b");
1105        b.ordinal = 7;
1106        // New turn + citation focus in the same tick: rows are present now.
1107        p.set_turn(2, vec![a, b], &tx);
1108        p.focus_source(7);
1109        assert_eq!(
1110            p.selected_source().map(|s| s.ordinal),
1111            Some(7),
1112            "citation focus applied in the same tick as set_turn"
1113        );
1114        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1115    }
1116
1117    /// Leader `a s` shape: `set_turn(new id)` then `open_reader(0)` in the SAME
1118    /// tick. The rows are synchronous, so the preview opens on the requested
1119    /// source on the FIRST press (the pre-fix bug needed two presses because the
1120    /// rows had not landed when `open_reader` read `source_at(0)`).
1121    #[tokio::test]
1122    async fn set_turn_then_open_reader_same_tick_opens_first_press() {
1123        let (_dir, vault) = test_vault().await;
1124        vault
1125            .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1126            .await
1127            .unwrap();
1128        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1129        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1130        // Fresh turn + open the top source in the same tick — no settle between.
1131        p.set_turn(9, vec![source("a.md", "ha", 0.9, "alpha text")], &tx);
1132        p.open_reader(0, &tx);
1133        assert!(
1134            p.preview.is_context(),
1135            "open_reader opens the preview on the first press"
1136        );
1137        assert_eq!(
1138            selected_heading(&p).as_deref(),
1139            Some("ha"),
1140            "the requested source is selected"
1141        );
1142        assert_eq!(
1143            p.loaded.as_ref().map(|l| l.path.clone()),
1144            Some(VaultPath::new("a.md")),
1145            "the note load is anchored to the opened source"
1146        );
1147    }
1148
1149    // ── New filter input (in-memory, heading/path text) ───────────────────
1150
1151    #[tokio::test]
1152    async fn filter_input_narrows_sources_by_heading_or_path_text() {
1153        let mut p = test_panel().await;
1154        p.set_turn(
1155            1,
1156            vec![
1157                source("alpha.md", "Alpha section", 0.9, "a"),
1158                source("beta.md", "Beta section", 0.5, "b"),
1159                source("gamma.md", "Gamma section", 0.3, "g"),
1160            ],
1161            &noop_tx(),
1162        );
1163        p.settle().await;
1164        assert_eq!(p.match_count(), 3, "no filter shows every source");
1165        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1166        // `i` reveals the filter input; typing filters by heading text.
1167        assert_eq!(p.list.focus(), Focus::List);
1168        p.handle_input(&InputEvent::Key(key(KeyCode::Char('i'))), &tx);
1169        assert_eq!(p.list.focus(), Focus::Input, "`i` reveals the filter input");
1170        for c in ['B', 'e', 't', 'a'] {
1171            p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1172        }
1173        p.settle().await;
1174        assert_eq!(p.match_count(), 1, "typed filter narrows to the match");
1175        assert_eq!(selected_heading(&p).as_deref(), Some("Beta section"));
1176    }
1177
1178    #[tokio::test]
1179    async fn slash_also_reveals_the_filter_and_matches_path_text() {
1180        let mut p = test_panel().await;
1181        p.set_turn(
1182            1,
1183            vec![
1184                source("notes/alpha.md", "One", 0.9, "a"),
1185                source("journal/beta.md", "Two", 0.5, "b"),
1186            ],
1187            &noop_tx(),
1188        );
1189        p.settle().await;
1190        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1191        p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &tx);
1192        assert_eq!(p.list.focus(), Focus::Input, "`/` reveals the filter input");
1193        for c in ['j', 'o', 'u', 'r'] {
1194            p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1195        }
1196        p.settle().await;
1197        assert_eq!(p.match_count(), 1, "path text filters too");
1198        assert_eq!(selected_heading(&p).as_deref(), Some("Two"));
1199    }
1200
1201    // ── Reveal cycle (Enter / l / h) ──────────────────────────────────────
1202
1203    #[tokio::test]
1204    async fn enter_and_l_cycle_forward_h_cycles_back() {
1205        let mut p = test_panel().await;
1206        two_source_panel(&mut p).await;
1207        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1208        assert!(p.preview.is_collapsed());
1209
1210        p.handle_input(&InputEvent::Key(key(KeyCode::Enter)), &tx);
1211        assert!(p.preview.is_context(), "Enter: Collapsed -> Context");
1212        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1213        assert!(p.preview.is_full(), "l: Context -> Full");
1214        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1215        assert!(p.preview.is_collapsed(), "l: Full -> Collapsed (wraps)");
1216
1217        // Back cycle with h stops at Collapsed.
1218        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx); // -> Context
1219        p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx); // -> Full
1220        assert!(p.preview.is_full());
1221        p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1222        assert!(p.preview.is_context(), "h: Full -> Context");
1223        p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1224        assert!(p.preview.is_collapsed(), "h: Context -> Collapsed");
1225        p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1226        assert!(p.preview.is_collapsed(), "h at Collapsed stays Collapsed");
1227    }
1228
1229    #[tokio::test]
1230    async fn esc_steps_back_then_bubbles_to_thread() {
1231        let mut p = test_panel().await;
1232        two_source_panel(&mut p).await;
1233        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1234        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1235
1236        let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1237        assert_eq!(st, EventState::Consumed);
1238        assert!(p.preview.is_collapsed(), "Esc steps back one reveal state");
1239
1240        // From Collapsed (list focus), Esc bubbles so the host returns focus to
1241        // the thread.
1242        let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1243        assert_eq!(
1244            st,
1245            EventState::NotConsumed,
1246            "Collapsed Esc -> back to thread"
1247        );
1248    }
1249
1250    #[tokio::test]
1251    async fn jk_moves_selection_within_bounds() {
1252        let mut p = test_panel().await;
1253        two_source_panel(&mut p).await;
1254        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1255
1256        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1257        assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1258        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1259        assert_eq!(
1260            selected_heading(&p).as_deref(),
1261            Some("B"),
1262            "clamped at the last row"
1263        );
1264        p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1265        assert_eq!(selected_heading(&p).as_deref(), Some("A"));
1266        p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1267        assert_eq!(
1268            selected_heading(&p).as_deref(),
1269            Some("A"),
1270            "clamped at the first row"
1271        );
1272    }
1273
1274    // ── Open (o / FollowLink) — from any reveal state ─────────────────────
1275
1276    async fn assert_opens_selected(setup: impl Fn(&mut SourcesPanel), open: KeyEvent) {
1277        let mut p = test_panel().await;
1278        two_source_panel(&mut p).await;
1279        select_index(&mut p, 1).await;
1280        setup(&mut p);
1281        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1282        let st = p.handle_input(&InputEvent::Key(open), &tx);
1283        assert_eq!(st, EventState::Consumed);
1284        let mut opened = None;
1285        while let Ok(ev) = rx.try_recv() {
1286            if let AppEvent::OpenPath { path, .. } = ev {
1287                opened = Some(path);
1288            }
1289        }
1290        assert_eq!(
1291            opened,
1292            Some(VaultPath::new("b.md")),
1293            "opened the selected source"
1294        );
1295    }
1296
1297    #[tokio::test]
1298    async fn o_opens_selected_from_every_reveal_state() {
1299        // Collapsed, Context, Full — `o` opens the selected source each time.
1300        assert_opens_selected(|_p| {}, key(KeyCode::Char('o'))).await;
1301        assert_opens_selected(
1302            |p| p.preview.toggle(Some(VaultPath::new("b.md"))),
1303            key(KeyCode::Char('o')),
1304        )
1305        .await;
1306        assert_opens_selected(
1307            |p| {
1308                p.preview.toggle(Some(VaultPath::new("b.md")));
1309                p.preview.toggle(Some(VaultPath::new("b.md")));
1310            },
1311            key(KeyCode::Char('o')),
1312        )
1313        .await;
1314    }
1315
1316    #[tokio::test]
1317    async fn followlink_ctrl_n_opens_selected() {
1318        assert_opens_selected(|_p| {}, ctrl(KeyCode::Char('n'))).await;
1319        // Also from Full.
1320        assert_opens_selected(
1321            |p| {
1322                p.preview.toggle(Some(VaultPath::new("b.md")));
1323                p.preview.toggle(Some(VaultPath::new("b.md")));
1324            },
1325            ctrl(KeyCode::Char('n')),
1326        )
1327        .await;
1328    }
1329
1330    // ── Yank (y / Ctrl+Y) ─────────────────────────────────────────────────
1331
1332    async fn assert_yanks(k: KeyEvent) {
1333        let mut p = test_panel().await;
1334        two_source_panel(&mut p).await;
1335        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1336        let st = p.handle_input(&InputEvent::Key(k), &tx);
1337        assert_eq!(st, EventState::Consumed);
1338        let mut flashed = false;
1339        while let Ok(ev) = rx.try_recv() {
1340            if matches!(ev, AppEvent::FlashMessage(_)) {
1341                flashed = true;
1342            }
1343        }
1344        assert!(
1345            flashed,
1346            "yank emits a flash message (ok or clipboard error)"
1347        );
1348    }
1349
1350    #[tokio::test]
1351    async fn plain_y_and_ctrl_y_both_yank() {
1352        assert_yanks(key(KeyCode::Char('y'))).await;
1353        assert_yanks(ctrl(KeyCode::Char('y'))).await;
1354    }
1355
1356    // ── Async note load + stale-drop ──────────────────────────────────────
1357
1358    #[tokio::test]
1359    async fn reader_note_for_the_wrong_path_is_dropped() {
1360        let mut p = test_panel().await;
1361        p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1362        p.loaded = Some(LoadedNote {
1363            path: VaultPath::new("a.md"),
1364            ordinal: 0,
1365            content: ReaderContent::Loading,
1366        });
1367        p.handle_data(AskData::ReaderNote {
1368            path: VaultPath::new("other.md"),
1369            text: Some("nope".to_string()),
1370        });
1371        assert!(
1372            matches!(p.loaded.as_ref().unwrap().content, ReaderContent::Loading),
1373            "wrong-path ReaderNote must be dropped, not accepted"
1374        );
1375    }
1376
1377    #[tokio::test]
1378    async fn reader_note_for_the_right_path_loads_and_highlights() {
1379        let mut p = test_panel().await;
1380        p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1381        p.settle().await;
1382        p.loaded = Some(LoadedNote {
1383            path: VaultPath::new("a.md"),
1384            ordinal: 0,
1385            content: ReaderContent::Loading,
1386        });
1387        p.handle_data(AskData::ReaderNote {
1388            path: VaultPath::new("a.md"),
1389            text: Some("# a\nalpha body\n# b\nbeta body\n".to_string()),
1390        });
1391        match &p.loaded.as_ref().unwrap().content {
1392            ReaderContent::Loaded { text, highlight } => {
1393                let r = highlight.clone().expect("chunk resolves");
1394                assert_eq!(&text[r], "beta body");
1395            }
1396            _ => panic!("expected Loaded"),
1397        }
1398    }
1399
1400    #[tokio::test]
1401    async fn reader_note_load_failure_is_recorded() {
1402        let mut p = test_panel().await;
1403        p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1404        p.loaded = Some(LoadedNote {
1405            path: VaultPath::new("a.md"),
1406            ordinal: 0,
1407            content: ReaderContent::Loading,
1408        });
1409        p.handle_data(AskData::ReaderNote {
1410            path: VaultPath::new("a.md"),
1411            text: None,
1412        });
1413        assert!(matches!(
1414            p.loaded.as_ref().unwrap().content,
1415            ReaderContent::Failed
1416        ));
1417    }
1418
1419    #[tokio::test]
1420    async fn handle_data_ignores_answer_ready() {
1421        let mut p = test_panel().await;
1422        p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1423        p.loaded = Some(LoadedNote {
1424            path: VaultPath::new("a.md"),
1425            ordinal: 0,
1426            content: ReaderContent::Loading,
1427        });
1428        p.handle_data(AskData::AnswerReady {
1429            turn_id: 1,
1430            result: Ok(("x".into(), vec![])),
1431        });
1432        assert!(matches!(
1433            p.loaded.as_ref().unwrap().content,
1434            ReaderContent::Loading
1435        ));
1436    }
1437
1438    #[tokio::test]
1439    async fn open_reader_opens_preview_and_round_trips_a_real_vault() {
1440        let (_dir, vault) = test_vault().await;
1441        let path = VaultPath::new("note.md");
1442        vault.create_note(&path, "# h\nbody text\n").await.unwrap();
1443
1444        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1445        p.set_turn(
1446            1,
1447            vec![source("note.md", "h", 0.9, "body text")],
1448            &noop_tx(),
1449        );
1450        p.settle().await;
1451        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1452        p.open_reader(0, &tx);
1453        assert!(
1454            p.preview.is_context(),
1455            "open_reader opens the Context preview"
1456        );
1457
1458        let event = rx.recv().await.expect("open_reader spawns a ReaderNote");
1459        let AppEvent::Ask(data) = event else {
1460            panic!("expected an Ask event");
1461        };
1462        p.handle_data(data);
1463        match &p.loaded.as_ref().unwrap().content {
1464            ReaderContent::Loaded { text, .. } => assert_eq!(text, "# h\nbody text\n"),
1465            _ => panic!("expected Loaded"),
1466        }
1467    }
1468
1469    #[tokio::test]
1470    async fn navigating_in_context_reloads_for_the_new_source() {
1471        let (_dir, vault) = test_vault().await;
1472        std::mem::forget(_dir);
1473        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1474        two_source_panel(&mut p).await;
1475        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1476        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1477        p.ensure_loaded(&tx);
1478        assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("a.md"));
1479        // Move down while the preview is open: the load re-keys to b.md, so a
1480        // late a.md ReaderNote would now be dropped.
1481        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1482        assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("b.md"));
1483    }
1484
1485    // ── Rendering ─────────────────────────────────────────────────────────
1486
1487    fn buffer_text(p: &mut SourcesPanel, w: u16, h: u16) -> String {
1488        let theme = Theme::default();
1489        let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
1490        term.draw(|f| {
1491            let area = f.area();
1492            p.render(f, area, &theme, true);
1493        })
1494        .unwrap();
1495        let buf = term.backend().buffer().clone();
1496        (0..buf.area.height)
1497            .map(|y| {
1498                (0..buf.area.width)
1499                    .map(|x| buf[(x, y)].symbol())
1500                    .collect::<String>()
1501            })
1502            .collect::<Vec<_>>()
1503            .join("\n")
1504    }
1505
1506    #[tokio::test]
1507    async fn row_render_carries_rank_and_score() {
1508        let mut p = test_panel().await;
1509        p.set_turn(
1510            1,
1511            vec![
1512                dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1513                source("b.md", "Beta section", 0.42, "beta body"),
1514            ],
1515            &noop_tx(),
1516        );
1517        p.settle().await;
1518        // Height grown to fit the always-visible filter box (Length(3)) above
1519        // the list plus both two-line rows.
1520        let text = buffer_text(&mut p, 60, 11);
1521        assert!(text.contains("1 "), "rank 1 leads the first row: {text}");
1522        assert!(text.contains("2 "), "rank 2 leads the second row: {text}");
1523        assert!(text.contains("90%"), "score percent shown: {text}");
1524        assert!(text.contains("42%"), "second score shown: {text}");
1525        assert!(text.contains("2026-04-08"), "date kept: {text}");
1526        assert!(
1527            text.contains('\u{b7}'),
1528            "date \u{b7} heading separation: {text}"
1529        );
1530        assert!(text.contains("Afternoon"), "heading kept: {text}");
1531    }
1532
1533    /// Converged with FIND (query_panel.rs): the filter field is a bordered
1534    /// box titled "filter", always visible — even before the user leaves list
1535    /// focus for the input — not a bare text line that only appears once `/`
1536    /// or `i` is pressed.
1537    #[tokio::test]
1538    async fn filter_box_is_bordered_and_always_visible() {
1539        let mut p = test_panel().await;
1540        p.set_turn(
1541            1,
1542            vec![source("a.md", "Alpha", 0.9, "alpha body")],
1543            &noop_tx(),
1544        );
1545        p.settle().await;
1546
1547        // Sources opens on the list (CONTEXT.md "List focus"), but the filter
1548        // box must already be on screen — the pre-convergence behavior only
1549        // rendered it once focus moved to Input.
1550        assert_eq!(p.list.focus(), Focus::List, "Sources opens on the list");
1551        let text = buffer_text(&mut p, 40, 10);
1552        assert!(
1553            text.contains("filter"),
1554            "filter box shows in list focus, before `/`/`i`: {text}"
1555        );
1556        assert!(
1557            text.contains('\u{250c}') || text.contains('\u{2500}'),
1558            "filter field is boxed (bordered), not a bare line: {text}"
1559        );
1560
1561        // Same boxed chrome once the user reveals the input — no layout
1562        // change, just the (already-existing) focused/unfocused input style.
1563        p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &noop_tx());
1564        assert_eq!(p.list.focus(), Focus::Input);
1565        let text = buffer_text(&mut p, 40, 10);
1566        assert!(
1567            text.contains("filter"),
1568            "filter box stays visible in input focus: {text}"
1569        );
1570    }
1571
1572    /// F3: a filter that matches nothing must render FIND's "No results"
1573    /// message, not a silent blank — the row set is non-empty, only the visible
1574    /// (filtered) set is empty.
1575    #[tokio::test]
1576    async fn zero_match_filter_shows_no_results() {
1577        let mut p = test_panel().await;
1578        p.set_turn(1, vec![source("a.md", "Alpha", 0.9, "body")], &noop_tx());
1579        p.settle().await;
1580        p.list.set_query("zzznomatch");
1581        assert_eq!(p.list.visible_rows().len(), 0, "filter narrows to nothing");
1582        let text = buffer_text(&mut p, 40, 10);
1583        assert!(
1584            text.contains("No results"),
1585            "zero-match filter shows the No results message: {text}"
1586        );
1587    }
1588
1589    /// F2: the Context list pane is sized by the VISIBLE (filtered) count, so a
1590    /// narrowing filter shrinks the list and the preview gets the reclaimed
1591    /// space (more of the note is shown).
1592    #[tokio::test]
1593    async fn context_list_pane_shrinks_when_filter_narrows() {
1594        let mut p = test_panel().await;
1595        let srcs: Vec<_> = (0..10)
1596            .map(|i| source(&format!("n{i}.md"), &format!("Alpha{i}"), 0.9, "body"))
1597            .collect();
1598        p.set_turn(1, srcs, &noop_tx());
1599        p.settle().await;
1600        // Open Context on the first source with a long note and NO highlight, so
1601        // the preview renders from the top and a taller pane shows more lines.
1602        p.preview.toggle(Some(VaultPath::new("n0.md")));
1603        let mut text = String::new();
1604        for i in 0..40 {
1605            text.push_str(&format!("noteline{i}\n"));
1606        }
1607        p.loaded = Some(LoadedNote {
1608            path: VaultPath::new("n0.md"),
1609            ordinal: 0,
1610            content: ReaderContent::Loaded {
1611                text,
1612                highlight: None,
1613            },
1614        });
1615        let count_lines = |p: &mut SourcesPanel| buffer_text(p, 40, 20).matches("noteline").count();
1616        let before = count_lines(&mut p);
1617        // Narrow to a single source: the list pane shrinks, the preview grows.
1618        p.list.set_query("Alpha3");
1619        assert_eq!(p.list.visible_rows().len(), 1, "filter narrows to one");
1620        let after = count_lines(&mut p);
1621        assert!(
1622            after > before,
1623            "preview gained the space the shrunken list gave up: before={before} after={after}"
1624        );
1625    }
1626
1627    #[tokio::test]
1628    async fn render_does_not_panic_across_states_and_sizes() {
1629        let mut p = test_panel().await;
1630        buffer_text(&mut p, 40, 10); // empty list
1631
1632        p.set_turn(
1633            1,
1634            vec![
1635                dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1636                source("b.md", "Beta section", 0.4, "beta body"),
1637            ],
1638            &noop_tx(),
1639        );
1640        p.settle().await;
1641        buffer_text(&mut p, 40, 10); // collapsed list
1642        select_index(&mut p, 1).await;
1643        buffer_text(&mut p, 40, 3); // tiny viewport
1644
1645        // Context with a loaded note.
1646        p.preview.toggle(Some(VaultPath::new("b.md")));
1647        p.loaded = Some(LoadedNote {
1648            path: VaultPath::new("b.md"),
1649            ordinal: 0,
1650            content: ReaderContent::Loaded {
1651                text: "# Beta\nbeta body\nmore\n".to_string(),
1652                highlight: Some(7..16),
1653            },
1654        });
1655        buffer_text(&mut p, 40, 12); // context + preview
1656        p.preview.toggle(Some(VaultPath::new("b.md"))); // -> Full
1657        buffer_text(&mut p, 40, 12); // full preview
1658
1659        // Loading / Failed placeholders.
1660        p.loaded = Some(LoadedNote {
1661            path: VaultPath::new("b.md"),
1662            ordinal: 0,
1663            content: ReaderContent::Loading,
1664        });
1665        buffer_text(&mut p, 40, 12);
1666        p.loaded = Some(LoadedNote {
1667            path: VaultPath::new("b.md"),
1668            ordinal: 0,
1669            content: ReaderContent::Failed,
1670        });
1671        buffer_text(&mut p, 40, 12);
1672
1673        buffer_text(&mut p, 3, 3); // degenerate
1674        buffer_text(&mut p, 0, 0); // zero rect
1675    }
1676
1677    #[tokio::test]
1678    async fn full_preview_anchors_scroll_to_the_highlighted_section() {
1679        let mut p = test_panel().await;
1680        p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1681        p.settle().await;
1682        // Open to Full and load a note where the section is several lines down.
1683        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1684        p.preview.toggle(Some(VaultPath::new("a.md"))); // Full
1685        // Section is deep enough that anchoring scrolls past the top (the "two
1686        // lines of context above the section" rule needs room above it).
1687        let mut body = String::new();
1688        for i in 0..8 {
1689            body.push_str(&format!("line{i}\n"));
1690        }
1691        body.push_str("beta body\n");
1692        for i in 0..8 {
1693            body.push_str(&format!("tail{i}\n"));
1694        }
1695        let start = body.find("beta body").unwrap();
1696        p.loaded = Some(LoadedNote {
1697            path: VaultPath::new("a.md"),
1698            ordinal: 0,
1699            content: ReaderContent::Loaded {
1700                text: body,
1701                highlight: Some(start..start + "beta body".len()),
1702            },
1703        });
1704        // Full mode: title(1) + divider(1) + content; a short content viewport so
1705        // the section (line 2) is scrollable into view.
1706        buffer_text(&mut p, 40, 6);
1707        assert!(
1708            p.preview.scroll_offset() > 0,
1709            "preview anchored the scroll to the section, offset={}",
1710            p.preview.scroll_offset()
1711        );
1712    }
1713
1714    // ── Full-preview content scroll (F1) ──────────────────────────────────
1715
1716    #[tokio::test]
1717    async fn full_down_scrolls_content_not_the_list() {
1718        let mut p = test_panel().await;
1719        two_source_panel(&mut p).await;
1720        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1721        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1722        p.preview.toggle(Some(VaultPath::new("a.md"))); // Full
1723        // A note taller than the viewport with the section at the very top, so
1724        // the anchor sits at offset 0 with room to scroll down.
1725        let mut body = String::from("alpha body\n");
1726        for i in 0..20 {
1727            body.push_str(&format!("line{i}\n"));
1728        }
1729        p.loaded = Some(LoadedNote {
1730            path: VaultPath::new("a.md"),
1731            ordinal: 0,
1732            content: ReaderContent::Loaded {
1733                text: body,
1734                highlight: Some(0.."alpha body".len()),
1735            },
1736        });
1737        buffer_text(&mut p, 40, 6); // render sets max; anchor at the top
1738        assert_eq!(p.preview.scroll_offset(), 0);
1739        // Down scrolls the preview content; the list selection stays put.
1740        p.handle_input(&InputEvent::Key(key(KeyCode::Down)), &tx);
1741        assert_eq!(
1742            selected_heading(&p).as_deref(),
1743            Some("A"),
1744            "Down in Full scrolls content, not the list"
1745        );
1746        assert!(
1747            p.preview.scroll_offset() > 0,
1748            "Full + Down scrolled the content, offset={}",
1749            p.preview.scroll_offset()
1750        );
1751    }
1752
1753    #[tokio::test]
1754    async fn full_j_still_moves_the_list_selection() {
1755        let mut p = test_panel().await;
1756        two_source_panel(&mut p).await;
1757        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1758        p.preview.toggle(Some(VaultPath::new("a.md"))); // Context
1759        p.preview.toggle(Some(VaultPath::new("a.md"))); // Full
1760        assert!(p.preview.is_full());
1761        // `j` is left for list navigation even under the full preview.
1762        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1763        assert_eq!(
1764            selected_heading(&p).as_deref(),
1765            Some("B"),
1766            "j moves the list selection in Full"
1767        );
1768    }
1769
1770    #[tokio::test]
1771    async fn wheel_scrolls_the_open_preview_and_is_ignored_when_collapsed() {
1772        let mut p = test_panel().await;
1773        two_source_panel(&mut p).await;
1774        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1775        // Collapsed with no list rect recorded yet: the wheel misses everything
1776        // and is left unconsumed for the host.
1777        let wheel = |kind| {
1778            InputEvent::Mouse(MouseEvent {
1779                kind,
1780                column: 0,
1781                row: 0,
1782                modifiers: KeyModifiers::NONE,
1783            })
1784        };
1785        assert_eq!(
1786            p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1787            EventState::NotConsumed,
1788            "collapsed preview with no recorded rect does not eat the wheel"
1789        );
1790        // Open to Full with scrollable content.
1791        p.preview.toggle(Some(VaultPath::new("a.md")));
1792        p.preview.toggle(Some(VaultPath::new("a.md")));
1793        let mut body = String::from("alpha body\n");
1794        for i in 0..20 {
1795            body.push_str(&format!("line{i}\n"));
1796        }
1797        p.loaded = Some(LoadedNote {
1798            path: VaultPath::new("a.md"),
1799            ordinal: 0,
1800            content: ReaderContent::Loaded {
1801                text: body,
1802                highlight: Some(0.."alpha body".len()),
1803            },
1804        });
1805        buffer_text(&mut p, 40, 6);
1806        assert_eq!(
1807            p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1808            EventState::Consumed,
1809            "open preview consumes the wheel"
1810        );
1811        assert!(p.preview.scroll_offset() > 0, "wheel scrolled the content");
1812    }
1813
1814    // ── Same-note, different-section re-anchor (F2) ───────────────────────
1815
1816    #[tokio::test]
1817    async fn same_note_different_heading_recomputes_highlight_without_reload() {
1818        let (_dir, vault) = test_vault().await;
1819        std::mem::forget(_dir);
1820        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1821        // Two sources in the SAME note, different sections (distinct ordinals).
1822        let mut s0 = source("doc.md", "Alpha", 0.9, "alpha body");
1823        s0.ordinal = 1;
1824        let mut s1 = source("doc.md", "Beta", 0.8, "beta body");
1825        s1.ordinal = 2;
1826        p.set_turn(1, vec![s0, s1], &noop_tx());
1827        p.settle().await;
1828        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1829        p.preview.toggle(Some(VaultPath::new("doc.md"))); // Context
1830        p.ensure_loaded(&tx); // spawns a load for doc.md, ordinal 1
1831        // Deliver the note text (simulating the load landing).
1832        let note = "# Alpha\nalpha body\n# Beta\nbeta body\n".to_string();
1833        p.handle_data(AskData::ReaderNote {
1834            path: VaultPath::new("doc.md"),
1835            text: Some(note),
1836        });
1837        let first = match &p.loaded.as_ref().unwrap().content {
1838            ReaderContent::Loaded { text, highlight } => {
1839                let r = highlight.clone().expect("section resolves");
1840                assert_eq!(&text[r.clone()], "alpha body");
1841                r
1842            }
1843            _ => panic!("expected Loaded"),
1844        };
1845        // Move to the second source (same note): the highlight re-resolves to
1846        // the new section and the loaded note is REUSED (no drop to Loading).
1847        p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1848        match &p.loaded.as_ref().unwrap().content {
1849            ReaderContent::Loaded { text, highlight } => {
1850                let r = highlight.clone().expect("re-resolved");
1851                assert_eq!(&text[r.clone()], "beta body");
1852                assert_ne!(r, first, "highlight moved to the new section");
1853            }
1854            _ => panic!("must reuse the loaded note, not reload"),
1855        }
1856        assert_eq!(
1857            p.loaded.as_ref().unwrap().ordinal,
1858            2,
1859            "re-keyed to the new source"
1860        );
1861    }
1862
1863    // ── open_reader keeps the reveal (F5) ─────────────────────────────────
1864
1865    #[tokio::test]
1866    async fn open_reader_stays_full_and_re_points_to_the_source() {
1867        let (_dir, vault) = test_vault().await;
1868        vault
1869            .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1870            .await
1871            .unwrap();
1872        vault
1873            .create_note(&VaultPath::new("b.md"), "# hb\nbeta text\n")
1874            .await
1875            .unwrap();
1876        let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1877        let mut s0 = source("a.md", "ha", 0.9, "alpha text");
1878        s0.ordinal = 1;
1879        let mut s1 = source("b.md", "hb", 0.8, "beta text");
1880        s1.ordinal = 2;
1881        p.set_turn(1, vec![s0, s1], &noop_tx());
1882        p.settle().await;
1883        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1884        // Reveal source 1 in Full.
1885        select_index(&mut p, 1).await;
1886        p.preview.toggle(Some(VaultPath::new("b.md"))); // Context
1887        p.preview.toggle(Some(VaultPath::new("b.md"))); // Full
1888        assert!(p.preview.is_full());
1889        // Directed reveal of source 0 must STAY Full (not collapse) and re-point.
1890        p.open_reader(0, &tx);
1891        assert!(p.preview.is_full(), "open_reader keeps the Full reveal");
1892        assert_eq!(selected_heading(&p).as_deref(), Some("ha"));
1893        // It spawned a load for source 0's note; deliver it and check the section.
1894        let ev = rx.recv().await.expect("open_reader spawns a ReaderNote");
1895        let AppEvent::Ask(data) = ev else {
1896            panic!("expected an Ask event");
1897        };
1898        p.handle_data(data);
1899        match &p.loaded.as_ref().unwrap().content {
1900            ReaderContent::Loaded { text, highlight } => {
1901                assert_eq!(text, "# ha\nalpha text\n", "source 0's note is shown");
1902                let r = highlight.clone().expect("section resolves");
1903                assert_eq!(&text[r], "alpha text");
1904            }
1905            _ => panic!("expected Loaded for source 0"),
1906        }
1907    }
1908}