1use 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
55const PAGE_OVERLAP: u16 = 2;
58
59enum ReaderContent {
61 Loading,
63 Loaded {
66 text: String,
67 highlight: Option<Range<usize>>,
68 },
69 Failed,
71}
72
73struct LoadedNote {
79 path: VaultPath,
80 ordinal: usize,
83 content: ReaderContent,
84}
85
86#[derive(Clone)]
91struct SourceRow {
92 rank: usize,
93 source: AskSource,
94 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 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
131pub struct SourcesPanel {
135 turn_id: Option<u64>,
136 list: SearchList<SourceRow>,
140 preview: PreviewPane,
144 loaded: Option<LoadedNote>,
147 vault: Arc<NoteVault>,
151 icons: Icons,
152 intercept: Vec<KeyCombo>,
155 yank_combos: Vec<KeyCombo>,
158 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 let intercept = follow;
175 let icons = Icons::new(false);
176 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 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 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 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 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 fn has_sources(&self) -> bool {
258 !self.list.rows().is_empty()
259 }
260
261 fn source_at(&self, index: usize) -> Option<&AskSource> {
264 self.list.rows().get(index).map(|r| &r.source)
265 }
266
267 pub fn focus_source(&mut self, ordinal: usize) {
275 self.list.set_query(""); self.preview.reset();
277 self.loaded = None;
278 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 pub fn open_reader(&mut self, source_index: usize, tx: &AppTx) {
299 self.list.set_query("");
300 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); } else {
311 self.preview.repoint(sel); }
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 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 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 InputEvent::Mouse(mouse) => return self.handle_mouse(mouse, tx),
391 _ => return EventState::NotConsumed,
392 };
393
394 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 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 KeyReaction::Intercepted(_) => {
437 self.open_selected(tx);
438 EventState::Consumed
439 }
440 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 KeyReaction::ListVerb(c) => {
450 match c {
451 'l' if self.has_sources() => {
452 self.preview.toggle(self.selected_path());
453 self.ensure_loaded(tx);
454 }
455 'h' => self.preview.collapse_step(self.selected_path()),
456 'o' => self.open_selected(tx),
457 'y' => self.yank_selected_path(tx),
458 _ => {}
459 }
460 EventState::Consumed
461 }
462 KeyReaction::Consumed => {
466 self.sync_preview();
467 self.ensure_loaded(tx);
468 EventState::Consumed
469 }
470 KeyReaction::Yank(target) => {
471 crate::components::yank_row(target, tx);
472 EventState::Consumed
473 }
474 KeyReaction::Cancel | KeyReaction::Unhandled => EventState::NotConsumed,
477 }
478 }
479
480 fn handle_mouse(&mut self, mouse: &MouseEvent, tx: &AppTx) -> EventState {
485 let was_full = self.preview.is_full();
486 self.sync_preview();
487 if was_full {
491 match mouse.kind {
492 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
496 MouseEventKind::Down(MouseButton::Left)
497 if self.list.input_contains(mouse.column, mouse.row)
498 || self.list.popup_contains(mouse.column, mouse.row) => {}
499 MouseEventKind::Down(MouseButton::Left)
500 if self.preview.full_header_rect().contains(Position {
501 x: mouse.column,
502 y: mouse.row,
503 }) =>
504 {
505 self.preview.toggle(self.selected_path());
506 return EventState::Consumed;
507 }
508 _ => return EventState::Consumed,
509 }
510 }
511 match self.list.handle_mouse(mouse) {
512 SearchMouse::ContentScrollUp => {
513 self.preview.scroll_up();
514 EventState::Consumed
515 }
516 SearchMouse::ContentScrollDown => {
517 self.preview.scroll_down();
518 EventState::Consumed
519 }
520 SearchMouse::DoubleClicked { .. } => {
523 self.open_selected(tx);
524 EventState::Consumed
525 }
526 SearchMouse::Activated(_) => {
527 self.preview.toggle(self.selected_path());
528 self.ensure_loaded(tx);
529 EventState::Consumed
530 }
531 SearchMouse::Autocomplete { edited: true } => {
534 self.sync_preview();
535 self.ensure_loaded(tx);
536 EventState::Consumed
537 }
538 SearchMouse::InputFocused | SearchMouse::Autocomplete { edited: false } => {
539 EventState::Consumed
540 }
541 SearchMouse::Selected(_) | SearchMouse::Scrolled | SearchMouse::Context(_) => {
542 self.sync_preview();
543 self.ensure_loaded(tx);
544 EventState::Consumed
545 }
546 SearchMouse::None => EventState::NotConsumed,
547 }
548 }
549
550 fn scroll_preview_page(&mut self, up: bool) {
554 let page = self.preview_page.saturating_sub(PAGE_OVERLAP).max(1);
555 for _ in 0..page {
556 if up {
557 self.preview.scroll_up();
558 } else {
559 self.preview.scroll_down();
560 }
561 }
562 }
563
564 fn selected_source(&self) -> Option<&AskSource> {
567 self.list.selected_row().map(|r| &r.source)
568 }
569
570 fn selected_path(&self) -> Option<VaultPath> {
572 self.selected_source().map(|s| s.path.clone())
573 }
574
575 fn sync_preview(&mut self) {
578 let sel = self.selected_path();
579 self.preview.sync(sel);
580 }
581
582 fn ensure_loaded(&mut self, tx: &AppTx) {
586 if self.preview.is_collapsed() {
587 return;
588 }
589 let Some(source) = self.selected_source() else {
590 return;
591 };
592 let path = source.path.clone();
593 let ordinal = source.ordinal;
594 let heading = source.match_heading().to_string();
595 let chunk = source.text.clone();
596 self.ensure_note_load(path, ordinal, heading, chunk, tx);
597 }
598
599 fn ensure_note_load(
609 &mut self,
610 path: VaultPath,
611 ordinal: usize,
612 heading: String,
613 chunk: String,
614 tx: &AppTx,
615 ) {
616 match &self.loaded {
617 Some(l) if l.path == path && l.ordinal == ordinal => return,
618 Some(l) if l.path == path => {
619 if let Some(l) = &mut self.loaded {
622 l.ordinal = ordinal;
623 if let ReaderContent::Loaded { text, highlight } = &mut l.content {
624 *highlight = locate::section_range(text, &heading, &chunk);
625 }
626 }
627 self.preview.re_anchor();
628 return;
629 }
630 _ => {}
631 }
632
633 self.loaded = Some(LoadedNote {
634 path: path.clone(),
635 ordinal,
636 content: ReaderContent::Loading,
637 });
638 let vault = self.vault.clone();
639 let tx = tx.clone();
640 tokio::spawn(async move {
641 let text = vault.get_note_text(&path).await.ok();
642 let _ = tx.send(AppEvent::Ask(AskData::ReaderNote { path, text }));
643 });
644 }
645
646 fn open_selected(&self, tx: &AppTx) {
649 if let Some(source) = self.selected_source() {
650 tx.send(AppEvent::open(source.path.clone())).ok();
651 }
652 }
653
654 fn yank_selected_path(&self, tx: &AppTx) {
658 let Some(source) = self.selected_source() else {
659 return;
660 };
661 crate::components::yank(source.path.to_string(), "path copied", tx);
662 }
663
664 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
665 self.list.poll();
666 self.sync_preview();
669 self.list.set_panel_rect(rect);
672 self.list.set_content_rect(Rect::default());
673 self.preview.clear_header();
674
675 let block = panel_block("Sources", theme, focused);
676 let inner = block.inner(rect);
677 f.render_widget(block, rect);
678
679 if self.list.rows().is_empty() {
683 let style = Style::default().fg(theme.gray.to_ratatui());
684 f.render_widget(
685 Paragraph::new("no sources — ask something").style(style),
686 inner,
687 );
688 return;
689 }
690
691 let rows = Layout::default()
699 .direction(Direction::Vertical)
700 .constraints([Constraint::Length(3), Constraint::Min(0)])
701 .split(inner);
702 let filter_block = Block::default()
703 .title(" filter ")
704 .borders(Borders::ALL)
705 .border_style(theme.border_style(focused))
706 .style(theme.panel_style());
707 let filter_inner = filter_block.inner(rows[0]);
708 f.render_widget(filter_block, rows[0]);
709 self.list.render_query(f, filter_inner, theme, focused);
710 let body = rows[1];
711
712 if self.list.visible_rows().is_empty() {
715 let gray = theme.gray.to_ratatui();
716 let bg = theme.bg_panel.to_ratatui();
717 f.render_widget(
718 Paragraph::new(" No results").style(Style::default().fg(gray).bg(bg)),
719 body,
720 );
721 return;
722 }
723
724 if self.preview.is_full() {
727 self.list.set_content_rect(rect);
728 self.render_preview(f, body, true, theme);
729 return;
730 }
731
732 if self.preview.is_context() {
734 let max_list = body.height / 2;
735 let visible = self.list.visible_rows().len();
740 let list_height = (visible as u16 * 2).min(max_list).max(1);
741 let areas = Layout::default()
742 .direction(Direction::Vertical)
743 .constraints([
744 Constraint::Length(list_height),
745 Constraint::Length(1),
746 Constraint::Min(0),
747 ])
748 .split(body);
749 self.list.render(f, areas[0], theme, focused);
750 self.list.set_list_rect(areas[0]);
751 let gray = theme.gray.to_ratatui();
752 let bg = theme.bg_panel.to_ratatui();
753 f.render_widget(
754 Paragraph::new("\u{2500}".repeat(areas[1].width as usize))
755 .style(Style::default().fg(gray).bg(bg)),
756 areas[1],
757 );
758 self.render_preview(f, areas[2], false, theme);
759 self.list.set_content_rect(areas[2]);
760 return;
761 }
762
763 self.list.render(f, body, theme, focused);
765 self.list.set_list_rect(body);
766 }
767
768 fn render_preview(&mut self, f: &mut Frame, area: Rect, full: bool, theme: &Theme) {
771 self.preview_page = area.height.saturating_sub(if full { 2 } else { 0 });
774
775 let title_fn = self
776 .list
777 .selected_row()
778 .map(|r| (r.source.display_heading(), r.source.path.to_string()));
779 let Self {
780 loaded, preview, ..
781 } = self;
782 match loaded {
783 Some(LoadedNote {
784 content: ReaderContent::Loaded { text, highlight },
785 ..
786 }) => {
787 if full {
788 let (title, filename) =
789 title_fn.unwrap_or_else(|| ("Source".to_string(), String::new()));
790 preview.render_full(
791 f,
792 area,
793 &title,
794 &filename,
795 text,
796 Highlight::Range(highlight.as_ref()),
797 theme,
798 );
799 } else {
800 preview.render_context(
801 f,
802 area,
803 text,
804 Highlight::Range(highlight.as_ref()),
805 theme,
806 );
807 }
808 }
809 Some(LoadedNote {
810 content: ReaderContent::Failed,
811 ..
812 }) => {
813 let red = Style::default().fg(theme.red.to_ratatui());
814 f.render_widget(Paragraph::new("failed to load note").style(red), area);
815 }
816 None
817 | Some(LoadedNote {
818 content: ReaderContent::Loading,
819 ..
820 }) => {
821 let dim = Style::default().fg(theme.gray.to_ratatui());
822 f.render_widget(Paragraph::new("loading\u{2026}").style(dim), area);
823 }
824 }
825 }
826
827 #[cfg(test)]
828 pub(crate) async fn settle(&mut self) {
829 self.list.poll_until_idle().await;
832 }
833
834 #[cfg(test)]
835 pub(crate) fn match_count(&self) -> usize {
836 self.list.match_count()
837 }
838}
839
840fn build_list(
847 rows: Vec<SourceRow>,
848 intercept: &[KeyCombo],
849 yank_combos: &[KeyCombo],
850 icons: &Icons,
851 redraw: Arc<dyn Fn() + Send + Sync>,
852) -> SearchList<SourceRow> {
853 SearchList::builder(StaticRowSource, redraw)
854 .yank_combos(yank_combos.to_vec())
855 .icons(icons.clone())
856 .filter(Filter::Fuzzy)
857 .opening_focus(Focus::List)
858 .intercept(intercept.to_vec())
859 .list_verb('l')
860 .list_verb('h')
861 .list_verb('o')
862 .list_verb('y')
863 .build_with_rows(rows)
864}
865
866fn score_percent(score: f64) -> u32 {
869 (score.clamp(0.0, 1.0) * 100.0).round() as u32
870}
871
872fn source_row(rank: usize, source: &AskSource, theme: &Theme) -> RichRow {
877 let bold = Style::default()
878 .fg(theme.fg_bright.to_ratatui())
879 .add_modifier(Modifier::BOLD);
880 let date_style = Style::default().fg(theme.color_journal_date.to_ratatui());
881 let rank_style = Style::default()
882 .fg(theme.accent.to_ratatui())
883 .add_modifier(Modifier::BOLD);
884 let pct = format!("{}%", score_percent(source.score));
885
886 let mut row = if source.heading.is_empty() {
887 match &source.date {
890 Some(date) => RichRow::new(rank.to_string(), date.clone()).title_style(date_style),
891 None => RichRow::new(rank.to_string(), String::new()).title_style(bold),
892 }
893 } else {
894 let mut r = RichRow::new(rank.to_string(), source.heading.clone()).title_style(bold);
895 if let Some(date) = &source.date {
896 r = r.date(date.clone(), Some(date_style));
897 }
898 r
899 };
900 row = row.glyph_style(rank_style).meta(pct);
901 row.filename(source.path.to_string())
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907 use kimun_core::VaultConfig;
908 use ratatui::Terminal;
909 use ratatui::backend::TestBackend;
910 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
911 use tempfile::TempDir;
912
913 fn source(path: &str, heading: &str, score: f64, text: &str) -> AskSource {
914 AskSource {
915 path: VaultPath::new(path),
916 heading: heading.to_string(),
917 date: None,
918 score,
919 text: text.to_string(),
920 ordinal: 0,
921 }
922 }
923
924 fn dated_source(path: &str, heading: &str, date: &str, score: f64) -> AskSource {
925 AskSource {
926 path: VaultPath::new(path),
927 heading: heading.to_string(),
928 date: Some(date.to_string()),
929 score,
930 text: String::new(),
931 ordinal: 0,
932 }
933 }
934
935 async fn test_vault() -> (TempDir, NoteVault) {
936 let dir = TempDir::new().unwrap();
937 let vault = NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
938 .await
939 .unwrap();
940 (dir, vault)
941 }
942
943 fn key_bindings() -> KeyBindings {
944 crate::settings::AppSettings::default().key_bindings.clone()
945 }
946
947 fn noop_tx() -> AppTx {
952 tokio::sync::mpsc::unbounded_channel().0
953 }
954
955 async fn test_panel() -> SourcesPanel {
959 let (dir, vault) = test_vault().await;
960 std::mem::forget(dir);
961 SourcesPanel::new(Arc::new(vault), &key_bindings())
962 }
963
964 fn key(code: KeyCode) -> KeyEvent {
965 KeyEvent::new(code, KeyModifiers::NONE)
966 }
967
968 fn ctrl(code: KeyCode) -> KeyEvent {
969 KeyEvent::new(code, KeyModifiers::CONTROL)
970 }
971
972 async fn two_source_panel(p: &mut SourcesPanel) {
975 p.set_turn(
976 1,
977 vec![
978 source("a.md", "A", 0.9, "alpha body"),
979 source("b.md", "B", 0.5, "beta body"),
980 ],
981 &noop_tx(),
982 );
983 p.settle().await;
984 }
985
986 async fn select_index(p: &mut SourcesPanel, i: usize) {
988 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
989 for _ in 0..i {
990 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
991 }
992 }
993
994 fn selected_heading(p: &SourcesPanel) -> Option<String> {
995 p.selected_source().map(|s| s.heading.clone())
996 }
997
998 fn nth_heading(p: &SourcesPanel, i: usize) -> Option<String> {
1001 p.source_at(i).map(|s| s.heading.clone())
1002 }
1003
1004 #[test]
1005 fn score_percent_rounds_and_clamps() {
1006 assert_eq!(score_percent(0.874), 87);
1007 assert_eq!(score_percent(1.5), 100);
1008 assert_eq!(score_percent(-0.2), 0);
1009 }
1010
1011 #[test]
1012 fn dated_source_display_heading_separates_date_and_heading() {
1013 let s = dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9);
1014 assert_eq!(s.display_heading(), "2026-04-08 \u{b7} Afternoon");
1015 assert_eq!(source("n.md", "Ideas", 0.5, "").display_heading(), "Ideas");
1016 }
1017
1018 #[tokio::test]
1019 async fn new_panel_starts_empty_and_collapsed() {
1020 let p = test_panel().await;
1021 assert_eq!(p.match_count(), 0);
1022 assert!(p.preview.is_collapsed());
1023 }
1024
1025 #[tokio::test]
1026 async fn set_turn_populates_and_collapses() {
1027 let mut p = test_panel().await;
1028 p.set_turn(1, vec![source("a.md", "A", 0.9, "text a")], &noop_tx());
1029 p.settle().await;
1030 assert_eq!(p.turn_id, Some(1));
1031 assert_eq!(p.match_count(), 1, "the engine mirrors the turn's rows");
1032 assert!(p.preview.is_collapsed());
1033 }
1034
1035 #[tokio::test]
1036 async fn set_turn_same_id_is_a_noop_and_keeps_selection() {
1037 let mut p = test_panel().await;
1038 two_source_panel(&mut p).await;
1039 select_index(&mut p, 1).await;
1040 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1041 p.set_turn(1, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1042 p.settle().await;
1043 assert_eq!(
1044 selected_heading(&p).as_deref(),
1045 Some("B"),
1046 "selection must survive a same-id set_turn"
1047 );
1048 assert_eq!(p.match_count(), 2, "rows must not be replaced");
1049 assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1050 }
1051
1052 #[tokio::test]
1053 async fn set_turn_new_id_resets_selection_and_collapses() {
1054 let mut p = test_panel().await;
1055 two_source_panel(&mut p).await;
1056 select_index(&mut p, 1).await;
1057 p.preview.toggle(Some(VaultPath::new("a.md")));
1058 p.set_turn(2, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1059 p.settle().await;
1060 assert_eq!(selected_heading(&p).as_deref(), Some("C"));
1061 assert_eq!(p.match_count(), 1);
1062 assert!(p.preview.is_collapsed());
1063 }
1064
1065 #[tokio::test]
1066 async fn focus_source_points_selection_by_ordinal_through_the_engine() {
1067 let mut p = test_panel().await;
1068 let mut a = source("a.md", "A", 0.9, "a");
1069 a.ordinal = 3;
1070 let mut b = source("b.md", "B", 0.5, "b");
1071 b.ordinal = 7;
1072 p.set_turn(1, vec![a, b], &noop_tx());
1073 p.settle().await;
1074 p.preview.toggle(Some(VaultPath::new("a.md")));
1075 p.focus_source(7);
1076 assert_eq!(
1077 p.selected_source().map(|s| s.ordinal),
1078 Some(7),
1079 "resolved ordinal 7 to its row through the engine, not ordinal-1"
1080 );
1081 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1082 assert!(p.preview.is_collapsed());
1083 p.focus_source(99);
1085 assert_eq!(p.selected_source().map(|s| s.ordinal), Some(7));
1086 }
1087
1088 #[tokio::test]
1094 async fn refresh_applies_rows_synchronously_no_redraw_needed() {
1095 let mut p = test_panel().await;
1096 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1097 p.refresh(1, vec![source("a.md", "A", 0.9, "alpha body")], &tx);
1098 assert_eq!(
1100 p.match_count(),
1101 1,
1102 "refresh's rows are applied synchronously"
1103 );
1104 assert!(!p.list.is_loading(), "no async load is in flight");
1105 assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1106 let mut redraws = 0;
1109 while let Ok(ev) = rx.try_recv() {
1110 if matches!(ev, AppEvent::Redraw) {
1111 redraws += 1;
1112 }
1113 }
1114 assert_eq!(redraws, 0, "no Redraw wake is needed for the sync row set");
1115 }
1116
1117 #[tokio::test]
1121 async fn cross_turn_focus_source_applies_immediately() {
1122 let mut p = test_panel().await;
1123 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1124 let mut a = source("a.md", "A", 0.9, "a");
1125 a.ordinal = 3;
1126 let mut b = source("b.md", "B", 0.5, "b");
1127 b.ordinal = 7;
1128 p.set_turn(2, vec![a, b], &tx);
1130 p.focus_source(7);
1131 assert_eq!(
1132 p.selected_source().map(|s| s.ordinal),
1133 Some(7),
1134 "citation focus applied in the same tick as set_turn"
1135 );
1136 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1137 }
1138
1139 #[tokio::test]
1144 async fn set_turn_then_open_reader_same_tick_opens_first_press() {
1145 let (_dir, vault) = test_vault().await;
1146 vault
1147 .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1148 .await
1149 .unwrap();
1150 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1151 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1152 p.set_turn(9, vec![source("a.md", "ha", 0.9, "alpha text")], &tx);
1154 p.open_reader(0, &tx);
1155 assert!(
1156 p.preview.is_context(),
1157 "open_reader opens the preview on the first press"
1158 );
1159 assert_eq!(
1160 selected_heading(&p).as_deref(),
1161 Some("ha"),
1162 "the requested source is selected"
1163 );
1164 assert_eq!(
1165 p.loaded.as_ref().map(|l| l.path.clone()),
1166 Some(VaultPath::new("a.md")),
1167 "the note load is anchored to the opened source"
1168 );
1169 }
1170
1171 #[tokio::test]
1174 async fn filter_input_narrows_sources_by_heading_or_path_text() {
1175 let mut p = test_panel().await;
1176 p.set_turn(
1177 1,
1178 vec![
1179 source("alpha.md", "Alpha section", 0.9, "a"),
1180 source("beta.md", "Beta section", 0.5, "b"),
1181 source("gamma.md", "Gamma section", 0.3, "g"),
1182 ],
1183 &noop_tx(),
1184 );
1185 p.settle().await;
1186 assert_eq!(p.match_count(), 3, "no filter shows every source");
1187 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1188 assert_eq!(p.list.focus(), Focus::List);
1190 p.handle_input(&InputEvent::Key(key(KeyCode::Char('i'))), &tx);
1191 assert_eq!(p.list.focus(), Focus::Input, "`i` reveals the filter input");
1192 for c in ['B', 'e', 't', 'a'] {
1193 p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1194 }
1195 p.settle().await;
1196 assert_eq!(p.match_count(), 1, "typed filter narrows to the match");
1197 assert_eq!(selected_heading(&p).as_deref(), Some("Beta section"));
1198 }
1199
1200 #[tokio::test]
1201 async fn slash_also_reveals_the_filter_and_matches_path_text() {
1202 let mut p = test_panel().await;
1203 p.set_turn(
1204 1,
1205 vec![
1206 source("notes/alpha.md", "One", 0.9, "a"),
1207 source("journal/beta.md", "Two", 0.5, "b"),
1208 ],
1209 &noop_tx(),
1210 );
1211 p.settle().await;
1212 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1213 p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &tx);
1214 assert_eq!(p.list.focus(), Focus::Input, "`/` reveals the filter input");
1215 for c in ['j', 'o', 'u', 'r'] {
1216 p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1217 }
1218 p.settle().await;
1219 assert_eq!(p.match_count(), 1, "path text filters too");
1220 assert_eq!(selected_heading(&p).as_deref(), Some("Two"));
1221 }
1222
1223 #[tokio::test]
1226 async fn enter_and_l_cycle_forward_h_cycles_back() {
1227 let mut p = test_panel().await;
1228 two_source_panel(&mut p).await;
1229 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1230 assert!(p.preview.is_collapsed());
1231
1232 p.handle_input(&InputEvent::Key(key(KeyCode::Enter)), &tx);
1233 assert!(p.preview.is_context(), "Enter: Collapsed -> Context");
1234 p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1235 assert!(p.preview.is_full(), "l: Context -> Full");
1236 p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1237 assert!(p.preview.is_collapsed(), "l: Full -> Collapsed (wraps)");
1238
1239 p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx); p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx); assert!(p.preview.is_full());
1243 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1244 assert!(p.preview.is_context(), "h: Full -> Context");
1245 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1246 assert!(p.preview.is_collapsed(), "h: Context -> Collapsed");
1247 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1248 assert!(p.preview.is_collapsed(), "h at Collapsed stays Collapsed");
1249 }
1250
1251 #[tokio::test]
1252 async fn esc_steps_back_then_bubbles_to_thread() {
1253 let mut p = test_panel().await;
1254 two_source_panel(&mut p).await;
1255 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1256 p.preview.toggle(Some(VaultPath::new("a.md"))); let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1259 assert_eq!(st, EventState::Consumed);
1260 assert!(p.preview.is_collapsed(), "Esc steps back one reveal state");
1261
1262 let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1265 assert_eq!(
1266 st,
1267 EventState::NotConsumed,
1268 "Collapsed Esc -> back to thread"
1269 );
1270 }
1271
1272 #[tokio::test]
1273 async fn jk_moves_selection_within_bounds() {
1274 let mut p = test_panel().await;
1275 two_source_panel(&mut p).await;
1276 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1277
1278 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1279 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1280 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1281 assert_eq!(
1282 selected_heading(&p).as_deref(),
1283 Some("B"),
1284 "clamped at the last row"
1285 );
1286 p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1287 assert_eq!(selected_heading(&p).as_deref(), Some("A"));
1288 p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1289 assert_eq!(
1290 selected_heading(&p).as_deref(),
1291 Some("A"),
1292 "clamped at the first row"
1293 );
1294 }
1295
1296 async fn assert_opens_selected(setup: impl Fn(&mut SourcesPanel), open: KeyEvent) {
1299 let mut p = test_panel().await;
1300 two_source_panel(&mut p).await;
1301 select_index(&mut p, 1).await;
1302 setup(&mut p);
1303 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1304 let st = p.handle_input(&InputEvent::Key(open), &tx);
1305 assert_eq!(st, EventState::Consumed);
1306 let mut opened = None;
1307 while let Ok(ev) = rx.try_recv() {
1308 if let AppEvent::OpenPath { path, .. } = ev {
1309 opened = Some(path);
1310 }
1311 }
1312 assert_eq!(
1313 opened,
1314 Some(VaultPath::new("b.md")),
1315 "opened the selected source"
1316 );
1317 }
1318
1319 #[tokio::test]
1321 async fn double_click_on_a_source_opens_it() {
1322 use ratatui::{Terminal, backend::TestBackend};
1323 let mut p = test_panel().await;
1324 two_source_panel(&mut p).await;
1325 let theme = crate::settings::themes::Theme::default();
1326 let mut t = Terminal::new(TestBackend::new(40, 20)).unwrap();
1327 t.draw(|f| p.render(f, f.area(), &theme, true)).unwrap();
1328 let (x, y) =
1329 crate::test_support::find_text(t.backend().buffer(), "b.md").expect("b.md row drawn");
1330 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1331 let press = crate::test_support::mouse_down_at(x, y);
1332 p.handle_input(&press, &tx);
1333 p.handle_input(&press, &tx);
1334 let opened = crate::test_support::drain(&mut rx)
1335 .into_iter()
1336 .find_map(|e| match e {
1337 AppEvent::OpenPath { path, .. } => Some(path),
1338 _ => None,
1339 });
1340 assert_eq!(opened, Some(VaultPath::new("b.md")));
1341 }
1342
1343 #[tokio::test]
1344 async fn o_opens_selected_from_every_reveal_state() {
1345 assert_opens_selected(|_p| {}, key(KeyCode::Char('o'))).await;
1347 assert_opens_selected(
1348 |p| p.preview.toggle(Some(VaultPath::new("b.md"))),
1349 key(KeyCode::Char('o')),
1350 )
1351 .await;
1352 assert_opens_selected(
1353 |p| {
1354 p.preview.toggle(Some(VaultPath::new("b.md")));
1355 p.preview.toggle(Some(VaultPath::new("b.md")));
1356 },
1357 key(KeyCode::Char('o')),
1358 )
1359 .await;
1360 }
1361
1362 #[tokio::test]
1363 async fn followlink_ctrl_n_opens_selected() {
1364 assert_opens_selected(|_p| {}, ctrl(KeyCode::Char('n'))).await;
1365 assert_opens_selected(
1367 |p| {
1368 p.preview.toggle(Some(VaultPath::new("b.md")));
1369 p.preview.toggle(Some(VaultPath::new("b.md")));
1370 },
1371 ctrl(KeyCode::Char('n')),
1372 )
1373 .await;
1374 }
1375
1376 async fn assert_yanks(k: KeyEvent) {
1379 let mut p = test_panel().await;
1380 two_source_panel(&mut p).await;
1381 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1382 let st = p.handle_input(&InputEvent::Key(k), &tx);
1383 assert_eq!(st, EventState::Consumed);
1384 let mut flashed = false;
1385 while let Ok(ev) = rx.try_recv() {
1386 if matches!(ev, AppEvent::FlashMessage(_)) {
1387 flashed = true;
1388 }
1389 }
1390 assert!(
1391 flashed,
1392 "yank emits a flash message (ok or clipboard error)"
1393 );
1394 }
1395
1396 #[tokio::test]
1397 async fn plain_y_and_ctrl_y_both_yank() {
1398 assert_yanks(key(KeyCode::Char('y'))).await;
1399 assert_yanks(ctrl(KeyCode::Char('y'))).await;
1400 }
1401
1402 #[tokio::test]
1405 async fn reader_note_for_the_wrong_path_is_dropped() {
1406 let mut p = test_panel().await;
1407 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1408 p.loaded = Some(LoadedNote {
1409 path: VaultPath::new("a.md"),
1410 ordinal: 0,
1411 content: ReaderContent::Loading,
1412 });
1413 p.handle_data(AskData::ReaderNote {
1414 path: VaultPath::new("other.md"),
1415 text: Some("nope".to_string()),
1416 });
1417 assert!(
1418 matches!(p.loaded.as_ref().unwrap().content, ReaderContent::Loading),
1419 "wrong-path ReaderNote must be dropped, not accepted"
1420 );
1421 }
1422
1423 #[tokio::test]
1424 async fn reader_note_for_the_right_path_loads_and_highlights() {
1425 let mut p = test_panel().await;
1426 p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1427 p.settle().await;
1428 p.loaded = Some(LoadedNote {
1429 path: VaultPath::new("a.md"),
1430 ordinal: 0,
1431 content: ReaderContent::Loading,
1432 });
1433 p.handle_data(AskData::ReaderNote {
1434 path: VaultPath::new("a.md"),
1435 text: Some("# a\nalpha body\n# b\nbeta body\n".to_string()),
1436 });
1437 match &p.loaded.as_ref().unwrap().content {
1438 ReaderContent::Loaded { text, highlight } => {
1439 let r = highlight.clone().expect("chunk resolves");
1440 assert_eq!(&text[r], "beta body");
1441 }
1442 _ => panic!("expected Loaded"),
1443 }
1444 }
1445
1446 #[tokio::test]
1447 async fn reader_note_load_failure_is_recorded() {
1448 let mut p = test_panel().await;
1449 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1450 p.loaded = Some(LoadedNote {
1451 path: VaultPath::new("a.md"),
1452 ordinal: 0,
1453 content: ReaderContent::Loading,
1454 });
1455 p.handle_data(AskData::ReaderNote {
1456 path: VaultPath::new("a.md"),
1457 text: None,
1458 });
1459 assert!(matches!(
1460 p.loaded.as_ref().unwrap().content,
1461 ReaderContent::Failed
1462 ));
1463 }
1464
1465 #[tokio::test]
1466 async fn handle_data_ignores_answer_ready() {
1467 let mut p = test_panel().await;
1468 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1469 p.loaded = Some(LoadedNote {
1470 path: VaultPath::new("a.md"),
1471 ordinal: 0,
1472 content: ReaderContent::Loading,
1473 });
1474 p.handle_data(AskData::AnswerReady {
1475 turn_id: 1,
1476 result: Ok(("x".into(), vec![])),
1477 });
1478 assert!(matches!(
1479 p.loaded.as_ref().unwrap().content,
1480 ReaderContent::Loading
1481 ));
1482 }
1483
1484 #[tokio::test]
1485 async fn open_reader_opens_preview_and_round_trips_a_real_vault() {
1486 let (_dir, vault) = test_vault().await;
1487 let path = VaultPath::new("note.md");
1488 vault.create_note(&path, "# h\nbody text\n").await.unwrap();
1489
1490 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1491 p.set_turn(
1492 1,
1493 vec![source("note.md", "h", 0.9, "body text")],
1494 &noop_tx(),
1495 );
1496 p.settle().await;
1497 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1498 p.open_reader(0, &tx);
1499 assert!(
1500 p.preview.is_context(),
1501 "open_reader opens the Context preview"
1502 );
1503
1504 let event = rx.recv().await.expect("open_reader spawns a ReaderNote");
1505 let AppEvent::Ask(data) = event else {
1506 panic!("expected an Ask event");
1507 };
1508 p.handle_data(data);
1509 match &p.loaded.as_ref().unwrap().content {
1510 ReaderContent::Loaded { text, .. } => assert_eq!(text, "# h\nbody text\n"),
1511 _ => panic!("expected Loaded"),
1512 }
1513 }
1514
1515 #[tokio::test]
1516 async fn navigating_in_context_reloads_for_the_new_source() {
1517 let (_dir, vault) = test_vault().await;
1518 std::mem::forget(_dir);
1519 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1520 two_source_panel(&mut p).await;
1521 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1522 p.preview.toggle(Some(VaultPath::new("a.md"))); p.ensure_loaded(&tx);
1524 assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("a.md"));
1525 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1528 assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("b.md"));
1529 }
1530
1531 fn buffer_text(p: &mut SourcesPanel, w: u16, h: u16) -> String {
1534 let theme = Theme::default();
1535 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
1536 term.draw(|f| {
1537 let area = f.area();
1538 p.render(f, area, &theme, true);
1539 })
1540 .unwrap();
1541 let buf = term.backend().buffer().clone();
1542 (0..buf.area.height)
1543 .map(|y| {
1544 (0..buf.area.width)
1545 .map(|x| buf[(x, y)].symbol())
1546 .collect::<String>()
1547 })
1548 .collect::<Vec<_>>()
1549 .join("\n")
1550 }
1551
1552 #[tokio::test]
1553 async fn row_render_carries_rank_and_score() {
1554 let mut p = test_panel().await;
1555 p.set_turn(
1556 1,
1557 vec![
1558 dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1559 source("b.md", "Beta section", 0.42, "beta body"),
1560 ],
1561 &noop_tx(),
1562 );
1563 p.settle().await;
1564 let text = buffer_text(&mut p, 60, 11);
1567 assert!(text.contains("1 "), "rank 1 leads the first row: {text}");
1568 assert!(text.contains("2 "), "rank 2 leads the second row: {text}");
1569 assert!(text.contains("90%"), "score percent shown: {text}");
1570 assert!(text.contains("42%"), "second score shown: {text}");
1571 assert!(text.contains("2026-04-08"), "date kept: {text}");
1572 assert!(
1573 text.contains('\u{b7}'),
1574 "date \u{b7} heading separation: {text}"
1575 );
1576 assert!(text.contains("Afternoon"), "heading kept: {text}");
1577 }
1578
1579 #[tokio::test]
1584 async fn filter_box_is_bordered_and_always_visible() {
1585 let mut p = test_panel().await;
1586 p.set_turn(
1587 1,
1588 vec![source("a.md", "Alpha", 0.9, "alpha body")],
1589 &noop_tx(),
1590 );
1591 p.settle().await;
1592
1593 assert_eq!(p.list.focus(), Focus::List, "Sources opens on the list");
1597 let text = buffer_text(&mut p, 40, 10);
1598 assert!(
1599 text.contains("filter"),
1600 "filter box shows in list focus, before `/`/`i`: {text}"
1601 );
1602 assert!(
1603 text.contains('\u{250c}') || text.contains('\u{2500}'),
1604 "filter field is boxed (bordered), not a bare line: {text}"
1605 );
1606
1607 p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &noop_tx());
1610 assert_eq!(p.list.focus(), Focus::Input);
1611 let text = buffer_text(&mut p, 40, 10);
1612 assert!(
1613 text.contains("filter"),
1614 "filter box stays visible in input focus: {text}"
1615 );
1616 }
1617
1618 #[tokio::test]
1622 async fn zero_match_filter_shows_no_results() {
1623 let mut p = test_panel().await;
1624 p.set_turn(1, vec![source("a.md", "Alpha", 0.9, "body")], &noop_tx());
1625 p.settle().await;
1626 p.list.set_query("zzznomatch");
1627 assert_eq!(p.list.visible_rows().len(), 0, "filter narrows to nothing");
1628 let text = buffer_text(&mut p, 40, 10);
1629 assert!(
1630 text.contains("No results"),
1631 "zero-match filter shows the No results message: {text}"
1632 );
1633 }
1634
1635 #[tokio::test]
1639 async fn context_list_pane_shrinks_when_filter_narrows() {
1640 let mut p = test_panel().await;
1641 let srcs: Vec<_> = (0..10)
1642 .map(|i| source(&format!("n{i}.md"), &format!("Alpha{i}"), 0.9, "body"))
1643 .collect();
1644 p.set_turn(1, srcs, &noop_tx());
1645 p.settle().await;
1646 p.preview.toggle(Some(VaultPath::new("n0.md")));
1649 let mut text = String::new();
1650 for i in 0..40 {
1651 text.push_str(&format!("noteline{i}\n"));
1652 }
1653 p.loaded = Some(LoadedNote {
1654 path: VaultPath::new("n0.md"),
1655 ordinal: 0,
1656 content: ReaderContent::Loaded {
1657 text,
1658 highlight: None,
1659 },
1660 });
1661 let count_lines = |p: &mut SourcesPanel| buffer_text(p, 40, 20).matches("noteline").count();
1662 let before = count_lines(&mut p);
1663 p.list.set_query("Alpha3");
1665 assert_eq!(p.list.visible_rows().len(), 1, "filter narrows to one");
1666 let after = count_lines(&mut p);
1667 assert!(
1668 after > before,
1669 "preview gained the space the shrunken list gave up: before={before} after={after}"
1670 );
1671 }
1672
1673 #[tokio::test]
1674 async fn render_does_not_panic_across_states_and_sizes() {
1675 let mut p = test_panel().await;
1676 buffer_text(&mut p, 40, 10); p.set_turn(
1679 1,
1680 vec![
1681 dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1682 source("b.md", "Beta section", 0.4, "beta body"),
1683 ],
1684 &noop_tx(),
1685 );
1686 p.settle().await;
1687 buffer_text(&mut p, 40, 10); select_index(&mut p, 1).await;
1689 buffer_text(&mut p, 40, 3); p.preview.toggle(Some(VaultPath::new("b.md")));
1693 p.loaded = Some(LoadedNote {
1694 path: VaultPath::new("b.md"),
1695 ordinal: 0,
1696 content: ReaderContent::Loaded {
1697 text: "# Beta\nbeta body\nmore\n".to_string(),
1698 highlight: Some(7..16),
1699 },
1700 });
1701 buffer_text(&mut p, 40, 12); p.preview.toggle(Some(VaultPath::new("b.md"))); buffer_text(&mut p, 40, 12); p.loaded = Some(LoadedNote {
1707 path: VaultPath::new("b.md"),
1708 ordinal: 0,
1709 content: ReaderContent::Loading,
1710 });
1711 buffer_text(&mut p, 40, 12);
1712 p.loaded = Some(LoadedNote {
1713 path: VaultPath::new("b.md"),
1714 ordinal: 0,
1715 content: ReaderContent::Failed,
1716 });
1717 buffer_text(&mut p, 40, 12);
1718
1719 buffer_text(&mut p, 3, 3); buffer_text(&mut p, 0, 0); }
1722
1723 #[tokio::test]
1724 async fn full_preview_anchors_scroll_to_the_highlighted_section() {
1725 let mut p = test_panel().await;
1726 p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1727 p.settle().await;
1728 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); let mut body = String::new();
1734 for i in 0..8 {
1735 body.push_str(&format!("line{i}\n"));
1736 }
1737 body.push_str("beta body\n");
1738 for i in 0..8 {
1739 body.push_str(&format!("tail{i}\n"));
1740 }
1741 let start = body.find("beta body").unwrap();
1742 p.loaded = Some(LoadedNote {
1743 path: VaultPath::new("a.md"),
1744 ordinal: 0,
1745 content: ReaderContent::Loaded {
1746 text: body,
1747 highlight: Some(start..start + "beta body".len()),
1748 },
1749 });
1750 buffer_text(&mut p, 40, 6);
1753 assert!(
1754 p.preview.scroll_offset() > 0,
1755 "preview anchored the scroll to the section, offset={}",
1756 p.preview.scroll_offset()
1757 );
1758 }
1759
1760 #[tokio::test]
1763 async fn full_down_scrolls_content_not_the_list() {
1764 let mut p = test_panel().await;
1765 two_source_panel(&mut p).await;
1766 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1767 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); let mut body = String::from("alpha body\n");
1772 for i in 0..20 {
1773 body.push_str(&format!("line{i}\n"));
1774 }
1775 p.loaded = Some(LoadedNote {
1776 path: VaultPath::new("a.md"),
1777 ordinal: 0,
1778 content: ReaderContent::Loaded {
1779 text: body,
1780 highlight: Some(0.."alpha body".len()),
1781 },
1782 });
1783 buffer_text(&mut p, 40, 6); assert_eq!(p.preview.scroll_offset(), 0);
1785 p.handle_input(&InputEvent::Key(key(KeyCode::Down)), &tx);
1787 assert_eq!(
1788 selected_heading(&p).as_deref(),
1789 Some("A"),
1790 "Down in Full scrolls content, not the list"
1791 );
1792 assert!(
1793 p.preview.scroll_offset() > 0,
1794 "Full + Down scrolled the content, offset={}",
1795 p.preview.scroll_offset()
1796 );
1797 }
1798
1799 #[tokio::test]
1800 async fn full_j_still_moves_the_list_selection() {
1801 let mut p = test_panel().await;
1802 two_source_panel(&mut p).await;
1803 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1804 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); assert!(p.preview.is_full());
1807 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1809 assert_eq!(
1810 selected_heading(&p).as_deref(),
1811 Some("B"),
1812 "j moves the list selection in Full"
1813 );
1814 }
1815
1816 #[tokio::test]
1817 async fn wheel_scrolls_the_open_preview_and_is_ignored_when_collapsed() {
1818 let mut p = test_panel().await;
1819 two_source_panel(&mut p).await;
1820 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1821 let wheel = |kind| {
1824 InputEvent::Mouse(MouseEvent {
1825 kind,
1826 column: 0,
1827 row: 0,
1828 modifiers: KeyModifiers::NONE,
1829 })
1830 };
1831 assert_eq!(
1832 p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1833 EventState::NotConsumed,
1834 "collapsed preview with no recorded rect does not eat the wheel"
1835 );
1836 p.preview.toggle(Some(VaultPath::new("a.md")));
1838 p.preview.toggle(Some(VaultPath::new("a.md")));
1839 let mut body = String::from("alpha body\n");
1840 for i in 0..20 {
1841 body.push_str(&format!("line{i}\n"));
1842 }
1843 p.loaded = Some(LoadedNote {
1844 path: VaultPath::new("a.md"),
1845 ordinal: 0,
1846 content: ReaderContent::Loaded {
1847 text: body,
1848 highlight: Some(0.."alpha body".len()),
1849 },
1850 });
1851 buffer_text(&mut p, 40, 6);
1852 assert_eq!(
1853 p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1854 EventState::Consumed,
1855 "open preview consumes the wheel"
1856 );
1857 assert!(p.preview.scroll_offset() > 0, "wheel scrolled the content");
1858 }
1859
1860 #[tokio::test]
1863 async fn same_note_different_heading_recomputes_highlight_without_reload() {
1864 let (_dir, vault) = test_vault().await;
1865 std::mem::forget(_dir);
1866 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1867 let mut s0 = source("doc.md", "Alpha", 0.9, "alpha body");
1869 s0.ordinal = 1;
1870 let mut s1 = source("doc.md", "Beta", 0.8, "beta body");
1871 s1.ordinal = 2;
1872 p.set_turn(1, vec![s0, s1], &noop_tx());
1873 p.settle().await;
1874 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1875 p.preview.toggle(Some(VaultPath::new("doc.md"))); p.ensure_loaded(&tx); let note = "# Alpha\nalpha body\n# Beta\nbeta body\n".to_string();
1879 p.handle_data(AskData::ReaderNote {
1880 path: VaultPath::new("doc.md"),
1881 text: Some(note),
1882 });
1883 let first = match &p.loaded.as_ref().unwrap().content {
1884 ReaderContent::Loaded { text, highlight } => {
1885 let r = highlight.clone().expect("section resolves");
1886 assert_eq!(&text[r.clone()], "alpha body");
1887 r
1888 }
1889 _ => panic!("expected Loaded"),
1890 };
1891 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1894 match &p.loaded.as_ref().unwrap().content {
1895 ReaderContent::Loaded { text, highlight } => {
1896 let r = highlight.clone().expect("re-resolved");
1897 assert_eq!(&text[r.clone()], "beta body");
1898 assert_ne!(r, first, "highlight moved to the new section");
1899 }
1900 _ => panic!("must reuse the loaded note, not reload"),
1901 }
1902 assert_eq!(
1903 p.loaded.as_ref().unwrap().ordinal,
1904 2,
1905 "re-keyed to the new source"
1906 );
1907 }
1908
1909 #[tokio::test]
1912 async fn open_reader_stays_full_and_re_points_to_the_source() {
1913 let (_dir, vault) = test_vault().await;
1914 vault
1915 .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1916 .await
1917 .unwrap();
1918 vault
1919 .create_note(&VaultPath::new("b.md"), "# hb\nbeta text\n")
1920 .await
1921 .unwrap();
1922 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1923 let mut s0 = source("a.md", "ha", 0.9, "alpha text");
1924 s0.ordinal = 1;
1925 let mut s1 = source("b.md", "hb", 0.8, "beta text");
1926 s1.ordinal = 2;
1927 p.set_turn(1, vec![s0, s1], &noop_tx());
1928 p.settle().await;
1929 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1930 select_index(&mut p, 1).await;
1932 p.preview.toggle(Some(VaultPath::new("b.md"))); p.preview.toggle(Some(VaultPath::new("b.md"))); assert!(p.preview.is_full());
1935 p.open_reader(0, &tx);
1937 assert!(p.preview.is_full(), "open_reader keeps the Full reveal");
1938 assert_eq!(selected_heading(&p).as_deref(), Some("ha"));
1939 let ev = rx.recv().await.expect("open_reader spawns a ReaderNote");
1941 let AppEvent::Ask(data) = ev else {
1942 panic!("expected an Ask event");
1943 };
1944 p.handle_data(data);
1945 match &p.loaded.as_ref().unwrap().content {
1946 ReaderContent::Loaded { text, highlight } => {
1947 assert_eq!(text, "# ha\nalpha text\n", "source 0's note is shown");
1948 let r = highlight.clone().expect("section resolves");
1949 assert_eq!(&text[r], "alpha text");
1950 }
1951 _ => panic!("expected Loaded for source 0"),
1952 }
1953 }
1954}