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::{
36 KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
37};
38use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
39use ratatui::style::{Modifier, Style};
40use ratatui::widgets::{Block, Borders, ListItem, Paragraph};
41
42use crate::ask::{AskSource, locate};
43use crate::components::event_state::EventState;
44use crate::components::events::{AppEvent, AppTx, AskData, InputEvent, redraw_callback};
45use crate::components::panel::panel_block;
46use crate::components::preview_pane::{Highlight, PreviewPane};
47use crate::components::rich_row::RichRow;
48use crate::components::search_list::{
49 Filter, Focus, KeyReaction, SearchList, SearchMouse, SearchRow, StaticRowSource,
50};
51use crate::keys::KeyBindings;
52use crate::keys::action_shortcuts::ActionShortcuts;
53use crate::keys::key_combo::KeyCombo;
54use crate::settings::icons::Icons;
55use crate::settings::themes::Theme;
56
57const PAGE_OVERLAP: u16 = 2;
60
61enum ReaderContent {
63 Loading,
65 Loaded {
68 text: String,
69 highlight: Option<Range<usize>>,
70 },
71 Failed,
73}
74
75struct LoadedNote {
81 path: VaultPath,
82 ordinal: usize,
85 content: ReaderContent,
86}
87
88#[derive(Clone)]
93struct SourceRow {
94 rank: usize,
95 source: AskSource,
96 filter_text: String,
99}
100
101impl SourceRow {
102 fn new(rank: usize, source: AskSource) -> Self {
103 let filter_text = format!("{} {}", source.heading, source.path);
104 Self {
105 rank,
106 source,
107 filter_text,
108 }
109 }
110}
111
112impl SearchRow for SourceRow {
113 fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
114 source_row(self.rank, &self.source, theme).into_list_item(theme)
115 }
116
117 fn visual_height(&self) -> u16 {
118 2
120 }
121
122 fn match_text(&self) -> Option<&str> {
123 Some(&self.filter_text)
124 }
125}
126
127pub struct SourcesPanel {
131 turn_id: Option<u64>,
132 list: SearchList<SourceRow>,
136 preview: PreviewPane,
140 loaded: Option<LoadedNote>,
143 vault: Arc<NoteVault>,
147 icons: Icons,
148 intercept: Vec<KeyCombo>,
151 ctrl_y_combo: Option<KeyCombo>,
154 preview_page: u16,
157}
158
159impl SourcesPanel {
160 pub fn new(vault: Arc<NoteVault>, key_bindings: &KeyBindings) -> Self {
161 let map = key_bindings.to_hashmap();
162 let follow = map
163 .get(&ActionShortcuts::FollowLink)
164 .cloned()
165 .unwrap_or_default();
166 let ctrl_y_combo = crate::keys::key_event_to_combo(&KeyEvent::new(
167 KeyCode::Char('y'),
168 KeyModifiers::CONTROL,
169 ));
170 let mut intercept = follow;
171 if let Some(c) = ctrl_y_combo {
172 intercept.push(c);
173 }
174 let icons = Icons::new(false);
175 let list = build_list(Vec::new(), &intercept, &icons, Arc::new(|| {}));
179 Self {
180 turn_id: None,
181 list,
182 preview: PreviewPane::new(),
183 loaded: None,
184 vault,
185 icons,
186 intercept,
187 ctrl_y_combo,
188 preview_page: 0,
189 }
190 }
191
192 pub fn set_turn(&mut self, turn_id: u64, sources: Vec<AskSource>, tx: &AppTx) {
199 if self.turn_id == Some(turn_id) {
200 return;
201 }
202 self.refresh(turn_id, sources, tx);
203 }
204
205 pub fn refresh(&mut self, turn_id: u64, sources: Vec<AskSource>, tx: &AppTx) {
211 self.turn_id = Some(turn_id);
212 self.rebuild_list(sources, tx);
213 self.preview.reset();
214 self.loaded = None;
215 }
216
217 pub fn reset(&mut self, tx: &AppTx) {
220 self.turn_id = None;
221 self.rebuild_list(Vec::new(), tx);
222 self.preview.reset();
223 self.loaded = None;
224 }
225
226 fn rebuild_list(&mut self, sources: Vec<AskSource>, tx: &AppTx) {
233 let rows: Vec<SourceRow> = sources
234 .into_iter()
235 .enumerate()
236 .map(|(i, s)| SourceRow::new(i + 1, s))
237 .collect();
238 self.list = build_list(
239 rows,
240 &self.intercept,
241 &self.icons,
242 redraw_callback(tx.clone()),
243 );
244 }
245
246 fn has_sources(&self) -> bool {
249 !self.list.rows().is_empty()
250 }
251
252 fn source_at(&self, index: usize) -> Option<&AskSource> {
255 self.list.rows().get(index).map(|r| &r.source)
256 }
257
258 pub fn focus_source(&mut self, ordinal: usize) {
266 self.list.set_query(""); self.preview.reset();
268 self.loaded = None;
269 if let Some(pos) = self
274 .list
275 .visible_rows()
276 .iter()
277 .position(|r| r.source.ordinal == ordinal)
278 {
279 self.list.select(pos);
280 }
281 }
282
283 pub fn open_reader(&mut self, source_index: usize, tx: &AppTx) {
290 self.list.set_query("");
291 let Some(source) = self.source_at(source_index).cloned() else {
295 return;
296 };
297 self.list.select(source_index);
298 let sel = Some(source.path.clone());
299 if self.preview.is_collapsed() {
300 self.preview.toggle(sel); } else {
302 self.preview.repoint(sel); }
304 self.ensure_note_load(
305 source.path.clone(),
306 source.ordinal,
307 source.match_heading().to_string(),
308 source.text.clone(),
309 tx,
310 );
311 }
312
313 pub fn handle_data(&mut self, data: AskData) {
318 let AskData::ReaderNote { path, text } = data else {
319 return;
320 };
321 if self.loaded.as_ref().map(|l| &l.path) != Some(&path) {
322 return;
323 }
324 let ord = self.loaded.as_ref().map(|l| l.ordinal);
328 let rows = self.list.rows();
329 let hl_src = rows
330 .iter()
331 .map(|r| &r.source)
332 .find(|s| s.path == path && Some(s.ordinal) == ord)
333 .or_else(|| rows.iter().map(|r| &r.source).find(|s| s.path == path))
334 .map(|s| (s.match_heading().to_string(), s.text.clone()));
335 let content = match text {
336 Some(loaded) => {
337 let highlight = hl_src
338 .and_then(|(heading, chunk)| locate::section_range(&loaded, &heading, &chunk));
339 ReaderContent::Loaded {
340 text: loaded,
341 highlight,
342 }
343 }
344 None => ReaderContent::Failed,
345 };
346 if let Some(l) = &mut self.loaded {
347 l.content = content;
348 }
349 }
350
351 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
352 if self.list.focus() == Focus::Input {
353 return vec![
354 ("Esc".into(), "list".into()),
355 ("type".into(), "filter".into()),
356 ];
357 }
358 if self.preview.is_collapsed() {
359 vec![
360 ("j/k".into(), "Select".into()),
361 ("Enter/l".into(), "Preview".into()),
362 ("o/^N".into(), "Open".into()),
363 ("y".into(), "Yank".into()),
364 ("i".into(), "Filter".into()),
365 ]
366 } else {
367 vec![
368 ("j/k".into(), "Select".into()),
369 ("Enter/l".into(), "Expand".into()),
370 ("h/Esc".into(), "Back".into()),
371 ("o/^N".into(), "Open".into()),
372 ]
373 }
374 }
375
376 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
377 let key = match event {
378 InputEvent::Key(key) => key,
379 InputEvent::Mouse(mouse) => return self.handle_mouse(mouse, tx),
382 _ => return EventState::NotConsumed,
383 };
384
385 if self.preview.is_full() {
389 match key.code {
390 KeyCode::Up => {
391 self.preview.scroll_up();
392 return EventState::Consumed;
393 }
394 KeyCode::Down => {
395 self.preview.scroll_down();
396 return EventState::Consumed;
397 }
398 KeyCode::PageUp => {
399 self.scroll_preview_page(true);
400 return EventState::Consumed;
401 }
402 KeyCode::PageDown => {
403 self.scroll_preview_page(false);
404 return EventState::Consumed;
405 }
406 _ => {}
407 }
408 }
409
410 if key.code == KeyCode::Esc
416 && self.list.focus() == Focus::List
417 && !self.preview.is_collapsed()
418 {
419 self.preview.collapse_step(self.selected_path());
420 return EventState::Consumed;
421 }
422
423 match self.list.handle_key(key) {
424 KeyReaction::Intercepted(c) => {
428 if Some(c) == self.ctrl_y_combo {
429 self.yank_selected_path(tx);
430 } else {
431 self.open_selected(tx);
432 }
433 EventState::Consumed
434 }
435 KeyReaction::Submit => {
437 if self.has_sources() {
438 self.preview.toggle(self.selected_path());
439 self.ensure_loaded(tx);
440 }
441 EventState::Consumed
442 }
443 KeyReaction::ListVerb(c) => {
445 match c {
446 'l' => {
447 if self.has_sources() {
448 self.preview.toggle(self.selected_path());
449 self.ensure_loaded(tx);
450 }
451 }
452 'h' => self.preview.collapse_step(self.selected_path()),
453 'o' => self.open_selected(tx),
454 'y' => self.yank_selected_path(tx),
455 _ => {}
456 }
457 EventState::Consumed
458 }
459 KeyReaction::Consumed => {
463 self.sync_preview();
464 self.ensure_loaded(tx);
465 EventState::Consumed
466 }
467 KeyReaction::Cancel | KeyReaction::Unhandled => EventState::NotConsumed,
470 }
471 }
472
473 fn handle_mouse(&mut self, mouse: &MouseEvent, tx: &AppTx) -> EventState {
478 let was_full = self.preview.is_full();
479 self.sync_preview();
480 if was_full {
484 match mouse.kind {
485 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
486 MouseEventKind::Down(MouseButton::Left)
487 if self.preview.full_header_rect().contains(Position {
488 x: mouse.column,
489 y: mouse.row,
490 }) =>
491 {
492 self.preview.toggle(self.selected_path());
493 return EventState::Consumed;
494 }
495 _ => return EventState::Consumed,
496 }
497 }
498 match self.list.handle_mouse(mouse) {
499 SearchMouse::ContentScrollUp => {
500 self.preview.scroll_up();
501 EventState::Consumed
502 }
503 SearchMouse::ContentScrollDown => {
504 self.preview.scroll_down();
505 EventState::Consumed
506 }
507 SearchMouse::Activated(_) => {
508 self.preview.toggle(self.selected_path());
509 self.ensure_loaded(tx);
510 EventState::Consumed
511 }
512 SearchMouse::Selected(_) | SearchMouse::Scrolled | SearchMouse::Context(_) => {
513 self.sync_preview();
514 self.ensure_loaded(tx);
515 EventState::Consumed
516 }
517 SearchMouse::None => EventState::NotConsumed,
518 }
519 }
520
521 fn scroll_preview_page(&mut self, up: bool) {
525 let page = self.preview_page.saturating_sub(PAGE_OVERLAP).max(1);
526 for _ in 0..page {
527 if up {
528 self.preview.scroll_up();
529 } else {
530 self.preview.scroll_down();
531 }
532 }
533 }
534
535 fn selected_source(&self) -> Option<&AskSource> {
538 self.list.selected_row().map(|r| &r.source)
539 }
540
541 fn selected_path(&self) -> Option<VaultPath> {
543 self.selected_source().map(|s| s.path.clone())
544 }
545
546 fn sync_preview(&mut self) {
549 let sel = self.selected_path();
550 self.preview.sync(sel);
551 }
552
553 fn ensure_loaded(&mut self, tx: &AppTx) {
557 if self.preview.is_collapsed() {
558 return;
559 }
560 let Some(source) = self.selected_source() else {
561 return;
562 };
563 let path = source.path.clone();
564 let ordinal = source.ordinal;
565 let heading = source.match_heading().to_string();
566 let chunk = source.text.clone();
567 self.ensure_note_load(path, ordinal, heading, chunk, tx);
568 }
569
570 fn ensure_note_load(
580 &mut self,
581 path: VaultPath,
582 ordinal: usize,
583 heading: String,
584 chunk: String,
585 tx: &AppTx,
586 ) {
587 match &self.loaded {
588 Some(l) if l.path == path && l.ordinal == ordinal => return,
589 Some(l) if l.path == path => {
590 if let Some(l) = &mut self.loaded {
593 l.ordinal = ordinal;
594 if let ReaderContent::Loaded { text, highlight } = &mut l.content {
595 *highlight = locate::section_range(text, &heading, &chunk);
596 }
597 }
598 self.preview.re_anchor();
599 return;
600 }
601 _ => {}
602 }
603
604 self.loaded = Some(LoadedNote {
605 path: path.clone(),
606 ordinal,
607 content: ReaderContent::Loading,
608 });
609 let vault = self.vault.clone();
610 let tx = tx.clone();
611 tokio::spawn(async move {
612 let text = vault.get_note_text(&path).await.ok();
613 let _ = tx.send(AppEvent::Ask(AskData::ReaderNote { path, text }));
614 });
615 }
616
617 fn open_selected(&self, tx: &AppTx) {
620 if let Some(source) = self.selected_source() {
621 tx.send(AppEvent::open(source.path.clone())).ok();
622 }
623 }
624
625 fn yank_selected_path(&self, tx: &AppTx) {
629 let Some(source) = self.selected_source() else {
630 return;
631 };
632 crate::components::yank(source.path.to_string(), "path copied", tx);
633 }
634
635 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
636 self.list.poll();
637 self.sync_preview();
640 self.list.set_panel_rect(rect);
643 self.list.set_content_rect(Rect::default());
644 self.preview.clear_header();
645
646 let block = panel_block("Sources", theme, focused);
647 let inner = block.inner(rect);
648 f.render_widget(block, rect);
649
650 if self.list.rows().is_empty() {
654 let style = Style::default().fg(theme.gray.to_ratatui());
655 f.render_widget(
656 Paragraph::new("no sources — ask something").style(style),
657 inner,
658 );
659 return;
660 }
661
662 let rows = Layout::default()
670 .direction(Direction::Vertical)
671 .constraints([Constraint::Length(3), Constraint::Min(0)])
672 .split(inner);
673 let filter_block = Block::default()
674 .title(" filter ")
675 .borders(Borders::ALL)
676 .border_style(theme.border_style(focused))
677 .style(theme.panel_style());
678 let filter_inner = filter_block.inner(rows[0]);
679 f.render_widget(filter_block, rows[0]);
680 self.list.render_query(f, filter_inner, theme, focused);
681 let body = rows[1];
682
683 if self.list.visible_rows().is_empty() {
686 let gray = theme.gray.to_ratatui();
687 let bg = theme.bg_panel.to_ratatui();
688 f.render_widget(
689 Paragraph::new(" No results").style(Style::default().fg(gray).bg(bg)),
690 body,
691 );
692 return;
693 }
694
695 if self.preview.is_full() {
698 self.list.set_content_rect(rect);
699 self.render_preview(f, body, true, theme);
700 return;
701 }
702
703 if self.preview.is_context() {
705 let max_list = body.height / 2;
706 let visible = self.list.visible_rows().len();
711 let list_height = (visible as u16 * 2).min(max_list).max(1);
712 let areas = Layout::default()
713 .direction(Direction::Vertical)
714 .constraints([
715 Constraint::Length(list_height),
716 Constraint::Length(1),
717 Constraint::Min(0),
718 ])
719 .split(body);
720 self.list.render(f, areas[0], theme, focused);
721 self.list.set_list_rect(areas[0]);
722 let gray = theme.gray.to_ratatui();
723 let bg = theme.bg_panel.to_ratatui();
724 f.render_widget(
725 Paragraph::new("\u{2500}".repeat(areas[1].width as usize))
726 .style(Style::default().fg(gray).bg(bg)),
727 areas[1],
728 );
729 self.render_preview(f, areas[2], false, theme);
730 self.list.set_content_rect(areas[2]);
731 return;
732 }
733
734 self.list.render(f, body, theme, focused);
736 self.list.set_list_rect(body);
737 }
738
739 fn render_preview(&mut self, f: &mut Frame, area: Rect, full: bool, theme: &Theme) {
742 self.preview_page = area.height.saturating_sub(if full { 2 } else { 0 });
745
746 let title_fn = self
747 .list
748 .selected_row()
749 .map(|r| (r.source.display_heading(), r.source.path.to_string()));
750 let Self {
751 loaded, preview, ..
752 } = self;
753 match loaded {
754 Some(LoadedNote {
755 content: ReaderContent::Loaded { text, highlight },
756 ..
757 }) => {
758 if full {
759 let (title, filename) =
760 title_fn.unwrap_or_else(|| ("Source".to_string(), String::new()));
761 preview.render_full(
762 f,
763 area,
764 &title,
765 &filename,
766 text,
767 Highlight::Range(highlight.as_ref()),
768 theme,
769 );
770 } else {
771 preview.render_context(
772 f,
773 area,
774 text,
775 Highlight::Range(highlight.as_ref()),
776 theme,
777 );
778 }
779 }
780 Some(LoadedNote {
781 content: ReaderContent::Failed,
782 ..
783 }) => {
784 let red = Style::default().fg(theme.red.to_ratatui());
785 f.render_widget(Paragraph::new("failed to load note").style(red), area);
786 }
787 None
788 | Some(LoadedNote {
789 content: ReaderContent::Loading,
790 ..
791 }) => {
792 let dim = Style::default().fg(theme.gray.to_ratatui());
793 f.render_widget(Paragraph::new("loading\u{2026}").style(dim), area);
794 }
795 }
796 }
797
798 #[cfg(test)]
799 pub(crate) async fn settle(&mut self) {
800 self.list.poll_until_idle().await;
803 }
804
805 #[cfg(test)]
806 pub(crate) fn match_count(&self) -> usize {
807 self.list.match_count()
808 }
809}
810
811fn build_list(
818 rows: Vec<SourceRow>,
819 intercept: &[KeyCombo],
820 icons: &Icons,
821 redraw: Arc<dyn Fn() + Send + Sync>,
822) -> SearchList<SourceRow> {
823 SearchList::builder(StaticRowSource, redraw)
824 .icons(icons.clone())
825 .filter(Filter::Fuzzy)
826 .opening_focus(Focus::List)
827 .intercept(intercept.to_vec())
828 .list_verb('l')
829 .list_verb('h')
830 .list_verb('o')
831 .list_verb('y')
832 .build_with_rows(rows)
833}
834
835fn score_percent(score: f64) -> u32 {
838 (score.clamp(0.0, 1.0) * 100.0).round() as u32
839}
840
841fn source_row(rank: usize, source: &AskSource, theme: &Theme) -> RichRow {
846 let bold = Style::default()
847 .fg(theme.fg_bright.to_ratatui())
848 .add_modifier(Modifier::BOLD);
849 let date_style = Style::default().fg(theme.color_journal_date.to_ratatui());
850 let rank_style = Style::default()
851 .fg(theme.accent.to_ratatui())
852 .add_modifier(Modifier::BOLD);
853 let pct = format!("{}%", score_percent(source.score));
854
855 let mut row = if source.heading.is_empty() {
856 match &source.date {
859 Some(date) => RichRow::new(rank.to_string(), date.clone()).title_style(date_style),
860 None => RichRow::new(rank.to_string(), String::new()).title_style(bold),
861 }
862 } else {
863 let mut r = RichRow::new(rank.to_string(), source.heading.clone()).title_style(bold);
864 if let Some(date) = &source.date {
865 r = r.date(date.clone(), Some(date_style));
866 }
867 r
868 };
869 row = row.glyph_style(rank_style).meta(pct);
870 row.filename(source.path.to_string())
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876 use kimun_core::VaultConfig;
877 use ratatui::Terminal;
878 use ratatui::backend::TestBackend;
879 use tempfile::TempDir;
880
881 fn source(path: &str, heading: &str, score: f64, text: &str) -> AskSource {
882 AskSource {
883 path: VaultPath::new(path),
884 heading: heading.to_string(),
885 date: None,
886 score,
887 text: text.to_string(),
888 ordinal: 0,
889 }
890 }
891
892 fn dated_source(path: &str, heading: &str, date: &str, score: f64) -> AskSource {
893 AskSource {
894 path: VaultPath::new(path),
895 heading: heading.to_string(),
896 date: Some(date.to_string()),
897 score,
898 text: String::new(),
899 ordinal: 0,
900 }
901 }
902
903 async fn test_vault() -> (TempDir, NoteVault) {
904 let dir = TempDir::new().unwrap();
905 let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
906 (dir, vault)
907 }
908
909 fn key_bindings() -> KeyBindings {
910 crate::settings::AppSettings::default().key_bindings.clone()
911 }
912
913 fn noop_tx() -> AppTx {
918 tokio::sync::mpsc::unbounded_channel().0
919 }
920
921 async fn test_panel() -> SourcesPanel {
925 let (dir, vault) = test_vault().await;
926 std::mem::forget(dir);
927 SourcesPanel::new(Arc::new(vault), &key_bindings())
928 }
929
930 fn key(code: KeyCode) -> KeyEvent {
931 KeyEvent::new(code, KeyModifiers::NONE)
932 }
933
934 fn ctrl(code: KeyCode) -> KeyEvent {
935 KeyEvent::new(code, KeyModifiers::CONTROL)
936 }
937
938 async fn two_source_panel(p: &mut SourcesPanel) {
941 p.set_turn(
942 1,
943 vec![
944 source("a.md", "A", 0.9, "alpha body"),
945 source("b.md", "B", 0.5, "beta body"),
946 ],
947 &noop_tx(),
948 );
949 p.settle().await;
950 }
951
952 async fn select_index(p: &mut SourcesPanel, i: usize) {
954 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
955 for _ in 0..i {
956 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
957 }
958 }
959
960 fn selected_heading(p: &SourcesPanel) -> Option<String> {
961 p.selected_source().map(|s| s.heading.clone())
962 }
963
964 fn nth_heading(p: &SourcesPanel, i: usize) -> Option<String> {
967 p.source_at(i).map(|s| s.heading.clone())
968 }
969
970 #[test]
971 fn score_percent_rounds_and_clamps() {
972 assert_eq!(score_percent(0.874), 87);
973 assert_eq!(score_percent(1.5), 100);
974 assert_eq!(score_percent(-0.2), 0);
975 }
976
977 #[test]
978 fn dated_source_display_heading_separates_date_and_heading() {
979 let s = dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9);
980 assert_eq!(s.display_heading(), "2026-04-08 \u{b7} Afternoon");
981 assert_eq!(source("n.md", "Ideas", 0.5, "").display_heading(), "Ideas");
982 }
983
984 #[tokio::test]
985 async fn new_panel_starts_empty_and_collapsed() {
986 let p = test_panel().await;
987 assert_eq!(p.match_count(), 0);
988 assert!(p.preview.is_collapsed());
989 }
990
991 #[tokio::test]
992 async fn set_turn_populates_and_collapses() {
993 let mut p = test_panel().await;
994 p.set_turn(1, vec![source("a.md", "A", 0.9, "text a")], &noop_tx());
995 p.settle().await;
996 assert_eq!(p.turn_id, Some(1));
997 assert_eq!(p.match_count(), 1, "the engine mirrors the turn's rows");
998 assert!(p.preview.is_collapsed());
999 }
1000
1001 #[tokio::test]
1002 async fn set_turn_same_id_is_a_noop_and_keeps_selection() {
1003 let mut p = test_panel().await;
1004 two_source_panel(&mut p).await;
1005 select_index(&mut p, 1).await;
1006 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1007 p.set_turn(1, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1008 p.settle().await;
1009 assert_eq!(
1010 selected_heading(&p).as_deref(),
1011 Some("B"),
1012 "selection must survive a same-id set_turn"
1013 );
1014 assert_eq!(p.match_count(), 2, "rows must not be replaced");
1015 assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1016 }
1017
1018 #[tokio::test]
1019 async fn set_turn_new_id_resets_selection_and_collapses() {
1020 let mut p = test_panel().await;
1021 two_source_panel(&mut p).await;
1022 select_index(&mut p, 1).await;
1023 p.preview.toggle(Some(VaultPath::new("a.md")));
1024 p.set_turn(2, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1025 p.settle().await;
1026 assert_eq!(selected_heading(&p).as_deref(), Some("C"));
1027 assert_eq!(p.match_count(), 1);
1028 assert!(p.preview.is_collapsed());
1029 }
1030
1031 #[tokio::test]
1032 async fn focus_source_points_selection_by_ordinal_through_the_engine() {
1033 let mut p = test_panel().await;
1034 let mut a = source("a.md", "A", 0.9, "a");
1035 a.ordinal = 3;
1036 let mut b = source("b.md", "B", 0.5, "b");
1037 b.ordinal = 7;
1038 p.set_turn(1, vec![a, b], &noop_tx());
1039 p.settle().await;
1040 p.preview.toggle(Some(VaultPath::new("a.md")));
1041 p.focus_source(7);
1042 assert_eq!(
1043 p.selected_source().map(|s| s.ordinal),
1044 Some(7),
1045 "resolved ordinal 7 to its row through the engine, not ordinal-1"
1046 );
1047 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1048 assert!(p.preview.is_collapsed());
1049 p.focus_source(99);
1051 assert_eq!(p.selected_source().map(|s| s.ordinal), Some(7));
1052 }
1053
1054 #[tokio::test]
1060 async fn refresh_applies_rows_synchronously_no_redraw_needed() {
1061 let mut p = test_panel().await;
1062 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1063 p.refresh(1, vec![source("a.md", "A", 0.9, "alpha body")], &tx);
1064 assert_eq!(
1066 p.match_count(),
1067 1,
1068 "refresh's rows are applied synchronously"
1069 );
1070 assert!(!p.list.is_loading(), "no async load is in flight");
1071 assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1072 let mut redraws = 0;
1075 while let Ok(ev) = rx.try_recv() {
1076 if matches!(ev, AppEvent::Redraw) {
1077 redraws += 1;
1078 }
1079 }
1080 assert_eq!(redraws, 0, "no Redraw wake is needed for the sync row set");
1081 }
1082
1083 #[tokio::test]
1087 async fn cross_turn_focus_source_applies_immediately() {
1088 let mut p = test_panel().await;
1089 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1090 let mut a = source("a.md", "A", 0.9, "a");
1091 a.ordinal = 3;
1092 let mut b = source("b.md", "B", 0.5, "b");
1093 b.ordinal = 7;
1094 p.set_turn(2, vec![a, b], &tx);
1096 p.focus_source(7);
1097 assert_eq!(
1098 p.selected_source().map(|s| s.ordinal),
1099 Some(7),
1100 "citation focus applied in the same tick as set_turn"
1101 );
1102 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1103 }
1104
1105 #[tokio::test]
1110 async fn set_turn_then_open_reader_same_tick_opens_first_press() {
1111 let (_dir, vault) = test_vault().await;
1112 vault
1113 .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1114 .await
1115 .unwrap();
1116 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1117 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1118 p.set_turn(9, vec![source("a.md", "ha", 0.9, "alpha text")], &tx);
1120 p.open_reader(0, &tx);
1121 assert!(
1122 p.preview.is_context(),
1123 "open_reader opens the preview on the first press"
1124 );
1125 assert_eq!(
1126 selected_heading(&p).as_deref(),
1127 Some("ha"),
1128 "the requested source is selected"
1129 );
1130 assert_eq!(
1131 p.loaded.as_ref().map(|l| l.path.clone()),
1132 Some(VaultPath::new("a.md")),
1133 "the note load is anchored to the opened source"
1134 );
1135 }
1136
1137 #[tokio::test]
1140 async fn filter_input_narrows_sources_by_heading_or_path_text() {
1141 let mut p = test_panel().await;
1142 p.set_turn(
1143 1,
1144 vec![
1145 source("alpha.md", "Alpha section", 0.9, "a"),
1146 source("beta.md", "Beta section", 0.5, "b"),
1147 source("gamma.md", "Gamma section", 0.3, "g"),
1148 ],
1149 &noop_tx(),
1150 );
1151 p.settle().await;
1152 assert_eq!(p.match_count(), 3, "no filter shows every source");
1153 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1154 assert_eq!(p.list.focus(), Focus::List);
1156 p.handle_input(&InputEvent::Key(key(KeyCode::Char('i'))), &tx);
1157 assert_eq!(p.list.focus(), Focus::Input, "`i` reveals the filter input");
1158 for c in ['B', 'e', 't', 'a'] {
1159 p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1160 }
1161 p.settle().await;
1162 assert_eq!(p.match_count(), 1, "typed filter narrows to the match");
1163 assert_eq!(selected_heading(&p).as_deref(), Some("Beta section"));
1164 }
1165
1166 #[tokio::test]
1167 async fn slash_also_reveals_the_filter_and_matches_path_text() {
1168 let mut p = test_panel().await;
1169 p.set_turn(
1170 1,
1171 vec![
1172 source("notes/alpha.md", "One", 0.9, "a"),
1173 source("journal/beta.md", "Two", 0.5, "b"),
1174 ],
1175 &noop_tx(),
1176 );
1177 p.settle().await;
1178 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1179 p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &tx);
1180 assert_eq!(p.list.focus(), Focus::Input, "`/` reveals the filter input");
1181 for c in ['j', 'o', 'u', 'r'] {
1182 p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1183 }
1184 p.settle().await;
1185 assert_eq!(p.match_count(), 1, "path text filters too");
1186 assert_eq!(selected_heading(&p).as_deref(), Some("Two"));
1187 }
1188
1189 #[tokio::test]
1192 async fn enter_and_l_cycle_forward_h_cycles_back() {
1193 let mut p = test_panel().await;
1194 two_source_panel(&mut p).await;
1195 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1196 assert!(p.preview.is_collapsed());
1197
1198 p.handle_input(&InputEvent::Key(key(KeyCode::Enter)), &tx);
1199 assert!(p.preview.is_context(), "Enter: Collapsed -> Context");
1200 p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1201 assert!(p.preview.is_full(), "l: Context -> Full");
1202 p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1203 assert!(p.preview.is_collapsed(), "l: Full -> Collapsed (wraps)");
1204
1205 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());
1209 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1210 assert!(p.preview.is_context(), "h: Full -> Context");
1211 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1212 assert!(p.preview.is_collapsed(), "h: Context -> Collapsed");
1213 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1214 assert!(p.preview.is_collapsed(), "h at Collapsed stays Collapsed");
1215 }
1216
1217 #[tokio::test]
1218 async fn esc_steps_back_then_bubbles_to_thread() {
1219 let mut p = test_panel().await;
1220 two_source_panel(&mut p).await;
1221 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1222 p.preview.toggle(Some(VaultPath::new("a.md"))); let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1225 assert_eq!(st, EventState::Consumed);
1226 assert!(p.preview.is_collapsed(), "Esc steps back one reveal state");
1227
1228 let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1231 assert_eq!(
1232 st,
1233 EventState::NotConsumed,
1234 "Collapsed Esc -> back to thread"
1235 );
1236 }
1237
1238 #[tokio::test]
1239 async fn jk_moves_selection_within_bounds() {
1240 let mut p = test_panel().await;
1241 two_source_panel(&mut p).await;
1242 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1243
1244 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1245 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1246 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1247 assert_eq!(
1248 selected_heading(&p).as_deref(),
1249 Some("B"),
1250 "clamped at the last row"
1251 );
1252 p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1253 assert_eq!(selected_heading(&p).as_deref(), Some("A"));
1254 p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1255 assert_eq!(
1256 selected_heading(&p).as_deref(),
1257 Some("A"),
1258 "clamped at the first row"
1259 );
1260 }
1261
1262 async fn assert_opens_selected(setup: impl Fn(&mut SourcesPanel), open: KeyEvent) {
1265 let mut p = test_panel().await;
1266 two_source_panel(&mut p).await;
1267 select_index(&mut p, 1).await;
1268 setup(&mut p);
1269 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1270 let st = p.handle_input(&InputEvent::Key(open), &tx);
1271 assert_eq!(st, EventState::Consumed);
1272 let mut opened = None;
1273 while let Ok(ev) = rx.try_recv() {
1274 if let AppEvent::OpenPath { path, .. } = ev {
1275 opened = Some(path);
1276 }
1277 }
1278 assert_eq!(
1279 opened,
1280 Some(VaultPath::new("b.md")),
1281 "opened the selected source"
1282 );
1283 }
1284
1285 #[tokio::test]
1286 async fn o_opens_selected_from_every_reveal_state() {
1287 assert_opens_selected(|_p| {}, key(KeyCode::Char('o'))).await;
1289 assert_opens_selected(
1290 |p| p.preview.toggle(Some(VaultPath::new("b.md"))),
1291 key(KeyCode::Char('o')),
1292 )
1293 .await;
1294 assert_opens_selected(
1295 |p| {
1296 p.preview.toggle(Some(VaultPath::new("b.md")));
1297 p.preview.toggle(Some(VaultPath::new("b.md")));
1298 },
1299 key(KeyCode::Char('o')),
1300 )
1301 .await;
1302 }
1303
1304 #[tokio::test]
1305 async fn followlink_ctrl_n_opens_selected() {
1306 assert_opens_selected(|_p| {}, ctrl(KeyCode::Char('n'))).await;
1307 assert_opens_selected(
1309 |p| {
1310 p.preview.toggle(Some(VaultPath::new("b.md")));
1311 p.preview.toggle(Some(VaultPath::new("b.md")));
1312 },
1313 ctrl(KeyCode::Char('n')),
1314 )
1315 .await;
1316 }
1317
1318 async fn assert_yanks(k: KeyEvent) {
1321 let mut p = test_panel().await;
1322 two_source_panel(&mut p).await;
1323 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1324 let st = p.handle_input(&InputEvent::Key(k), &tx);
1325 assert_eq!(st, EventState::Consumed);
1326 let mut flashed = false;
1327 while let Ok(ev) = rx.try_recv() {
1328 if matches!(ev, AppEvent::FlashMessage(_)) {
1329 flashed = true;
1330 }
1331 }
1332 assert!(
1333 flashed,
1334 "yank emits a flash message (ok or clipboard error)"
1335 );
1336 }
1337
1338 #[tokio::test]
1339 async fn plain_y_and_ctrl_y_both_yank() {
1340 assert_yanks(key(KeyCode::Char('y'))).await;
1341 assert_yanks(ctrl(KeyCode::Char('y'))).await;
1342 }
1343
1344 #[tokio::test]
1347 async fn reader_note_for_the_wrong_path_is_dropped() {
1348 let mut p = test_panel().await;
1349 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1350 p.loaded = Some(LoadedNote {
1351 path: VaultPath::new("a.md"),
1352 ordinal: 0,
1353 content: ReaderContent::Loading,
1354 });
1355 p.handle_data(AskData::ReaderNote {
1356 path: VaultPath::new("other.md"),
1357 text: Some("nope".to_string()),
1358 });
1359 assert!(
1360 matches!(p.loaded.as_ref().unwrap().content, ReaderContent::Loading),
1361 "wrong-path ReaderNote must be dropped, not accepted"
1362 );
1363 }
1364
1365 #[tokio::test]
1366 async fn reader_note_for_the_right_path_loads_and_highlights() {
1367 let mut p = test_panel().await;
1368 p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1369 p.settle().await;
1370 p.loaded = Some(LoadedNote {
1371 path: VaultPath::new("a.md"),
1372 ordinal: 0,
1373 content: ReaderContent::Loading,
1374 });
1375 p.handle_data(AskData::ReaderNote {
1376 path: VaultPath::new("a.md"),
1377 text: Some("# a\nalpha body\n# b\nbeta body\n".to_string()),
1378 });
1379 match &p.loaded.as_ref().unwrap().content {
1380 ReaderContent::Loaded { text, highlight } => {
1381 let r = highlight.clone().expect("chunk resolves");
1382 assert_eq!(&text[r], "beta body");
1383 }
1384 _ => panic!("expected Loaded"),
1385 }
1386 }
1387
1388 #[tokio::test]
1389 async fn reader_note_load_failure_is_recorded() {
1390 let mut p = test_panel().await;
1391 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1392 p.loaded = Some(LoadedNote {
1393 path: VaultPath::new("a.md"),
1394 ordinal: 0,
1395 content: ReaderContent::Loading,
1396 });
1397 p.handle_data(AskData::ReaderNote {
1398 path: VaultPath::new("a.md"),
1399 text: None,
1400 });
1401 assert!(matches!(
1402 p.loaded.as_ref().unwrap().content,
1403 ReaderContent::Failed
1404 ));
1405 }
1406
1407 #[tokio::test]
1408 async fn handle_data_ignores_answer_ready() {
1409 let mut p = test_panel().await;
1410 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1411 p.loaded = Some(LoadedNote {
1412 path: VaultPath::new("a.md"),
1413 ordinal: 0,
1414 content: ReaderContent::Loading,
1415 });
1416 p.handle_data(AskData::AnswerReady {
1417 turn_id: 1,
1418 result: Ok(("x".into(), vec![])),
1419 });
1420 assert!(matches!(
1421 p.loaded.as_ref().unwrap().content,
1422 ReaderContent::Loading
1423 ));
1424 }
1425
1426 #[tokio::test]
1427 async fn open_reader_opens_preview_and_round_trips_a_real_vault() {
1428 let (_dir, vault) = test_vault().await;
1429 let path = VaultPath::new("note.md");
1430 vault.create_note(&path, "# h\nbody text\n").await.unwrap();
1431
1432 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1433 p.set_turn(
1434 1,
1435 vec![source("note.md", "h", 0.9, "body text")],
1436 &noop_tx(),
1437 );
1438 p.settle().await;
1439 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1440 p.open_reader(0, &tx);
1441 assert!(
1442 p.preview.is_context(),
1443 "open_reader opens the Context preview"
1444 );
1445
1446 let event = rx.recv().await.expect("open_reader spawns a ReaderNote");
1447 let AppEvent::Ask(data) = event else {
1448 panic!("expected an Ask event");
1449 };
1450 p.handle_data(data);
1451 match &p.loaded.as_ref().unwrap().content {
1452 ReaderContent::Loaded { text, .. } => assert_eq!(text, "# h\nbody text\n"),
1453 _ => panic!("expected Loaded"),
1454 }
1455 }
1456
1457 #[tokio::test]
1458 async fn navigating_in_context_reloads_for_the_new_source() {
1459 let (_dir, vault) = test_vault().await;
1460 std::mem::forget(_dir);
1461 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1462 two_source_panel(&mut p).await;
1463 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1464 p.preview.toggle(Some(VaultPath::new("a.md"))); p.ensure_loaded(&tx);
1466 assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("a.md"));
1467 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1470 assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("b.md"));
1471 }
1472
1473 fn buffer_text(p: &mut SourcesPanel, w: u16, h: u16) -> String {
1476 let theme = Theme::default();
1477 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
1478 term.draw(|f| {
1479 let area = f.area();
1480 p.render(f, area, &theme, true);
1481 })
1482 .unwrap();
1483 let buf = term.backend().buffer().clone();
1484 (0..buf.area.height)
1485 .map(|y| {
1486 (0..buf.area.width)
1487 .map(|x| buf[(x, y)].symbol())
1488 .collect::<String>()
1489 })
1490 .collect::<Vec<_>>()
1491 .join("\n")
1492 }
1493
1494 #[tokio::test]
1495 async fn row_render_carries_rank_and_score() {
1496 let mut p = test_panel().await;
1497 p.set_turn(
1498 1,
1499 vec![
1500 dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1501 source("b.md", "Beta section", 0.42, "beta body"),
1502 ],
1503 &noop_tx(),
1504 );
1505 p.settle().await;
1506 let text = buffer_text(&mut p, 60, 11);
1509 assert!(text.contains("1 "), "rank 1 leads the first row: {text}");
1510 assert!(text.contains("2 "), "rank 2 leads the second row: {text}");
1511 assert!(text.contains("90%"), "score percent shown: {text}");
1512 assert!(text.contains("42%"), "second score shown: {text}");
1513 assert!(text.contains("2026-04-08"), "date kept: {text}");
1514 assert!(
1515 text.contains('\u{b7}'),
1516 "date \u{b7} heading separation: {text}"
1517 );
1518 assert!(text.contains("Afternoon"), "heading kept: {text}");
1519 }
1520
1521 #[tokio::test]
1526 async fn filter_box_is_bordered_and_always_visible() {
1527 let mut p = test_panel().await;
1528 p.set_turn(
1529 1,
1530 vec![source("a.md", "Alpha", 0.9, "alpha body")],
1531 &noop_tx(),
1532 );
1533 p.settle().await;
1534
1535 assert_eq!(p.list.focus(), Focus::List, "Sources opens on the list");
1539 let text = buffer_text(&mut p, 40, 10);
1540 assert!(
1541 text.contains("filter"),
1542 "filter box shows in list focus, before `/`/`i`: {text}"
1543 );
1544 assert!(
1545 text.contains('\u{250c}') || text.contains('\u{2500}'),
1546 "filter field is boxed (bordered), not a bare line: {text}"
1547 );
1548
1549 p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &noop_tx());
1552 assert_eq!(p.list.focus(), Focus::Input);
1553 let text = buffer_text(&mut p, 40, 10);
1554 assert!(
1555 text.contains("filter"),
1556 "filter box stays visible in input focus: {text}"
1557 );
1558 }
1559
1560 #[tokio::test]
1564 async fn zero_match_filter_shows_no_results() {
1565 let mut p = test_panel().await;
1566 p.set_turn(1, vec![source("a.md", "Alpha", 0.9, "body")], &noop_tx());
1567 p.settle().await;
1568 p.list.set_query("zzznomatch");
1569 assert_eq!(p.list.visible_rows().len(), 0, "filter narrows to nothing");
1570 let text = buffer_text(&mut p, 40, 10);
1571 assert!(
1572 text.contains("No results"),
1573 "zero-match filter shows the No results message: {text}"
1574 );
1575 }
1576
1577 #[tokio::test]
1581 async fn context_list_pane_shrinks_when_filter_narrows() {
1582 let mut p = test_panel().await;
1583 let srcs: Vec<_> = (0..10)
1584 .map(|i| source(&format!("n{i}.md"), &format!("Alpha{i}"), 0.9, "body"))
1585 .collect();
1586 p.set_turn(1, srcs, &noop_tx());
1587 p.settle().await;
1588 p.preview.toggle(Some(VaultPath::new("n0.md")));
1591 let mut text = String::new();
1592 for i in 0..40 {
1593 text.push_str(&format!("noteline{i}\n"));
1594 }
1595 p.loaded = Some(LoadedNote {
1596 path: VaultPath::new("n0.md"),
1597 ordinal: 0,
1598 content: ReaderContent::Loaded {
1599 text,
1600 highlight: None,
1601 },
1602 });
1603 let count_lines = |p: &mut SourcesPanel| buffer_text(p, 40, 20).matches("noteline").count();
1604 let before = count_lines(&mut p);
1605 p.list.set_query("Alpha3");
1607 assert_eq!(p.list.visible_rows().len(), 1, "filter narrows to one");
1608 let after = count_lines(&mut p);
1609 assert!(
1610 after > before,
1611 "preview gained the space the shrunken list gave up: before={before} after={after}"
1612 );
1613 }
1614
1615 #[tokio::test]
1616 async fn render_does_not_panic_across_states_and_sizes() {
1617 let mut p = test_panel().await;
1618 buffer_text(&mut p, 40, 10); p.set_turn(
1621 1,
1622 vec![
1623 dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1624 source("b.md", "Beta section", 0.4, "beta body"),
1625 ],
1626 &noop_tx(),
1627 );
1628 p.settle().await;
1629 buffer_text(&mut p, 40, 10); select_index(&mut p, 1).await;
1631 buffer_text(&mut p, 40, 3); p.preview.toggle(Some(VaultPath::new("b.md")));
1635 p.loaded = Some(LoadedNote {
1636 path: VaultPath::new("b.md"),
1637 ordinal: 0,
1638 content: ReaderContent::Loaded {
1639 text: "# Beta\nbeta body\nmore\n".to_string(),
1640 highlight: Some(7..16),
1641 },
1642 });
1643 buffer_text(&mut p, 40, 12); p.preview.toggle(Some(VaultPath::new("b.md"))); buffer_text(&mut p, 40, 12); p.loaded = Some(LoadedNote {
1649 path: VaultPath::new("b.md"),
1650 ordinal: 0,
1651 content: ReaderContent::Loading,
1652 });
1653 buffer_text(&mut p, 40, 12);
1654 p.loaded = Some(LoadedNote {
1655 path: VaultPath::new("b.md"),
1656 ordinal: 0,
1657 content: ReaderContent::Failed,
1658 });
1659 buffer_text(&mut p, 40, 12);
1660
1661 buffer_text(&mut p, 3, 3); buffer_text(&mut p, 0, 0); }
1664
1665 #[tokio::test]
1666 async fn full_preview_anchors_scroll_to_the_highlighted_section() {
1667 let mut p = test_panel().await;
1668 p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1669 p.settle().await;
1670 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); let mut body = String::new();
1676 for i in 0..8 {
1677 body.push_str(&format!("line{i}\n"));
1678 }
1679 body.push_str("beta body\n");
1680 for i in 0..8 {
1681 body.push_str(&format!("tail{i}\n"));
1682 }
1683 let start = body.find("beta body").unwrap();
1684 p.loaded = Some(LoadedNote {
1685 path: VaultPath::new("a.md"),
1686 ordinal: 0,
1687 content: ReaderContent::Loaded {
1688 text: body,
1689 highlight: Some(start..start + "beta body".len()),
1690 },
1691 });
1692 buffer_text(&mut p, 40, 6);
1695 assert!(
1696 p.preview.scroll_offset() > 0,
1697 "preview anchored the scroll to the section, offset={}",
1698 p.preview.scroll_offset()
1699 );
1700 }
1701
1702 #[tokio::test]
1705 async fn full_down_scrolls_content_not_the_list() {
1706 let mut p = test_panel().await;
1707 two_source_panel(&mut p).await;
1708 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1709 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); let mut body = String::from("alpha body\n");
1714 for i in 0..20 {
1715 body.push_str(&format!("line{i}\n"));
1716 }
1717 p.loaded = Some(LoadedNote {
1718 path: VaultPath::new("a.md"),
1719 ordinal: 0,
1720 content: ReaderContent::Loaded {
1721 text: body,
1722 highlight: Some(0.."alpha body".len()),
1723 },
1724 });
1725 buffer_text(&mut p, 40, 6); assert_eq!(p.preview.scroll_offset(), 0);
1727 p.handle_input(&InputEvent::Key(key(KeyCode::Down)), &tx);
1729 assert_eq!(
1730 selected_heading(&p).as_deref(),
1731 Some("A"),
1732 "Down in Full scrolls content, not the list"
1733 );
1734 assert!(
1735 p.preview.scroll_offset() > 0,
1736 "Full + Down scrolled the content, offset={}",
1737 p.preview.scroll_offset()
1738 );
1739 }
1740
1741 #[tokio::test]
1742 async fn full_j_still_moves_the_list_selection() {
1743 let mut p = test_panel().await;
1744 two_source_panel(&mut p).await;
1745 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1746 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); assert!(p.preview.is_full());
1749 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1751 assert_eq!(
1752 selected_heading(&p).as_deref(),
1753 Some("B"),
1754 "j moves the list selection in Full"
1755 );
1756 }
1757
1758 #[tokio::test]
1759 async fn wheel_scrolls_the_open_preview_and_is_ignored_when_collapsed() {
1760 let mut p = test_panel().await;
1761 two_source_panel(&mut p).await;
1762 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1763 let wheel = |kind| {
1766 InputEvent::Mouse(MouseEvent {
1767 kind,
1768 column: 0,
1769 row: 0,
1770 modifiers: KeyModifiers::NONE,
1771 })
1772 };
1773 assert_eq!(
1774 p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1775 EventState::NotConsumed,
1776 "collapsed preview with no recorded rect does not eat the wheel"
1777 );
1778 p.preview.toggle(Some(VaultPath::new("a.md")));
1780 p.preview.toggle(Some(VaultPath::new("a.md")));
1781 let mut body = String::from("alpha body\n");
1782 for i in 0..20 {
1783 body.push_str(&format!("line{i}\n"));
1784 }
1785 p.loaded = Some(LoadedNote {
1786 path: VaultPath::new("a.md"),
1787 ordinal: 0,
1788 content: ReaderContent::Loaded {
1789 text: body,
1790 highlight: Some(0.."alpha body".len()),
1791 },
1792 });
1793 buffer_text(&mut p, 40, 6);
1794 assert_eq!(
1795 p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1796 EventState::Consumed,
1797 "open preview consumes the wheel"
1798 );
1799 assert!(p.preview.scroll_offset() > 0, "wheel scrolled the content");
1800 }
1801
1802 #[tokio::test]
1805 async fn same_note_different_heading_recomputes_highlight_without_reload() {
1806 let (_dir, vault) = test_vault().await;
1807 std::mem::forget(_dir);
1808 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1809 let mut s0 = source("doc.md", "Alpha", 0.9, "alpha body");
1811 s0.ordinal = 1;
1812 let mut s1 = source("doc.md", "Beta", 0.8, "beta body");
1813 s1.ordinal = 2;
1814 p.set_turn(1, vec![s0, s1], &noop_tx());
1815 p.settle().await;
1816 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1817 p.preview.toggle(Some(VaultPath::new("doc.md"))); p.ensure_loaded(&tx); let note = "# Alpha\nalpha body\n# Beta\nbeta body\n".to_string();
1821 p.handle_data(AskData::ReaderNote {
1822 path: VaultPath::new("doc.md"),
1823 text: Some(note),
1824 });
1825 let first = match &p.loaded.as_ref().unwrap().content {
1826 ReaderContent::Loaded { text, highlight } => {
1827 let r = highlight.clone().expect("section resolves");
1828 assert_eq!(&text[r.clone()], "alpha body");
1829 r
1830 }
1831 _ => panic!("expected Loaded"),
1832 };
1833 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1836 match &p.loaded.as_ref().unwrap().content {
1837 ReaderContent::Loaded { text, highlight } => {
1838 let r = highlight.clone().expect("re-resolved");
1839 assert_eq!(&text[r.clone()], "beta body");
1840 assert_ne!(r, first, "highlight moved to the new section");
1841 }
1842 _ => panic!("must reuse the loaded note, not reload"),
1843 }
1844 assert_eq!(
1845 p.loaded.as_ref().unwrap().ordinal,
1846 2,
1847 "re-keyed to the new source"
1848 );
1849 }
1850
1851 #[tokio::test]
1854 async fn open_reader_stays_full_and_re_points_to_the_source() {
1855 let (_dir, vault) = test_vault().await;
1856 vault
1857 .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1858 .await
1859 .unwrap();
1860 vault
1861 .create_note(&VaultPath::new("b.md"), "# hb\nbeta text\n")
1862 .await
1863 .unwrap();
1864 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1865 let mut s0 = source("a.md", "ha", 0.9, "alpha text");
1866 s0.ordinal = 1;
1867 let mut s1 = source("b.md", "hb", 0.8, "beta text");
1868 s1.ordinal = 2;
1869 p.set_turn(1, vec![s0, s1], &noop_tx());
1870 p.settle().await;
1871 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1872 select_index(&mut p, 1).await;
1874 p.preview.toggle(Some(VaultPath::new("b.md"))); p.preview.toggle(Some(VaultPath::new("b.md"))); assert!(p.preview.is_full());
1877 p.open_reader(0, &tx);
1879 assert!(p.preview.is_full(), "open_reader keeps the Full reveal");
1880 assert_eq!(selected_heading(&p).as_deref(), Some("ha"));
1881 let ev = rx.recv().await.expect("open_reader spawns a ReaderNote");
1883 let AppEvent::Ask(data) = ev else {
1884 panic!("expected an Ask event");
1885 };
1886 p.handle_data(data);
1887 match &p.loaded.as_ref().unwrap().content {
1888 ReaderContent::Loaded { text, highlight } => {
1889 assert_eq!(text, "# ha\nalpha text\n", "source 0's note is shown");
1890 let r = highlight.clone().expect("section resolves");
1891 assert_eq!(&text[r], "alpha text");
1892 }
1893 _ => panic!("expected Loaded for source 0"),
1894 }
1895 }
1896}