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(dir.path())).await.unwrap();
918 (dir, vault)
919 }
920
921 fn key_bindings() -> KeyBindings {
922 crate::settings::AppSettings::default().key_bindings.clone()
923 }
924
925 fn noop_tx() -> AppTx {
930 tokio::sync::mpsc::unbounded_channel().0
931 }
932
933 async fn test_panel() -> SourcesPanel {
937 let (dir, vault) = test_vault().await;
938 std::mem::forget(dir);
939 SourcesPanel::new(Arc::new(vault), &key_bindings())
940 }
941
942 fn key(code: KeyCode) -> KeyEvent {
943 KeyEvent::new(code, KeyModifiers::NONE)
944 }
945
946 fn ctrl(code: KeyCode) -> KeyEvent {
947 KeyEvent::new(code, KeyModifiers::CONTROL)
948 }
949
950 async fn two_source_panel(p: &mut SourcesPanel) {
953 p.set_turn(
954 1,
955 vec![
956 source("a.md", "A", 0.9, "alpha body"),
957 source("b.md", "B", 0.5, "beta body"),
958 ],
959 &noop_tx(),
960 );
961 p.settle().await;
962 }
963
964 async fn select_index(p: &mut SourcesPanel, i: usize) {
966 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
967 for _ in 0..i {
968 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
969 }
970 }
971
972 fn selected_heading(p: &SourcesPanel) -> Option<String> {
973 p.selected_source().map(|s| s.heading.clone())
974 }
975
976 fn nth_heading(p: &SourcesPanel, i: usize) -> Option<String> {
979 p.source_at(i).map(|s| s.heading.clone())
980 }
981
982 #[test]
983 fn score_percent_rounds_and_clamps() {
984 assert_eq!(score_percent(0.874), 87);
985 assert_eq!(score_percent(1.5), 100);
986 assert_eq!(score_percent(-0.2), 0);
987 }
988
989 #[test]
990 fn dated_source_display_heading_separates_date_and_heading() {
991 let s = dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9);
992 assert_eq!(s.display_heading(), "2026-04-08 \u{b7} Afternoon");
993 assert_eq!(source("n.md", "Ideas", 0.5, "").display_heading(), "Ideas");
994 }
995
996 #[tokio::test]
997 async fn new_panel_starts_empty_and_collapsed() {
998 let p = test_panel().await;
999 assert_eq!(p.match_count(), 0);
1000 assert!(p.preview.is_collapsed());
1001 }
1002
1003 #[tokio::test]
1004 async fn set_turn_populates_and_collapses() {
1005 let mut p = test_panel().await;
1006 p.set_turn(1, vec![source("a.md", "A", 0.9, "text a")], &noop_tx());
1007 p.settle().await;
1008 assert_eq!(p.turn_id, Some(1));
1009 assert_eq!(p.match_count(), 1, "the engine mirrors the turn's rows");
1010 assert!(p.preview.is_collapsed());
1011 }
1012
1013 #[tokio::test]
1014 async fn set_turn_same_id_is_a_noop_and_keeps_selection() {
1015 let mut p = test_panel().await;
1016 two_source_panel(&mut p).await;
1017 select_index(&mut p, 1).await;
1018 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1019 p.set_turn(1, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1020 p.settle().await;
1021 assert_eq!(
1022 selected_heading(&p).as_deref(),
1023 Some("B"),
1024 "selection must survive a same-id set_turn"
1025 );
1026 assert_eq!(p.match_count(), 2, "rows must not be replaced");
1027 assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1028 }
1029
1030 #[tokio::test]
1031 async fn set_turn_new_id_resets_selection_and_collapses() {
1032 let mut p = test_panel().await;
1033 two_source_panel(&mut p).await;
1034 select_index(&mut p, 1).await;
1035 p.preview.toggle(Some(VaultPath::new("a.md")));
1036 p.set_turn(2, vec![source("c.md", "C", 0.1, "text c")], &noop_tx());
1037 p.settle().await;
1038 assert_eq!(selected_heading(&p).as_deref(), Some("C"));
1039 assert_eq!(p.match_count(), 1);
1040 assert!(p.preview.is_collapsed());
1041 }
1042
1043 #[tokio::test]
1044 async fn focus_source_points_selection_by_ordinal_through_the_engine() {
1045 let mut p = test_panel().await;
1046 let mut a = source("a.md", "A", 0.9, "a");
1047 a.ordinal = 3;
1048 let mut b = source("b.md", "B", 0.5, "b");
1049 b.ordinal = 7;
1050 p.set_turn(1, vec![a, b], &noop_tx());
1051 p.settle().await;
1052 p.preview.toggle(Some(VaultPath::new("a.md")));
1053 p.focus_source(7);
1054 assert_eq!(
1055 p.selected_source().map(|s| s.ordinal),
1056 Some(7),
1057 "resolved ordinal 7 to its row through the engine, not ordinal-1"
1058 );
1059 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1060 assert!(p.preview.is_collapsed());
1061 p.focus_source(99);
1063 assert_eq!(p.selected_source().map(|s| s.ordinal), Some(7));
1064 }
1065
1066 #[tokio::test]
1072 async fn refresh_applies_rows_synchronously_no_redraw_needed() {
1073 let mut p = test_panel().await;
1074 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1075 p.refresh(1, vec![source("a.md", "A", 0.9, "alpha body")], &tx);
1076 assert_eq!(
1078 p.match_count(),
1079 1,
1080 "refresh's rows are applied synchronously"
1081 );
1082 assert!(!p.list.is_loading(), "no async load is in flight");
1083 assert_eq!(nth_heading(&p, 0).as_deref(), Some("A"));
1084 let mut redraws = 0;
1087 while let Ok(ev) = rx.try_recv() {
1088 if matches!(ev, AppEvent::Redraw) {
1089 redraws += 1;
1090 }
1091 }
1092 assert_eq!(redraws, 0, "no Redraw wake is needed for the sync row set");
1093 }
1094
1095 #[tokio::test]
1099 async fn cross_turn_focus_source_applies_immediately() {
1100 let mut p = test_panel().await;
1101 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1102 let mut a = source("a.md", "A", 0.9, "a");
1103 a.ordinal = 3;
1104 let mut b = source("b.md", "B", 0.5, "b");
1105 b.ordinal = 7;
1106 p.set_turn(2, vec![a, b], &tx);
1108 p.focus_source(7);
1109 assert_eq!(
1110 p.selected_source().map(|s| s.ordinal),
1111 Some(7),
1112 "citation focus applied in the same tick as set_turn"
1113 );
1114 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1115 }
1116
1117 #[tokio::test]
1122 async fn set_turn_then_open_reader_same_tick_opens_first_press() {
1123 let (_dir, vault) = test_vault().await;
1124 vault
1125 .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1126 .await
1127 .unwrap();
1128 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1129 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1130 p.set_turn(9, vec![source("a.md", "ha", 0.9, "alpha text")], &tx);
1132 p.open_reader(0, &tx);
1133 assert!(
1134 p.preview.is_context(),
1135 "open_reader opens the preview on the first press"
1136 );
1137 assert_eq!(
1138 selected_heading(&p).as_deref(),
1139 Some("ha"),
1140 "the requested source is selected"
1141 );
1142 assert_eq!(
1143 p.loaded.as_ref().map(|l| l.path.clone()),
1144 Some(VaultPath::new("a.md")),
1145 "the note load is anchored to the opened source"
1146 );
1147 }
1148
1149 #[tokio::test]
1152 async fn filter_input_narrows_sources_by_heading_or_path_text() {
1153 let mut p = test_panel().await;
1154 p.set_turn(
1155 1,
1156 vec![
1157 source("alpha.md", "Alpha section", 0.9, "a"),
1158 source("beta.md", "Beta section", 0.5, "b"),
1159 source("gamma.md", "Gamma section", 0.3, "g"),
1160 ],
1161 &noop_tx(),
1162 );
1163 p.settle().await;
1164 assert_eq!(p.match_count(), 3, "no filter shows every source");
1165 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1166 assert_eq!(p.list.focus(), Focus::List);
1168 p.handle_input(&InputEvent::Key(key(KeyCode::Char('i'))), &tx);
1169 assert_eq!(p.list.focus(), Focus::Input, "`i` reveals the filter input");
1170 for c in ['B', 'e', 't', 'a'] {
1171 p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1172 }
1173 p.settle().await;
1174 assert_eq!(p.match_count(), 1, "typed filter narrows to the match");
1175 assert_eq!(selected_heading(&p).as_deref(), Some("Beta section"));
1176 }
1177
1178 #[tokio::test]
1179 async fn slash_also_reveals_the_filter_and_matches_path_text() {
1180 let mut p = test_panel().await;
1181 p.set_turn(
1182 1,
1183 vec![
1184 source("notes/alpha.md", "One", 0.9, "a"),
1185 source("journal/beta.md", "Two", 0.5, "b"),
1186 ],
1187 &noop_tx(),
1188 );
1189 p.settle().await;
1190 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1191 p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &tx);
1192 assert_eq!(p.list.focus(), Focus::Input, "`/` reveals the filter input");
1193 for c in ['j', 'o', 'u', 'r'] {
1194 p.handle_input(&InputEvent::Key(key(KeyCode::Char(c))), &tx);
1195 }
1196 p.settle().await;
1197 assert_eq!(p.match_count(), 1, "path text filters too");
1198 assert_eq!(selected_heading(&p).as_deref(), Some("Two"));
1199 }
1200
1201 #[tokio::test]
1204 async fn enter_and_l_cycle_forward_h_cycles_back() {
1205 let mut p = test_panel().await;
1206 two_source_panel(&mut p).await;
1207 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1208 assert!(p.preview.is_collapsed());
1209
1210 p.handle_input(&InputEvent::Key(key(KeyCode::Enter)), &tx);
1211 assert!(p.preview.is_context(), "Enter: Collapsed -> Context");
1212 p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1213 assert!(p.preview.is_full(), "l: Context -> Full");
1214 p.handle_input(&InputEvent::Key(key(KeyCode::Char('l'))), &tx);
1215 assert!(p.preview.is_collapsed(), "l: Full -> Collapsed (wraps)");
1216
1217 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());
1221 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1222 assert!(p.preview.is_context(), "h: Full -> Context");
1223 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1224 assert!(p.preview.is_collapsed(), "h: Context -> Collapsed");
1225 p.handle_input(&InputEvent::Key(key(KeyCode::Char('h'))), &tx);
1226 assert!(p.preview.is_collapsed(), "h at Collapsed stays Collapsed");
1227 }
1228
1229 #[tokio::test]
1230 async fn esc_steps_back_then_bubbles_to_thread() {
1231 let mut p = test_panel().await;
1232 two_source_panel(&mut p).await;
1233 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1234 p.preview.toggle(Some(VaultPath::new("a.md"))); let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1237 assert_eq!(st, EventState::Consumed);
1238 assert!(p.preview.is_collapsed(), "Esc steps back one reveal state");
1239
1240 let st = p.handle_input(&InputEvent::Key(key(KeyCode::Esc)), &tx);
1243 assert_eq!(
1244 st,
1245 EventState::NotConsumed,
1246 "Collapsed Esc -> back to thread"
1247 );
1248 }
1249
1250 #[tokio::test]
1251 async fn jk_moves_selection_within_bounds() {
1252 let mut p = test_panel().await;
1253 two_source_panel(&mut p).await;
1254 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1255
1256 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1257 assert_eq!(selected_heading(&p).as_deref(), Some("B"));
1258 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1259 assert_eq!(
1260 selected_heading(&p).as_deref(),
1261 Some("B"),
1262 "clamped at the last row"
1263 );
1264 p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1265 assert_eq!(selected_heading(&p).as_deref(), Some("A"));
1266 p.handle_input(&InputEvent::Key(key(KeyCode::Char('k'))), &tx);
1267 assert_eq!(
1268 selected_heading(&p).as_deref(),
1269 Some("A"),
1270 "clamped at the first row"
1271 );
1272 }
1273
1274 async fn assert_opens_selected(setup: impl Fn(&mut SourcesPanel), open: KeyEvent) {
1277 let mut p = test_panel().await;
1278 two_source_panel(&mut p).await;
1279 select_index(&mut p, 1).await;
1280 setup(&mut p);
1281 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1282 let st = p.handle_input(&InputEvent::Key(open), &tx);
1283 assert_eq!(st, EventState::Consumed);
1284 let mut opened = None;
1285 while let Ok(ev) = rx.try_recv() {
1286 if let AppEvent::OpenPath { path, .. } = ev {
1287 opened = Some(path);
1288 }
1289 }
1290 assert_eq!(
1291 opened,
1292 Some(VaultPath::new("b.md")),
1293 "opened the selected source"
1294 );
1295 }
1296
1297 #[tokio::test]
1298 async fn o_opens_selected_from_every_reveal_state() {
1299 assert_opens_selected(|_p| {}, key(KeyCode::Char('o'))).await;
1301 assert_opens_selected(
1302 |p| p.preview.toggle(Some(VaultPath::new("b.md"))),
1303 key(KeyCode::Char('o')),
1304 )
1305 .await;
1306 assert_opens_selected(
1307 |p| {
1308 p.preview.toggle(Some(VaultPath::new("b.md")));
1309 p.preview.toggle(Some(VaultPath::new("b.md")));
1310 },
1311 key(KeyCode::Char('o')),
1312 )
1313 .await;
1314 }
1315
1316 #[tokio::test]
1317 async fn followlink_ctrl_n_opens_selected() {
1318 assert_opens_selected(|_p| {}, ctrl(KeyCode::Char('n'))).await;
1319 assert_opens_selected(
1321 |p| {
1322 p.preview.toggle(Some(VaultPath::new("b.md")));
1323 p.preview.toggle(Some(VaultPath::new("b.md")));
1324 },
1325 ctrl(KeyCode::Char('n')),
1326 )
1327 .await;
1328 }
1329
1330 async fn assert_yanks(k: KeyEvent) {
1333 let mut p = test_panel().await;
1334 two_source_panel(&mut p).await;
1335 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1336 let st = p.handle_input(&InputEvent::Key(k), &tx);
1337 assert_eq!(st, EventState::Consumed);
1338 let mut flashed = false;
1339 while let Ok(ev) = rx.try_recv() {
1340 if matches!(ev, AppEvent::FlashMessage(_)) {
1341 flashed = true;
1342 }
1343 }
1344 assert!(
1345 flashed,
1346 "yank emits a flash message (ok or clipboard error)"
1347 );
1348 }
1349
1350 #[tokio::test]
1351 async fn plain_y_and_ctrl_y_both_yank() {
1352 assert_yanks(key(KeyCode::Char('y'))).await;
1353 assert_yanks(ctrl(KeyCode::Char('y'))).await;
1354 }
1355
1356 #[tokio::test]
1359 async fn reader_note_for_the_wrong_path_is_dropped() {
1360 let mut p = test_panel().await;
1361 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1362 p.loaded = Some(LoadedNote {
1363 path: VaultPath::new("a.md"),
1364 ordinal: 0,
1365 content: ReaderContent::Loading,
1366 });
1367 p.handle_data(AskData::ReaderNote {
1368 path: VaultPath::new("other.md"),
1369 text: Some("nope".to_string()),
1370 });
1371 assert!(
1372 matches!(p.loaded.as_ref().unwrap().content, ReaderContent::Loading),
1373 "wrong-path ReaderNote must be dropped, not accepted"
1374 );
1375 }
1376
1377 #[tokio::test]
1378 async fn reader_note_for_the_right_path_loads_and_highlights() {
1379 let mut p = test_panel().await;
1380 p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1381 p.settle().await;
1382 p.loaded = Some(LoadedNote {
1383 path: VaultPath::new("a.md"),
1384 ordinal: 0,
1385 content: ReaderContent::Loading,
1386 });
1387 p.handle_data(AskData::ReaderNote {
1388 path: VaultPath::new("a.md"),
1389 text: Some("# a\nalpha body\n# b\nbeta body\n".to_string()),
1390 });
1391 match &p.loaded.as_ref().unwrap().content {
1392 ReaderContent::Loaded { text, highlight } => {
1393 let r = highlight.clone().expect("chunk resolves");
1394 assert_eq!(&text[r], "beta body");
1395 }
1396 _ => panic!("expected Loaded"),
1397 }
1398 }
1399
1400 #[tokio::test]
1401 async fn reader_note_load_failure_is_recorded() {
1402 let mut p = test_panel().await;
1403 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1404 p.loaded = Some(LoadedNote {
1405 path: VaultPath::new("a.md"),
1406 ordinal: 0,
1407 content: ReaderContent::Loading,
1408 });
1409 p.handle_data(AskData::ReaderNote {
1410 path: VaultPath::new("a.md"),
1411 text: None,
1412 });
1413 assert!(matches!(
1414 p.loaded.as_ref().unwrap().content,
1415 ReaderContent::Failed
1416 ));
1417 }
1418
1419 #[tokio::test]
1420 async fn handle_data_ignores_answer_ready() {
1421 let mut p = test_panel().await;
1422 p.set_turn(1, vec![source("a.md", "A", 0.9, "alpha body")], &noop_tx());
1423 p.loaded = Some(LoadedNote {
1424 path: VaultPath::new("a.md"),
1425 ordinal: 0,
1426 content: ReaderContent::Loading,
1427 });
1428 p.handle_data(AskData::AnswerReady {
1429 turn_id: 1,
1430 result: Ok(("x".into(), vec![])),
1431 });
1432 assert!(matches!(
1433 p.loaded.as_ref().unwrap().content,
1434 ReaderContent::Loading
1435 ));
1436 }
1437
1438 #[tokio::test]
1439 async fn open_reader_opens_preview_and_round_trips_a_real_vault() {
1440 let (_dir, vault) = test_vault().await;
1441 let path = VaultPath::new("note.md");
1442 vault.create_note(&path, "# h\nbody text\n").await.unwrap();
1443
1444 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1445 p.set_turn(
1446 1,
1447 vec![source("note.md", "h", 0.9, "body text")],
1448 &noop_tx(),
1449 );
1450 p.settle().await;
1451 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1452 p.open_reader(0, &tx);
1453 assert!(
1454 p.preview.is_context(),
1455 "open_reader opens the Context preview"
1456 );
1457
1458 let event = rx.recv().await.expect("open_reader spawns a ReaderNote");
1459 let AppEvent::Ask(data) = event else {
1460 panic!("expected an Ask event");
1461 };
1462 p.handle_data(data);
1463 match &p.loaded.as_ref().unwrap().content {
1464 ReaderContent::Loaded { text, .. } => assert_eq!(text, "# h\nbody text\n"),
1465 _ => panic!("expected Loaded"),
1466 }
1467 }
1468
1469 #[tokio::test]
1470 async fn navigating_in_context_reloads_for_the_new_source() {
1471 let (_dir, vault) = test_vault().await;
1472 std::mem::forget(_dir);
1473 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1474 two_source_panel(&mut p).await;
1475 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1476 p.preview.toggle(Some(VaultPath::new("a.md"))); p.ensure_loaded(&tx);
1478 assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("a.md"));
1479 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1482 assert_eq!(p.loaded.as_ref().unwrap().path, VaultPath::new("b.md"));
1483 }
1484
1485 fn buffer_text(p: &mut SourcesPanel, w: u16, h: u16) -> String {
1488 let theme = Theme::default();
1489 let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
1490 term.draw(|f| {
1491 let area = f.area();
1492 p.render(f, area, &theme, true);
1493 })
1494 .unwrap();
1495 let buf = term.backend().buffer().clone();
1496 (0..buf.area.height)
1497 .map(|y| {
1498 (0..buf.area.width)
1499 .map(|x| buf[(x, y)].symbol())
1500 .collect::<String>()
1501 })
1502 .collect::<Vec<_>>()
1503 .join("\n")
1504 }
1505
1506 #[tokio::test]
1507 async fn row_render_carries_rank_and_score() {
1508 let mut p = test_panel().await;
1509 p.set_turn(
1510 1,
1511 vec![
1512 dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1513 source("b.md", "Beta section", 0.42, "beta body"),
1514 ],
1515 &noop_tx(),
1516 );
1517 p.settle().await;
1518 let text = buffer_text(&mut p, 60, 11);
1521 assert!(text.contains("1 "), "rank 1 leads the first row: {text}");
1522 assert!(text.contains("2 "), "rank 2 leads the second row: {text}");
1523 assert!(text.contains("90%"), "score percent shown: {text}");
1524 assert!(text.contains("42%"), "second score shown: {text}");
1525 assert!(text.contains("2026-04-08"), "date kept: {text}");
1526 assert!(
1527 text.contains('\u{b7}'),
1528 "date \u{b7} heading separation: {text}"
1529 );
1530 assert!(text.contains("Afternoon"), "heading kept: {text}");
1531 }
1532
1533 #[tokio::test]
1538 async fn filter_box_is_bordered_and_always_visible() {
1539 let mut p = test_panel().await;
1540 p.set_turn(
1541 1,
1542 vec![source("a.md", "Alpha", 0.9, "alpha body")],
1543 &noop_tx(),
1544 );
1545 p.settle().await;
1546
1547 assert_eq!(p.list.focus(), Focus::List, "Sources opens on the list");
1551 let text = buffer_text(&mut p, 40, 10);
1552 assert!(
1553 text.contains("filter"),
1554 "filter box shows in list focus, before `/`/`i`: {text}"
1555 );
1556 assert!(
1557 text.contains('\u{250c}') || text.contains('\u{2500}'),
1558 "filter field is boxed (bordered), not a bare line: {text}"
1559 );
1560
1561 p.handle_input(&InputEvent::Key(key(KeyCode::Char('/'))), &noop_tx());
1564 assert_eq!(p.list.focus(), Focus::Input);
1565 let text = buffer_text(&mut p, 40, 10);
1566 assert!(
1567 text.contains("filter"),
1568 "filter box stays visible in input focus: {text}"
1569 );
1570 }
1571
1572 #[tokio::test]
1576 async fn zero_match_filter_shows_no_results() {
1577 let mut p = test_panel().await;
1578 p.set_turn(1, vec![source("a.md", "Alpha", 0.9, "body")], &noop_tx());
1579 p.settle().await;
1580 p.list.set_query("zzznomatch");
1581 assert_eq!(p.list.visible_rows().len(), 0, "filter narrows to nothing");
1582 let text = buffer_text(&mut p, 40, 10);
1583 assert!(
1584 text.contains("No results"),
1585 "zero-match filter shows the No results message: {text}"
1586 );
1587 }
1588
1589 #[tokio::test]
1593 async fn context_list_pane_shrinks_when_filter_narrows() {
1594 let mut p = test_panel().await;
1595 let srcs: Vec<_> = (0..10)
1596 .map(|i| source(&format!("n{i}.md"), &format!("Alpha{i}"), 0.9, "body"))
1597 .collect();
1598 p.set_turn(1, srcs, &noop_tx());
1599 p.settle().await;
1600 p.preview.toggle(Some(VaultPath::new("n0.md")));
1603 let mut text = String::new();
1604 for i in 0..40 {
1605 text.push_str(&format!("noteline{i}\n"));
1606 }
1607 p.loaded = Some(LoadedNote {
1608 path: VaultPath::new("n0.md"),
1609 ordinal: 0,
1610 content: ReaderContent::Loaded {
1611 text,
1612 highlight: None,
1613 },
1614 });
1615 let count_lines = |p: &mut SourcesPanel| buffer_text(p, 40, 20).matches("noteline").count();
1616 let before = count_lines(&mut p);
1617 p.list.set_query("Alpha3");
1619 assert_eq!(p.list.visible_rows().len(), 1, "filter narrows to one");
1620 let after = count_lines(&mut p);
1621 assert!(
1622 after > before,
1623 "preview gained the space the shrunken list gave up: before={before} after={after}"
1624 );
1625 }
1626
1627 #[tokio::test]
1628 async fn render_does_not_panic_across_states_and_sizes() {
1629 let mut p = test_panel().await;
1630 buffer_text(&mut p, 40, 10); p.set_turn(
1633 1,
1634 vec![
1635 dated_source("journal/2026-04-08.md", "Afternoon", "2026-04-08", 0.9),
1636 source("b.md", "Beta section", 0.4, "beta body"),
1637 ],
1638 &noop_tx(),
1639 );
1640 p.settle().await;
1641 buffer_text(&mut p, 40, 10); select_index(&mut p, 1).await;
1643 buffer_text(&mut p, 40, 3); p.preview.toggle(Some(VaultPath::new("b.md")));
1647 p.loaded = Some(LoadedNote {
1648 path: VaultPath::new("b.md"),
1649 ordinal: 0,
1650 content: ReaderContent::Loaded {
1651 text: "# Beta\nbeta body\nmore\n".to_string(),
1652 highlight: Some(7..16),
1653 },
1654 });
1655 buffer_text(&mut p, 40, 12); p.preview.toggle(Some(VaultPath::new("b.md"))); buffer_text(&mut p, 40, 12); p.loaded = Some(LoadedNote {
1661 path: VaultPath::new("b.md"),
1662 ordinal: 0,
1663 content: ReaderContent::Loading,
1664 });
1665 buffer_text(&mut p, 40, 12);
1666 p.loaded = Some(LoadedNote {
1667 path: VaultPath::new("b.md"),
1668 ordinal: 0,
1669 content: ReaderContent::Failed,
1670 });
1671 buffer_text(&mut p, 40, 12);
1672
1673 buffer_text(&mut p, 3, 3); buffer_text(&mut p, 0, 0); }
1676
1677 #[tokio::test]
1678 async fn full_preview_anchors_scroll_to_the_highlighted_section() {
1679 let mut p = test_panel().await;
1680 p.set_turn(1, vec![source("a.md", "b", 0.9, "beta body")], &noop_tx());
1681 p.settle().await;
1682 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); let mut body = String::new();
1688 for i in 0..8 {
1689 body.push_str(&format!("line{i}\n"));
1690 }
1691 body.push_str("beta body\n");
1692 for i in 0..8 {
1693 body.push_str(&format!("tail{i}\n"));
1694 }
1695 let start = body.find("beta body").unwrap();
1696 p.loaded = Some(LoadedNote {
1697 path: VaultPath::new("a.md"),
1698 ordinal: 0,
1699 content: ReaderContent::Loaded {
1700 text: body,
1701 highlight: Some(start..start + "beta body".len()),
1702 },
1703 });
1704 buffer_text(&mut p, 40, 6);
1707 assert!(
1708 p.preview.scroll_offset() > 0,
1709 "preview anchored the scroll to the section, offset={}",
1710 p.preview.scroll_offset()
1711 );
1712 }
1713
1714 #[tokio::test]
1717 async fn full_down_scrolls_content_not_the_list() {
1718 let mut p = test_panel().await;
1719 two_source_panel(&mut p).await;
1720 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1721 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); let mut body = String::from("alpha body\n");
1726 for i in 0..20 {
1727 body.push_str(&format!("line{i}\n"));
1728 }
1729 p.loaded = Some(LoadedNote {
1730 path: VaultPath::new("a.md"),
1731 ordinal: 0,
1732 content: ReaderContent::Loaded {
1733 text: body,
1734 highlight: Some(0.."alpha body".len()),
1735 },
1736 });
1737 buffer_text(&mut p, 40, 6); assert_eq!(p.preview.scroll_offset(), 0);
1739 p.handle_input(&InputEvent::Key(key(KeyCode::Down)), &tx);
1741 assert_eq!(
1742 selected_heading(&p).as_deref(),
1743 Some("A"),
1744 "Down in Full scrolls content, not the list"
1745 );
1746 assert!(
1747 p.preview.scroll_offset() > 0,
1748 "Full + Down scrolled the content, offset={}",
1749 p.preview.scroll_offset()
1750 );
1751 }
1752
1753 #[tokio::test]
1754 async fn full_j_still_moves_the_list_selection() {
1755 let mut p = test_panel().await;
1756 two_source_panel(&mut p).await;
1757 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1758 p.preview.toggle(Some(VaultPath::new("a.md"))); p.preview.toggle(Some(VaultPath::new("a.md"))); assert!(p.preview.is_full());
1761 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1763 assert_eq!(
1764 selected_heading(&p).as_deref(),
1765 Some("B"),
1766 "j moves the list selection in Full"
1767 );
1768 }
1769
1770 #[tokio::test]
1771 async fn wheel_scrolls_the_open_preview_and_is_ignored_when_collapsed() {
1772 let mut p = test_panel().await;
1773 two_source_panel(&mut p).await;
1774 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1775 let wheel = |kind| {
1778 InputEvent::Mouse(MouseEvent {
1779 kind,
1780 column: 0,
1781 row: 0,
1782 modifiers: KeyModifiers::NONE,
1783 })
1784 };
1785 assert_eq!(
1786 p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1787 EventState::NotConsumed,
1788 "collapsed preview with no recorded rect does not eat the wheel"
1789 );
1790 p.preview.toggle(Some(VaultPath::new("a.md")));
1792 p.preview.toggle(Some(VaultPath::new("a.md")));
1793 let mut body = String::from("alpha body\n");
1794 for i in 0..20 {
1795 body.push_str(&format!("line{i}\n"));
1796 }
1797 p.loaded = Some(LoadedNote {
1798 path: VaultPath::new("a.md"),
1799 ordinal: 0,
1800 content: ReaderContent::Loaded {
1801 text: body,
1802 highlight: Some(0.."alpha body".len()),
1803 },
1804 });
1805 buffer_text(&mut p, 40, 6);
1806 assert_eq!(
1807 p.handle_input(&wheel(MouseEventKind::ScrollDown), &tx),
1808 EventState::Consumed,
1809 "open preview consumes the wheel"
1810 );
1811 assert!(p.preview.scroll_offset() > 0, "wheel scrolled the content");
1812 }
1813
1814 #[tokio::test]
1817 async fn same_note_different_heading_recomputes_highlight_without_reload() {
1818 let (_dir, vault) = test_vault().await;
1819 std::mem::forget(_dir);
1820 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1821 let mut s0 = source("doc.md", "Alpha", 0.9, "alpha body");
1823 s0.ordinal = 1;
1824 let mut s1 = source("doc.md", "Beta", 0.8, "beta body");
1825 s1.ordinal = 2;
1826 p.set_turn(1, vec![s0, s1], &noop_tx());
1827 p.settle().await;
1828 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1829 p.preview.toggle(Some(VaultPath::new("doc.md"))); p.ensure_loaded(&tx); let note = "# Alpha\nalpha body\n# Beta\nbeta body\n".to_string();
1833 p.handle_data(AskData::ReaderNote {
1834 path: VaultPath::new("doc.md"),
1835 text: Some(note),
1836 });
1837 let first = match &p.loaded.as_ref().unwrap().content {
1838 ReaderContent::Loaded { text, highlight } => {
1839 let r = highlight.clone().expect("section resolves");
1840 assert_eq!(&text[r.clone()], "alpha body");
1841 r
1842 }
1843 _ => panic!("expected Loaded"),
1844 };
1845 p.handle_input(&InputEvent::Key(key(KeyCode::Char('j'))), &tx);
1848 match &p.loaded.as_ref().unwrap().content {
1849 ReaderContent::Loaded { text, highlight } => {
1850 let r = highlight.clone().expect("re-resolved");
1851 assert_eq!(&text[r.clone()], "beta body");
1852 assert_ne!(r, first, "highlight moved to the new section");
1853 }
1854 _ => panic!("must reuse the loaded note, not reload"),
1855 }
1856 assert_eq!(
1857 p.loaded.as_ref().unwrap().ordinal,
1858 2,
1859 "re-keyed to the new source"
1860 );
1861 }
1862
1863 #[tokio::test]
1866 async fn open_reader_stays_full_and_re_points_to_the_source() {
1867 let (_dir, vault) = test_vault().await;
1868 vault
1869 .create_note(&VaultPath::new("a.md"), "# ha\nalpha text\n")
1870 .await
1871 .unwrap();
1872 vault
1873 .create_note(&VaultPath::new("b.md"), "# hb\nbeta text\n")
1874 .await
1875 .unwrap();
1876 let mut p = SourcesPanel::new(Arc::new(vault), &key_bindings());
1877 let mut s0 = source("a.md", "ha", 0.9, "alpha text");
1878 s0.ordinal = 1;
1879 let mut s1 = source("b.md", "hb", 0.8, "beta text");
1880 s1.ordinal = 2;
1881 p.set_turn(1, vec![s0, s1], &noop_tx());
1882 p.settle().await;
1883 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1884 select_index(&mut p, 1).await;
1886 p.preview.toggle(Some(VaultPath::new("b.md"))); p.preview.toggle(Some(VaultPath::new("b.md"))); assert!(p.preview.is_full());
1889 p.open_reader(0, &tx);
1891 assert!(p.preview.is_full(), "open_reader keeps the Full reveal");
1892 assert_eq!(selected_heading(&p).as_deref(), Some("ha"));
1893 let ev = rx.recv().await.expect("open_reader spawns a ReaderNote");
1895 let AppEvent::Ask(data) = ev else {
1896 panic!("expected an Ask event");
1897 };
1898 p.handle_data(data);
1899 match &p.loaded.as_ref().unwrap().content {
1900 ReaderContent::Loaded { text, highlight } => {
1901 assert_eq!(text, "# ha\nalpha text\n", "source 0's note is shown");
1902 let r = highlight.clone().expect("section resolves");
1903 assert_eq!(&text[r], "alpha text");
1904 }
1905 _ => panic!("expected Loaded for source 0"),
1906 }
1907 }
1908}