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' => {
452 if self.has_sources() {
453 self.preview.toggle(self.selected_path());
454 self.ensure_loaded(tx);
455 }
456 }
457 'h' => self.preview.collapse_step(self.selected_path()),
458 'o' => self.open_selected(tx),
459 'y' => self.yank_selected_path(tx),
460 _ => {}
461 }
462 EventState::Consumed
463 }
464 KeyReaction::Consumed => {
468 self.sync_preview();
469 self.ensure_loaded(tx);
470 EventState::Consumed
471 }
472 KeyReaction::Yank(target) => {
473 crate::components::yank_row(target, tx);
474 EventState::Consumed
475 }
476 KeyReaction::Cancel | KeyReaction::Unhandled => EventState::NotConsumed,
479 }
480 }
481
482 fn handle_mouse(&mut self, mouse: &MouseEvent, tx: &AppTx) -> EventState {
487 let was_full = self.preview.is_full();
488 self.sync_preview();
489 if was_full {
493 match mouse.kind {
494 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
495 MouseEventKind::Down(MouseButton::Left)
496 if self.preview.full_header_rect().contains(Position {
497 x: mouse.column,
498 y: mouse.row,
499 }) =>
500 {
501 self.preview.toggle(self.selected_path());
502 return EventState::Consumed;
503 }
504 _ => return EventState::Consumed,
505 }
506 }
507 match self.list.handle_mouse(mouse) {
508 SearchMouse::ContentScrollUp => {
509 self.preview.scroll_up();
510 EventState::Consumed
511 }
512 SearchMouse::ContentScrollDown => {
513 self.preview.scroll_down();
514 EventState::Consumed
515 }
516 SearchMouse::Activated(_) => {
517 self.preview.toggle(self.selected_path());
518 self.ensure_loaded(tx);
519 EventState::Consumed
520 }
521 SearchMouse::Selected(_) | SearchMouse::Scrolled | SearchMouse::Context(_) => {
522 self.sync_preview();
523 self.ensure_loaded(tx);
524 EventState::Consumed
525 }
526 SearchMouse::None => EventState::NotConsumed,
527 }
528 }
529
530 fn scroll_preview_page(&mut self, up: bool) {
534 let page = self.preview_page.saturating_sub(PAGE_OVERLAP).max(1);
535 for _ in 0..page {
536 if up {
537 self.preview.scroll_up();
538 } else {
539 self.preview.scroll_down();
540 }
541 }
542 }
543
544 fn selected_source(&self) -> Option<&AskSource> {
547 self.list.selected_row().map(|r| &r.source)
548 }
549
550 fn selected_path(&self) -> Option<VaultPath> {
552 self.selected_source().map(|s| s.path.clone())
553 }
554
555 fn sync_preview(&mut self) {
558 let sel = self.selected_path();
559 self.preview.sync(sel);
560 }
561
562 fn ensure_loaded(&mut self, tx: &AppTx) {
566 if self.preview.is_collapsed() {
567 return;
568 }
569 let Some(source) = self.selected_source() else {
570 return;
571 };
572 let path = source.path.clone();
573 let ordinal = source.ordinal;
574 let heading = source.match_heading().to_string();
575 let chunk = source.text.clone();
576 self.ensure_note_load(path, ordinal, heading, chunk, tx);
577 }
578
579 fn ensure_note_load(
589 &mut self,
590 path: VaultPath,
591 ordinal: usize,
592 heading: String,
593 chunk: String,
594 tx: &AppTx,
595 ) {
596 match &self.loaded {
597 Some(l) if l.path == path && l.ordinal == ordinal => return,
598 Some(l) if l.path == path => {
599 if let Some(l) = &mut self.loaded {
602 l.ordinal = ordinal;
603 if let ReaderContent::Loaded { text, highlight } = &mut l.content {
604 *highlight = locate::section_range(text, &heading, &chunk);
605 }
606 }
607 self.preview.re_anchor();
608 return;
609 }
610 _ => {}
611 }
612
613 self.loaded = Some(LoadedNote {
614 path: path.clone(),
615 ordinal,
616 content: ReaderContent::Loading,
617 });
618 let vault = self.vault.clone();
619 let tx = tx.clone();
620 tokio::spawn(async move {
621 let text = vault.get_note_text(&path).await.ok();
622 let _ = tx.send(AppEvent::Ask(AskData::ReaderNote { path, text }));
623 });
624 }
625
626 fn open_selected(&self, tx: &AppTx) {
629 if let Some(source) = self.selected_source() {
630 tx.send(AppEvent::open(source.path.clone())).ok();
631 }
632 }
633
634 fn yank_selected_path(&self, tx: &AppTx) {
638 let Some(source) = self.selected_source() else {
639 return;
640 };
641 crate::components::yank(source.path.to_string(), "path copied", tx);
642 }
643
644 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
645 self.list.poll();
646 self.sync_preview();
649 self.list.set_panel_rect(rect);
652 self.list.set_content_rect(Rect::default());
653 self.preview.clear_header();
654
655 let block = panel_block("Sources", theme, focused);
656 let inner = block.inner(rect);
657 f.render_widget(block, rect);
658
659 if self.list.rows().is_empty() {
663 let style = Style::default().fg(theme.gray.to_ratatui());
664 f.render_widget(
665 Paragraph::new("no sources — ask something").style(style),
666 inner,
667 );
668 return;
669 }
670
671 let rows = Layout::default()
679 .direction(Direction::Vertical)
680 .constraints([Constraint::Length(3), Constraint::Min(0)])
681 .split(inner);
682 let filter_block = Block::default()
683 .title(" filter ")
684 .borders(Borders::ALL)
685 .border_style(theme.border_style(focused))
686 .style(theme.panel_style());
687 let filter_inner = filter_block.inner(rows[0]);
688 f.render_widget(filter_block, rows[0]);
689 self.list.render_query(f, filter_inner, theme, focused);
690 let body = rows[1];
691
692 if self.list.visible_rows().is_empty() {
695 let gray = theme.gray.to_ratatui();
696 let bg = theme.bg_panel.to_ratatui();
697 f.render_widget(
698 Paragraph::new(" No results").style(Style::default().fg(gray).bg(bg)),
699 body,
700 );
701 return;
702 }
703
704 if self.preview.is_full() {
707 self.list.set_content_rect(rect);
708 self.render_preview(f, body, true, theme);
709 return;
710 }
711
712 if self.preview.is_context() {
714 let max_list = body.height / 2;
715 let visible = self.list.visible_rows().len();
720 let list_height = (visible as u16 * 2).min(max_list).max(1);
721 let areas = Layout::default()
722 .direction(Direction::Vertical)
723 .constraints([
724 Constraint::Length(list_height),
725 Constraint::Length(1),
726 Constraint::Min(0),
727 ])
728 .split(body);
729 self.list.render(f, areas[0], theme, focused);
730 self.list.set_list_rect(areas[0]);
731 let gray = theme.gray.to_ratatui();
732 let bg = theme.bg_panel.to_ratatui();
733 f.render_widget(
734 Paragraph::new("\u{2500}".repeat(areas[1].width as usize))
735 .style(Style::default().fg(gray).bg(bg)),
736 areas[1],
737 );
738 self.render_preview(f, areas[2], false, theme);
739 self.list.set_content_rect(areas[2]);
740 return;
741 }
742
743 self.list.render(f, body, theme, focused);
745 self.list.set_list_rect(body);
746 }
747
748 fn render_preview(&mut self, f: &mut Frame, area: Rect, full: bool, theme: &Theme) {
751 self.preview_page = area.height.saturating_sub(if full { 2 } else { 0 });
754
755 let title_fn = self
756 .list
757 .selected_row()
758 .map(|r| (r.source.display_heading(), r.source.path.to_string()));
759 let Self {
760 loaded, preview, ..
761 } = self;
762 match loaded {
763 Some(LoadedNote {
764 content: ReaderContent::Loaded { text, highlight },
765 ..
766 }) => {
767 if full {
768 let (title, filename) =
769 title_fn.unwrap_or_else(|| ("Source".to_string(), String::new()));
770 preview.render_full(
771 f,
772 area,
773 &title,
774 &filename,
775 text,
776 Highlight::Range(highlight.as_ref()),
777 theme,
778 );
779 } else {
780 preview.render_context(
781 f,
782 area,
783 text,
784 Highlight::Range(highlight.as_ref()),
785 theme,
786 );
787 }
788 }
789 Some(LoadedNote {
790 content: ReaderContent::Failed,
791 ..
792 }) => {
793 let red = Style::default().fg(theme.red.to_ratatui());
794 f.render_widget(Paragraph::new("failed to load note").style(red), area);
795 }
796 None
797 | Some(LoadedNote {
798 content: ReaderContent::Loading,
799 ..
800 }) => {
801 let dim = Style::default().fg(theme.gray.to_ratatui());
802 f.render_widget(Paragraph::new("loading\u{2026}").style(dim), area);
803 }
804 }
805 }
806
807 #[cfg(test)]
808 pub(crate) async fn settle(&mut self) {
809 self.list.poll_until_idle().await;
812 }
813
814 #[cfg(test)]
815 pub(crate) fn match_count(&self) -> usize {
816 self.list.match_count()
817 }
818}
819
820fn build_list(
827 rows: Vec<SourceRow>,
828 intercept: &[KeyCombo],
829 yank_combos: &[KeyCombo],
830 icons: &Icons,
831 redraw: Arc<dyn Fn() + Send + Sync>,
832) -> SearchList<SourceRow> {
833 SearchList::builder(StaticRowSource, redraw)
834 .yank_combos(yank_combos.to_vec())
835 .icons(icons.clone())
836 .filter(Filter::Fuzzy)
837 .opening_focus(Focus::List)
838 .intercept(intercept.to_vec())
839 .list_verb('l')
840 .list_verb('h')
841 .list_verb('o')
842 .list_verb('y')
843 .build_with_rows(rows)
844}
845
846fn score_percent(score: f64) -> u32 {
849 (score.clamp(0.0, 1.0) * 100.0).round() as u32
850}
851
852fn source_row(rank: usize, source: &AskSource, theme: &Theme) -> RichRow {
857 let bold = Style::default()
858 .fg(theme.fg_bright.to_ratatui())
859 .add_modifier(Modifier::BOLD);
860 let date_style = Style::default().fg(theme.color_journal_date.to_ratatui());
861 let rank_style = Style::default()
862 .fg(theme.accent.to_ratatui())
863 .add_modifier(Modifier::BOLD);
864 let pct = format!("{}%", score_percent(source.score));
865
866 let mut row = if source.heading.is_empty() {
867 match &source.date {
870 Some(date) => RichRow::new(rank.to_string(), date.clone()).title_style(date_style),
871 None => RichRow::new(rank.to_string(), String::new()).title_style(bold),
872 }
873 } else {
874 let mut r = RichRow::new(rank.to_string(), source.heading.clone()).title_style(bold);
875 if let Some(date) = &source.date {
876 r = r.date(date.clone(), Some(date_style));
877 }
878 r
879 };
880 row = row.glyph_style(rank_style).meta(pct);
881 row.filename(source.path.to_string())
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887 use kimun_core::VaultConfig;
888 use ratatui::Terminal;
889 use ratatui::backend::TestBackend;
890 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
891 use tempfile::TempDir;
892
893 fn source(path: &str, heading: &str, score: f64, text: &str) -> AskSource {
894 AskSource {
895 path: VaultPath::new(path),
896 heading: heading.to_string(),
897 date: None,
898 score,
899 text: text.to_string(),
900 ordinal: 0,
901 }
902 }
903
904 fn dated_source(path: &str, heading: &str, date: &str, score: f64) -> AskSource {
905 AskSource {
906 path: VaultPath::new(path),
907 heading: heading.to_string(),
908 date: Some(date.to_string()),
909 score,
910 text: String::new(),
911 ordinal: 0,
912 }
913 }
914
915 async fn test_vault() -> (TempDir, NoteVault) {
916 let dir = TempDir::new().unwrap();
917 let vault = NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
918 .await
919 .unwrap();
920 (dir, vault)
921 }
922
923 fn key_bindings() -> KeyBindings {
924 crate::settings::AppSettings::default().key_bindings.clone()
925 }
926
927 fn noop_tx() -> AppTx {
932 tokio::sync::mpsc::unbounded_channel().0
933 }
934
935 async fn test_panel() -> SourcesPanel {
939 let (dir, vault) = test_vault().await;
940 std::mem::forget(dir);
941 SourcesPanel::new(Arc::new(vault), &key_bindings())
942 }
943
944 fn key(code: KeyCode) -> KeyEvent {
945 KeyEvent::new(code, KeyModifiers::NONE)
946 }
947
948 fn ctrl(code: KeyCode) -> KeyEvent {
949 KeyEvent::new(code, KeyModifiers::CONTROL)
950 }
951
952 async fn two_source_panel(p: &mut SourcesPanel) {
955 p.set_turn(
956 1,
957 vec![
958 source("a.md", "A", 0.9, "alpha body"),
959 source("b.md", "B", 0.5, "beta body"),
960 ],
961 &noop_tx(),
962 );
963 p.settle().await;
964 }
965
966 async fn select_index(p: &mut SourcesPanel, i: usize) {
968 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
969 for _ in 0..i {
970 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
971 }
972 }
973
974 fn selected_heading(p: &SourcesPanel) -> Option<String> {
975 p.selected_source().map(|s| s.heading.clone())
976 }
977
978 fn nth_heading(p: &SourcesPanel, i: usize) -> Option<String> {
981 p.source_at(i).map(|s| s.heading.clone())
982 }
983
984 #[test]
985 fn score_percent_rounds_and_clamps() {
986 assert_eq!(score_percent(0.874), 87);
987 assert_eq!(score_percent(1.5), 100);
988 assert_eq!(score_percent(-0.2), 0);
989 }
990
991 #[test]
992 fn dated_source_display_heading_separates_date_and_heading() {
993 let s = dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9);
994 assert_eq!(s.display_heading(), "2026-04-08 \u{b7} Afternoon");
995 assert_eq!(source("n.md", "Ideas", 0.5, "").display_heading(), "Ideas");
996 }
997
998 #[tokio::test]
999 async fn new_panel_starts_empty_and_collapsed() {
1000 let p = test_panel().await;
1001 assert_eq!(p.match_count(), 0);
1002 assert!(p.preview.is_collapsed());
1003 }
1004
1005 #[tokio::test]
1006 async fn set_turn_populates_and_collapses() {
1007 let mut p = test_panel().await;
1008 p.set_turn(1, vec![source("a.md", "A", 0.9, "text a")], &noop_tx());
1009 p.settle().await;
1010 assert_eq!(p.turn_id, Some(1));
1011 assert_eq!(p.match_count(), 1, "the engine mirrors the turn's rows");
1012 assert!(p.preview.is_collapsed());
1013 }
1014
1015 #[tokio::test]
1016 async fn set_turn_same_id_is_a_noop_and_keeps_selection() {
1017 let mut p = test_panel().await;
1018 two_source_panel(&mut p).await;
1019 select_index(&mut p, 1).await;
1020 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1021 p.set_turn(1, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1022 p.settle().await;
1023 assert_eq!(
1024 selected_heading(&p).as_deref(),
1025 Some("B"),
1026 "selection must survive a same-id set_turn"
1027 );
1028 assert_eq!(p.match_count(), 2, "rows must not be replaced");
1029 assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1030 }
1031
1032 #[tokio::test]
1033 async fn set_turn_new_id_resets_selection_and_collapses() {
1034 let mut p = test_panel().await;
1035 two_source_panel(&mut p).await;
1036 select_index(&mut p, 1).await;
1037 p.preview.toggle(Some(VaultPath::new("a.md")));
1038 p.set_turn(2, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1039 p.settle().await;
1040 assert_eq!(selected_heading(&p).as_deref(), Some("C"));
1041 assert_eq!(p.match_count(), 1);
1042 assert!(p.preview.is_collapsed());
1043 }
1044
1045 #[tokio::test]
1046 async fn focus_source_points_selection_by_ordinal_through_the_engine() {
1047 let mut p = test_panel().await;
1048 let mut a = source("a.md", "A", 0.9, "a");
1049 a.ordinal = 3;
1050 let mut b = source("b.md", "B", 0.5, "b");
1051 b.ordinal = 7;
1052 p.set_turn(1, vec![a, b], &noop_tx());
1053 p.settle().await;
1054 p.preview.toggle(Some(VaultPath::new("a.md")));
1055 p.focus_source(7);
1056 assert_eq!(
1057 p.selected_source().map(|s| s.ordinal),
1058 Some(7),
1059 "resolved ordinal 7 to its row through the engine, not ordinal-1"
1060 );
1061 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1062 assert!(p.preview.is_collapsed());
1063 p.focus_source(99);
1065 assert_eq!(p.selected_source().map(|s| s.ordinal), Some(7));
1066 }
1067
1068 #[tokio::test]
1074 async fn refresh_applies_rows_synchronously_no_redraw_needed() {
1075 let mut p = test_panel().await;
1076 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1077 p.refresh(1, vec![source("a.md", "A", 0.9, "alpha body")], &tx);
1078 assert_eq!(
1080 p.match_count(),
1081 1,
1082 "refresh's rows are applied synchronously"
1083 );
1084 assert!(!p.list.is_loading(), "no async load is in flight");
1085 assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1086 let mut redraws = 0;
1089 while let Ok(ev) = rx.try_recv() {
1090 if matches!(ev, AppEvent::Redraw) {
1091 redraws += 1;
1092 }
1093 }
1094 assert_eq!(redraws, 0, "no Redraw wake is needed for the sync row set");
1095 }
1096
1097 #[tokio::test]
1101 async fn cross_turn_focus_source_applies_immediately() {
1102 let mut p = test_panel().await;
1103 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1104 let mut a = source("a.md", "A", 0.9, "a");
1105 a.ordinal = 3;
1106 let mut b = source("b.md", "B", 0.5, "b");
1107 b.ordinal = 7;
1108 p.set_turn(2, vec![a, b], &tx);
1110 p.focus_source(7);
1111 assert_eq!(
1112 p.selected_source().map(|s| s.ordinal),
1113 Some(7),
1114 "citation focus applied in the same tick as set_turn"
1115 );
1116 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1117 }
1118
1119 #[tokio::test]
1124 async fn set_turn_then_open_reader_same_tick_opens_first_press() {
1125 let (_dir, vault) = test_vault().await;
1126 vault
1127 .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1128 .await
1129 .unwrap();
1130 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1131 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1132 p.set_turn(9, vec![source("a.md", "ha", 0.9, "alpha text")], &tx);
1134 p.open_reader(0, &tx);
1135 assert!(
1136 p.preview.is_context(),
1137 "open_reader opens the preview on the first press"
1138 );
1139 assert_eq!(
1140 selected_heading(&p).as_deref(),
1141 Some("ha"),
1142 "the requested source is selected"
1143 );
1144 assert_eq!(
1145 p.loaded.as_ref().map(|l| l.path.clone()),
1146 Some(VaultPath::new("a.md")),
1147 "the note load is anchored to the opened source"
1148 );
1149 }
1150
1151 #[tokio::test]
1154 async fn filter_input_narrows_sources_by_heading_or_path_text() {
1155 let mut p = test_panel().await;
1156 p.set_turn(
1157 1,
1158 vec![
1159 source("alpha.md", "Alpha section", 0.9, "a"),
1160 source("beta.md", "Beta section", 0.5, "b"),
1161 source("gamma.md", "Gamma section", 0.3, "g"),
1162 ],
1163 &noop_tx(),
1164 );
1165 p.settle().await;
1166 assert_eq!(p.match_count(), 3, "no filter shows every source");
1167 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1168 assert_eq!(p.list.focus(), Focus::List);
1170 p.handle_input(&InputEvent::Key(key(KeyCode::Char('i'))), &tx);
1171 assert_eq!(p.list.focus(), Focus::Input, "`i` reveals the filter input");
1172 for c in ['B', 'e', 't', 'a'] {
1173 p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1174 }
1175 p.settle().await;
1176 assert_eq!(p.match_count(), 1, "typed filter narrows to the match");
1177 assert_eq!(selected_heading(&p).as_deref(), Some("Beta section"));
1178 }
1179
1180 #[tokio::test]
1181 async fn slash_also_reveals_the_filter_and_matches_path_text() {
1182 let mut p = test_panel().await;
1183 p.set_turn(
1184 1,
1185 vec![
1186 source("notes/alpha.md", "One", 0.9, "a"),
1187 source("journal/beta.md", "Two", 0.5, "b"),
1188 ],
1189 &noop_tx(),
1190 );
1191 p.settle().await;
1192 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1193 p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &tx);
1194 assert_eq!(p.list.focus(), Focus::Input, "`/` reveals the filter input");
1195 for c in ['j', 'o', 'u', 'r'] {
1196 p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1197 }
1198 p.settle().await;
1199 assert_eq!(p.match_count(), 1, "path text filters too");
1200 assert_eq!(selected_heading(&p).as_deref(), Some("Two"));
1201 }
1202
1203 #[tokio::test]
1206 async fn enter_and_l_cycle_forward_h_cycles_back() {
1207 let mut p = test_panel().await;
1208 two_source_panel(&mut p).await;
1209 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1210 assert!(p.preview.is_collapsed());
1211
1212 p.handle_input(&InputEvent::Key(key(KeyCode::Enter)), &tx);
1213 assert!(p.preview.is_context(), "Enter: Collapsed -> Context");
1214 p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1215 assert!(p.preview.is_full(), "l: Context -> Full");
1216 p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1217 assert!(p.preview.is_collapsed(), "l: Full -> Collapsed (wraps)");
1218
1219 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());
1223 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1224 assert!(p.preview.is_context(), "h: Full -> Context");
1225 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1226 assert!(p.preview.is_collapsed(), "h: Context -> Collapsed");
1227 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1228 assert!(p.preview.is_collapsed(), "h at Collapsed stays Collapsed");
1229 }
1230
1231 #[tokio::test]
1232 async fn esc_steps_back_then_bubbles_to_thread() {
1233 let mut p = test_panel().await;
1234 two_source_panel(&mut p).await;
1235 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1236 p.preview.toggle(Some(VaultPath::new("a.md"))); let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1239 assert_eq!(st, EventState::Consumed);
1240 assert!(p.preview.is_collapsed(), "Esc steps back one reveal state");
1241
1242 let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1245 assert_eq!(
1246 st,
1247 EventState::NotConsumed,
1248 "Collapsed Esc -> back to thread"
1249 );
1250 }
1251
1252 #[tokio::test]
1253 async fn jk_moves_selection_within_bounds() {
1254 let mut p = test_panel().await;
1255 two_source_panel(&mut p).await;
1256 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1257
1258 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1259 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1260 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1261 assert_eq!(
1262 selected_heading(&p).as_deref(),
1263 Some("B"),
1264 "clamped at the last row"
1265 );
1266 p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1267 assert_eq!(selected_heading(&p).as_deref(), Some("A"));
1268 p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1269 assert_eq!(
1270 selected_heading(&p).as_deref(),
1271 Some("A"),
1272 "clamped at the first row"
1273 );
1274 }
1275
1276 async fn assert_opens_selected(setup: impl Fn(&mut SourcesPanel), open: KeyEvent) {
1279 let mut p = test_panel().await;
1280 two_source_panel(&mut p).await;
1281 select_index(&mut p, 1).await;
1282 setup(&mut p);
1283 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1284 let st = p.handle_input(&InputEvent::Key(open), &tx);
1285 assert_eq!(st, EventState::Consumed);
1286 let mut opened = None;
1287 while let Ok(ev) = rx.try_recv() {
1288 if let AppEvent::OpenPath { path, .. } = ev {
1289 opened = Some(path);
1290 }
1291 }
1292 assert_eq!(
1293 opened,
1294 Some(VaultPath::new("b.md")),
1295 "opened the selected source"
1296 );
1297 }
1298
1299 #[tokio::test]
1300 async fn o_opens_selected_from_every_reveal_state() {
1301 assert_opens_selected(|_p| {}, key(KeyCode::Char('o'))).await;
1303 assert_opens_selected(
1304 |p| p.preview.toggle(Some(VaultPath::new("b.md"))),
1305 key(KeyCode::Char('o')),
1306 )
1307 .await;
1308 assert_opens_selected(
1309 |p| {
1310 p.preview.toggle(Some(VaultPath::new("b.md")));
1311 p.preview.toggle(Some(VaultPath::new("b.md")));
1312 },
1313 key(KeyCode::Char('o')),
1314 )
1315 .await;
1316 }
1317
1318 #[tokio::test]
1319 async fn followlink_ctrl_n_opens_selected() {
1320 assert_opens_selected(|_p| {}, ctrl(KeyCode::Char('n'))).await;
1321 assert_opens_selected(
1323 |p| {
1324 p.preview.toggle(Some(VaultPath::new("b.md")));
1325 p.preview.toggle(Some(VaultPath::new("b.md")));
1326 },
1327 ctrl(KeyCode::Char('n')),
1328 )
1329 .await;
1330 }
1331
1332 async fn assert_yanks(k: KeyEvent) {
1335 let mut p = test_panel().await;
1336 two_source_panel(&mut p).await;
1337 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1338 let st = p.handle_input(&InputEvent::Key(k), &tx);
1339 assert_eq!(st, EventState::Consumed);
1340 let mut flashed = false;
1341 while let Ok(ev) = rx.try_recv() {
1342 if matches!(ev, AppEvent::FlashMessage(_)) {
1343 flashed = true;
1344 }
1345 }
1346 assert!(
1347 flashed,
1348 "yank emits a flash message (ok or clipboard error)"
1349 );
1350 }
1351
1352 #[tokio::test]
1353 async fn plain_y_and_ctrl_y_both_yank() {
1354 assert_yanks(key(KeyCode::Char('y'))).await;
1355 assert_yanks(ctrl(KeyCode::Char('y'))).await;
1356 }
1357
1358 #[tokio::test]
1361 async fn reader_note_for_the_wrong_path_is_dropped() {
1362 let mut p = test_panel().await;
1363 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1364 p.loaded = Some(LoadedNote {
1365 path: VaultPath::new("a.md"),
1366 ordinal: 0,
1367 content: ReaderContent::Loading,
1368 });
1369 p.handle_data(AskData::ReaderNote {
1370 path: VaultPath::new("other.md"),
1371 text: Some("nope".to_string()),
1372 });
1373 assert!(
1374 matches!(p.loaded.as_ref().unwrap().content, ReaderContent::Loading),
1375 "wrong-path ReaderNote must be dropped, not accepted"
1376 );
1377 }
1378
1379 #[tokio::test]
1380 async fn reader_note_for_the_right_path_loads_and_highlights() {
1381 let mut p = test_panel().await;
1382 p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1383 p.settle().await;
1384 p.loaded = Some(LoadedNote {
1385 path: VaultPath::new("a.md"),
1386 ordinal: 0,
1387 content: ReaderContent::Loading,
1388 });
1389 p.handle_data(AskData::ReaderNote {
1390 path: VaultPath::new("a.md"),
1391 text: Some("# a\nalpha body\n# b\nbeta body\n".to_string()),
1392 });
1393 match &p.loaded.as_ref().unwrap().content {
1394 ReaderContent::Loaded { text, highlight } => {
1395 let r = highlight.clone().expect("chunk resolves");
1396 assert_eq!(&text[r], "beta body");
1397 }
1398 _ => panic!("expected Loaded"),
1399 }
1400 }
1401
1402 #[tokio::test]
1403 async fn reader_note_load_failure_is_recorded() {
1404 let mut p = test_panel().await;
1405 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1406 p.loaded = Some(LoadedNote {
1407 path: VaultPath::new("a.md"),
1408 ordinal: 0,
1409 content: ReaderContent::Loading,
1410 });
1411 p.handle_data(AskData::ReaderNote {
1412 path: VaultPath::new("a.md"),
1413 text: None,
1414 });
1415 assert!(matches!(
1416 p.loaded.as_ref().unwrap().content,
1417 ReaderContent::Failed
1418 ));
1419 }
1420
1421 #[tokio::test]
1422 async fn handle_data_ignores_answer_ready() {
1423 let mut p = test_panel().await;
1424 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1425 p.loaded = Some(LoadedNote {
1426 path: VaultPath::new("a.md"),
1427 ordinal: 0,
1428 content: ReaderContent::Loading,
1429 });
1430 p.handle_data(AskData::AnswerReady {
1431 turn_id: 1,
1432 result: Ok(("x".into(), vec![])),
1433 });
1434 assert!(matches!(
1435 p.loaded.as_ref().unwrap().content,
1436 ReaderContent::Loading
1437 ));
1438 }
1439
1440 #[tokio::test]
1441 async fn open_reader_opens_preview_and_round_trips_a_real_vault() {
1442 let (_dir, vault) = test_vault().await;
1443 let path = VaultPath::new("note.md");
1444 vault.create_note(&path, "# h\nbody text\n").await.unwrap();
1445
1446 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1447 p.set_turn(
1448 1,
1449 vec![source("note.md", "h", 0.9, "body text")],
1450 &noop_tx(),
1451 );
1452 p.settle().await;
1453 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1454 p.open_reader(0, &tx);
1455 assert!(
1456 p.preview.is_context(),
1457 "open_reader opens the Context preview"
1458 );
1459
1460 let event = rx.recv().await.expect("open_reader spawns a ReaderNote");
1461 let AppEvent::Ask(data) = event else {
1462 panic!("expected an Ask event");
1463 };
1464 p.handle_data(data);
1465 match &p.loaded.as_ref().unwrap().content {
1466 ReaderContent::Loaded { text, .. } => assert_eq!(text, "# h\nbody text\n"),
1467 _ => panic!("expected Loaded"),
1468 }
1469 }
1470
1471 #[tokio::test]
1472 async fn navigating_in_context_reloads_for_the_new_source() {
1473 let (_dir, vault) = test_vault().await;
1474 std::mem::forget(_dir);
1475 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1476 two_source_panel(&mut p).await;
1477 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1478 p.preview.toggle(Some(VaultPath::new("a.md"))); p.ensure_loaded(&tx);
1480 assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("a.md"));
1481 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1484 assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("b.md"));
1485 }
1486
1487 fn buffer_text(p: &mut SourcesPanel, w: u16, h: u16) -> String {
1490 let theme = Theme::default();
1491 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
1492 term.draw(|f| {
1493 let area = f.area();
1494 p.render(f, area, &theme, true);
1495 })
1496 .unwrap();
1497 let buf = term.backend().buffer().clone();
1498 (0..buf.area.height)
1499 .map(|y| {
1500 (0..buf.area.width)
1501 .map(|x| buf[(x, y)].symbol())
1502 .collect::<String>()
1503 })
1504 .collect::<Vec<_>>()
1505 .join("\n")
1506 }
1507
1508 #[tokio::test]
1509 async fn row_render_carries_rank_and_score() {
1510 let mut p = test_panel().await;
1511 p.set_turn(
1512 1,
1513 vec![
1514 dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1515 source("b.md", "Beta section", 0.42, "beta body"),
1516 ],
1517 &noop_tx(),
1518 );
1519 p.settle().await;
1520 let text = buffer_text(&mut p, 60, 11);
1523 assert!(text.contains("1 "), "rank 1 leads the first row: {text}");
1524 assert!(text.contains("2 "), "rank 2 leads the second row: {text}");
1525 assert!(text.contains("90%"), "score percent shown: {text}");
1526 assert!(text.contains("42%"), "second score shown: {text}");
1527 assert!(text.contains("2026-04-08"), "date kept: {text}");
1528 assert!(
1529 text.contains('\u{b7}'),
1530 "date \u{b7} heading separation: {text}"
1531 );
1532 assert!(text.contains("Afternoon"), "heading kept: {text}");
1533 }
1534
1535 #[tokio::test]
1540 async fn filter_box_is_bordered_and_always_visible() {
1541 let mut p = test_panel().await;
1542 p.set_turn(
1543 1,
1544 vec![source("a.md", "Alpha", 0.9, "alpha body")],
1545 &noop_tx(),
1546 );
1547 p.settle().await;
1548
1549 assert_eq!(p.list.focus(), Focus::List, "Sources opens on the list");
1553 let text = buffer_text(&mut p, 40, 10);
1554 assert!(
1555 text.contains("filter"),
1556 "filter box shows in list focus, before `/`/`i`: {text}"
1557 );
1558 assert!(
1559 text.contains('\u{250c}') || text.contains('\u{2500}'),
1560 "filter field is boxed (bordered), not a bare line: {text}"
1561 );
1562
1563 p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &noop_tx());
1566 assert_eq!(p.list.focus(), Focus::Input);
1567 let text = buffer_text(&mut p, 40, 10);
1568 assert!(
1569 text.contains("filter"),
1570 "filter box stays visible in input focus: {text}"
1571 );
1572 }
1573
1574 #[tokio::test]
1578 async fn zero_match_filter_shows_no_results() {
1579 let mut p = test_panel().await;
1580 p.set_turn(1, vec![source("a.md", "Alpha", 0.9, "body")], &noop_tx());
1581 p.settle().await;
1582 p.list.set_query("zzznomatch");
1583 assert_eq!(p.list.visible_rows().len(), 0, "filter narrows to nothing");
1584 let text = buffer_text(&mut p, 40, 10);
1585 assert!(
1586 text.contains("No results"),
1587 "zero-match filter shows the No results message: {text}"
1588 );
1589 }
1590
1591 #[tokio::test]
1595 async fn context_list_pane_shrinks_when_filter_narrows() {
1596 let mut p = test_panel().await;
1597 let srcs: Vec<_> = (0..10)
1598 .map(|i| source(&format!("n{i}.md"), &format!("Alpha{i}"), 0.9, "body"))
1599 .collect();
1600 p.set_turn(1, srcs, &noop_tx());
1601 p.settle().await;
1602 p.preview.toggle(Some(VaultPath::new("n0.md")));
1605 let mut text = String::new();
1606 for i in 0..40 {
1607 text.push_str(&format!("noteline{i}\n"));
1608 }
1609 p.loaded = Some(LoadedNote {
1610 path: VaultPath::new("n0.md"),
1611 ordinal: 0,
1612 content: ReaderContent::Loaded {
1613 text,
1614 highlight: None,
1615 },
1616 });
1617 let count_lines = |p: &mut SourcesPanel| buffer_text(p, 40, 20).matches("noteline").count();
1618 let before = count_lines(&mut p);
1619 p.list.set_query("Alpha3");
1621 assert_eq!(p.list.visible_rows().len(), 1, "filter narrows to one");
1622 let after = count_lines(&mut p);
1623 assert!(
1624 after > before,
1625 "preview gained the space the shrunken list gave up: before={before} after={after}"
1626 );
1627 }
1628
1629 #[tokio::test]
1630 async fn render_does_not_panic_across_states_and_sizes() {
1631 let mut p = test_panel().await;
1632 buffer_text(&mut p, 40, 10); p.set_turn(
1635 1,
1636 vec![
1637 dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1638 source("b.md", "Beta section", 0.4, "beta body"),
1639 ],
1640 &noop_tx(),
1641 );
1642 p.settle().await;
1643 buffer_text(&mut p, 40, 10); select_index(&mut p, 1).await;
1645 buffer_text(&mut p, 40, 3); p.preview.toggle(Some(VaultPath::new("b.md")));
1649 p.loaded = Some(LoadedNote {
1650 path: VaultPath::new("b.md"),
1651 ordinal: 0,
1652 content: ReaderContent::Loaded {
1653 text: "# Beta\nbeta body\nmore\n".to_string(),
1654 highlight: Some(7..16),
1655 },
1656 });
1657 buffer_text(&mut p, 40, 12); p.preview.toggle(Some(VaultPath::new("b.md"))); buffer_text(&mut p, 40, 12); p.loaded = Some(LoadedNote {
1663 path: VaultPath::new("b.md"),
1664 ordinal: 0,
1665 content: ReaderContent::Loading,
1666 });
1667 buffer_text(&mut p, 40, 12);
1668 p.loaded = Some(LoadedNote {
1669 path: VaultPath::new("b.md"),
1670 ordinal: 0,
1671 content: ReaderContent::Failed,
1672 });
1673 buffer_text(&mut p, 40, 12);
1674
1675 buffer_text(&mut p, 3, 3); buffer_text(&mut p, 0, 0); }
1678
1679 #[tokio::test]
1680 async fn full_preview_anchors_scroll_to_the_highlighted_section() {
1681 let mut p = test_panel().await;
1682 p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1683 p.settle().await;
1684 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); let mut body = String::new();
1690 for i in 0..8 {
1691 body.push_str(&format!("line{i}\n"));
1692 }
1693 body.push_str("beta body\n");
1694 for i in 0..8 {
1695 body.push_str(&format!("tail{i}\n"));
1696 }
1697 let start = body.find("beta body").unwrap();
1698 p.loaded = Some(LoadedNote {
1699 path: VaultPath::new("a.md"),
1700 ordinal: 0,
1701 content: ReaderContent::Loaded {
1702 text: body,
1703 highlight: Some(start..start + "beta body".len()),
1704 },
1705 });
1706 buffer_text(&mut p, 40, 6);
1709 assert!(
1710 p.preview.scroll_offset() > 0,
1711 "preview anchored the scroll to the section, offset={}",
1712 p.preview.scroll_offset()
1713 );
1714 }
1715
1716 #[tokio::test]
1719 async fn full_down_scrolls_content_not_the_list() {
1720 let mut p = test_panel().await;
1721 two_source_panel(&mut p).await;
1722 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1723 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); let mut body = String::from("alpha body\n");
1728 for i in 0..20 {
1729 body.push_str(&format!("line{i}\n"));
1730 }
1731 p.loaded = Some(LoadedNote {
1732 path: VaultPath::new("a.md"),
1733 ordinal: 0,
1734 content: ReaderContent::Loaded {
1735 text: body,
1736 highlight: Some(0.."alpha body".len()),
1737 },
1738 });
1739 buffer_text(&mut p, 40, 6); assert_eq!(p.preview.scroll_offset(), 0);
1741 p.handle_input(&InputEvent::Key(key(KeyCode::Down)), &tx);
1743 assert_eq!(
1744 selected_heading(&p).as_deref(),
1745 Some("A"),
1746 "Down in Full scrolls content, not the list"
1747 );
1748 assert!(
1749 p.preview.scroll_offset() > 0,
1750 "Full + Down scrolled the content, offset={}",
1751 p.preview.scroll_offset()
1752 );
1753 }
1754
1755 #[tokio::test]
1756 async fn full_j_still_moves_the_list_selection() {
1757 let mut p = test_panel().await;
1758 two_source_panel(&mut p).await;
1759 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1760 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); assert!(p.preview.is_full());
1763 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1765 assert_eq!(
1766 selected_heading(&p).as_deref(),
1767 Some("B"),
1768 "j moves the list selection in Full"
1769 );
1770 }
1771
1772 #[tokio::test]
1773 async fn wheel_scrolls_the_open_preview_and_is_ignored_when_collapsed() {
1774 let mut p = test_panel().await;
1775 two_source_panel(&mut p).await;
1776 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1777 let wheel = |kind| {
1780 InputEvent::Mouse(MouseEvent {
1781 kind,
1782 column: 0,
1783 row: 0,
1784 modifiers: KeyModifiers::NONE,
1785 })
1786 };
1787 assert_eq!(
1788 p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1789 EventState::NotConsumed,
1790 "collapsed preview with no recorded rect does not eat the wheel"
1791 );
1792 p.preview.toggle(Some(VaultPath::new("a.md")));
1794 p.preview.toggle(Some(VaultPath::new("a.md")));
1795 let mut body = String::from("alpha body\n");
1796 for i in 0..20 {
1797 body.push_str(&format!("line{i}\n"));
1798 }
1799 p.loaded = Some(LoadedNote {
1800 path: VaultPath::new("a.md"),
1801 ordinal: 0,
1802 content: ReaderContent::Loaded {
1803 text: body,
1804 highlight: Some(0.."alpha body".len()),
1805 },
1806 });
1807 buffer_text(&mut p, 40, 6);
1808 assert_eq!(
1809 p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1810 EventState::Consumed,
1811 "open preview consumes the wheel"
1812 );
1813 assert!(p.preview.scroll_offset() > 0, "wheel scrolled the content");
1814 }
1815
1816 #[tokio::test]
1819 async fn same_note_different_heading_recomputes_highlight_without_reload() {
1820 let (_dir, vault) = test_vault().await;
1821 std::mem::forget(_dir);
1822 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1823 let mut s0 = source("doc.md", "Alpha", 0.9, "alpha body");
1825 s0.ordinal = 1;
1826 let mut s1 = source("doc.md", "Beta", 0.8, "beta body");
1827 s1.ordinal = 2;
1828 p.set_turn(1, vec![s0, s1], &noop_tx());
1829 p.settle().await;
1830 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1831 p.preview.toggle(Some(VaultPath::new("doc.md"))); p.ensure_loaded(&tx); let note = "# Alpha\nalpha body\n# Beta\nbeta body\n".to_string();
1835 p.handle_data(AskData::ReaderNote {
1836 path: VaultPath::new("doc.md"),
1837 text: Some(note),
1838 });
1839 let first = match &p.loaded.as_ref().unwrap().content {
1840 ReaderContent::Loaded { text, highlight } => {
1841 let r = highlight.clone().expect("section resolves");
1842 assert_eq!(&text[r.clone()], "alpha body");
1843 r
1844 }
1845 _ => panic!("expected Loaded"),
1846 };
1847 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1850 match &p.loaded.as_ref().unwrap().content {
1851 ReaderContent::Loaded { text, highlight } => {
1852 let r = highlight.clone().expect("re-resolved");
1853 assert_eq!(&text[r.clone()], "beta body");
1854 assert_ne!(r, first, "highlight moved to the new section");
1855 }
1856 _ => panic!("must reuse the loaded note, not reload"),
1857 }
1858 assert_eq!(
1859 p.loaded.as_ref().unwrap().ordinal,
1860 2,
1861 "re-keyed to the new source"
1862 );
1863 }
1864
1865 #[tokio::test]
1868 async fn open_reader_stays_full_and_re_points_to_the_source() {
1869 let (_dir, vault) = test_vault().await;
1870 vault
1871 .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1872 .await
1873 .unwrap();
1874 vault
1875 .create_note(&VaultPath::new("b.md"), "# hb\nbeta text\n")
1876 .await
1877 .unwrap();
1878 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1879 let mut s0 = source("a.md", "ha", 0.9, "alpha text");
1880 s0.ordinal = 1;
1881 let mut s1 = source("b.md", "hb", 0.8, "beta text");
1882 s1.ordinal = 2;
1883 p.set_turn(1, vec![s0, s1], &noop_tx());
1884 p.settle().await;
1885 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1886 select_index(&mut p, 1).await;
1888 p.preview.toggle(Some(VaultPath::new("b.md"))); p.preview.toggle(Some(VaultPath::new("b.md"))); assert!(p.preview.is_full());
1891 p.open_reader(0, &tx);
1893 assert!(p.preview.is_full(), "open_reader keeps the Full reveal");
1894 assert_eq!(selected_heading(&p).as_deref(), Some("ha"));
1895 let ev = rx.recv().await.expect("open_reader spawns a ReaderNote");
1897 let AppEvent::Ask(data) = ev else {
1898 panic!("expected an Ask event");
1899 };
1900 p.handle_data(data);
1901 match &p.loaded.as_ref().unwrap().content {
1902 ReaderContent::Loaded { text, highlight } => {
1903 assert_eq!(text, "# ha\nalpha text\n", "source 0's note is shown");
1904 let r = highlight.clone().expect("section resolves");
1905 assert_eq!(&text[r], "alpha text");
1906 }
1907 _ => panic!("expected Loaded for source 0"),
1908 }
1909 }
1910}