1use std::collections::{HashMap, HashSet};
8use std::num::NonZeroU64;
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use kimun_core::NoteVault;
13use kimun_core::nfs::VaultPath;
14use kimun_core::note::LinkType;
15use ratatui::Frame;
16use ratatui::crossterm::event::KeyCode;
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Modifier, Style};
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{ListItem, Paragraph};
21
22use crate::components::event_state::EventState;
23use crate::components::events::{AppEvent, AppTx, FileOp, InputEvent};
24use crate::components::panel::panel_block;
25use crate::components::query_list_panel::{ListPanelSpec, QueryListPanel};
26use crate::components::rich_row::RichRow;
27use crate::components::search_list::{Emit, RowSource, SearchRow, YankTarget};
28use crate::keys::key_combo::KeyCombo;
29use crate::settings::icons::Icons;
30use crate::settings::themes::Theme;
31
32#[derive(Clone)]
37pub struct TagEntry {
38 pub label: String,
39 pub count: usize,
40}
41
42impl SearchRow for TagEntry {
43 fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
44 let aqua = Style::default().fg(theme.aqua.to_ratatui());
45 RichRow::new("#", self.label.clone())
46 .glyph_style(aqua)
47 .title_style(aqua)
48 .meta(self.count.to_string())
49 .into_list_item(theme)
50 }
51
52 fn match_text(&self) -> Option<&str> {
53 Some(&self.label)
54 }
55
56 fn visual_height(&self) -> u16 {
57 1
58 }
59
60 fn yank_target(&self) -> Option<YankTarget> {
61 let text = if kimun_core::note::is_hashtag_label(&self.label) {
65 format!("#{}", self.label)
66 } else {
67 self.label.clone()
68 };
69 Some(YankTarget::new(text, "tag"))
70 }
71}
72
73struct TagSource {
74 vault: Arc<NoteVault>,
75}
76
77#[async_trait]
78impl RowSource<TagEntry> for TagSource {
79 async fn load(&self, _query: &str, emit: Emit<TagEntry>) {
80 let mut rows: Vec<TagEntry> = self
81 .vault
82 .label_counts()
83 .await
84 .unwrap_or_default()
85 .into_iter()
86 .map(|(label, count)| TagEntry { label, count })
87 .collect();
88 rows.sort_by_key(|r| std::cmp::Reverse(r.count));
90 emit.replace(rows);
91 }
92
93 fn reload_on_query(&self) -> bool {
94 false }
96}
97
98pub struct TagsSpec;
100
101impl ListPanelSpec for TagsSpec {
102 type Row = TagEntry;
103 const TITLE: &'static str = "Tags";
104
105 fn submit(row: &TagEntry, tx: &AppTx) {
106 tx.send(AppEvent::RunTagQuery(row.label.clone())).ok();
107 }
108
109 fn hints() -> Vec<(String, String)> {
110 vec![("Enter".into(), "Run tag query".into())]
111 }
112}
113
114pub struct TagsPanel {
116 vault: Arc<NoteVault>,
117 body: QueryListPanel<TagsSpec>,
118}
119
120impl TagsPanel {
121 pub fn new(vault: Arc<NoteVault>, icons: Icons, yank_combos: Vec<KeyCombo>) -> Self {
122 Self {
123 vault,
124 body: QueryListPanel::new(icons, yank_combos),
125 }
126 }
127
128 pub fn refresh(&mut self, tx: &AppTx) {
130 self.body.set_source(
131 TagSource {
132 vault: self.vault.clone(),
133 },
134 tx,
135 );
136 }
137
138 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
139 self.body.hint_shortcuts()
140 }
141
142 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
143 self.body.handle_input(event, tx)
144 }
145
146 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
147 self.body.render(f, rect, theme, focused);
148 }
149}
150
151#[derive(Clone, Copy, PartialEq, Eq, Debug)]
156pub enum LinksTab {
157 Backlinks,
158 Outgoing,
159 Unlinked,
160}
161
162impl LinksTab {
163 pub const ORDER: [LinksTab; 3] = [LinksTab::Backlinks, LinksTab::Outgoing, LinksTab::Unlinked];
165
166 fn cycled(self, steps: isize) -> LinksTab {
168 let n = Self::ORDER.len() as isize;
169 let i = Self::ORDER.iter().position(|t| *t == self).unwrap_or(0) as isize;
170 Self::ORDER[((i + steps).rem_euclid(n)) as usize]
171 }
172
173 fn label(self) -> &'static str {
174 match self {
175 LinksTab::Backlinks => "backlinks",
176 LinksTab::Outgoing => "outgoing",
177 LinksTab::Unlinked => "unlinked",
178 }
179 }
180}
181
182#[derive(Clone)]
183pub struct LinkEntry {
184 pub path: VaultPath,
185 pub title: String,
186 pub filename: String,
187}
188
189impl LinkEntry {
190 fn from_path(path: VaultPath) -> Self {
191 let title = path.get_clean_name();
192 let (_, filename) = path.get_parent_path();
193 Self {
194 path,
195 title,
196 filename,
197 }
198 }
199}
200
201impl SearchRow for LinkEntry {
202 fn to_list_item(&self, theme: &Theme, icons: &Icons, _selected: bool) -> ListItem<'static> {
203 let title = if self.title.is_empty() {
204 self.filename.clone()
205 } else {
206 self.title.clone()
207 };
208 RichRow::new(icons.note, title)
209 .filename(self.filename.clone())
210 .into_list_item(theme)
211 }
212
213 fn match_text(&self) -> Option<&str> {
214 Some(&self.filename)
215 }
216
217 fn yank_target(&self) -> Option<YankTarget> {
218 Some(YankTarget::path(self.path.to_string()))
219 }
220
221 fn visual_height(&self) -> u16 {
222 2
223 }
224}
225
226struct LinksSource {
227 vault: Arc<NoteVault>,
228 note: VaultPath,
229 tab: LinksTab,
230}
231
232#[async_trait]
233impl RowSource<LinkEntry> for LinksSource {
234 async fn load(&self, _query: &str, emit: Emit<LinkEntry>) {
235 if self.note.is_root_or_empty() {
236 emit.replace(Vec::new());
237 return;
238 }
239 let entries = match self.tab {
240 LinksTab::Backlinks => self
241 .vault
242 .get_backlinks(&self.note)
243 .await
244 .unwrap_or_default()
245 .into_iter()
246 .map(|(entry, content)| {
247 let (_, filename) = entry.path.get_parent_path();
248 LinkEntry {
249 path: entry.path,
250 title: content.title,
251 filename,
252 }
253 })
254 .collect(),
255 LinksTab::Outgoing => {
256 let links = self
257 .vault
258 .get_markdown_and_links(&self.note)
259 .await
260 .map(|md| md.links)
261 .unwrap_or_default();
262 let mut seen = HashSet::new();
263 links
264 .into_iter()
265 .filter_map(|link| match link.ltype {
266 LinkType::Note(path) => seen
267 .insert(path.clone())
268 .then(|| LinkEntry::from_path(path)),
269 _ => None,
270 })
271 .collect()
272 }
273 LinksTab::Unlinked => {
274 let name = self.note.get_clean_name();
278 if name.is_empty() {
279 emit.replace(Vec::new());
280 return;
281 }
282 let (backlinks, mentions) = tokio::join!(
285 self.vault.get_backlinks(&self.note),
286 self.vault.search_notes(kimun_core::quote_query_term(&name))
287 );
288 let linked: HashSet<VaultPath> = backlinks
289 .unwrap_or_default()
290 .into_iter()
291 .map(|(entry, _)| entry.path)
292 .collect();
293 mentions
294 .unwrap_or_default()
295 .into_iter()
296 .filter(|(entry, _)| {
300 !entry.path.is_like(&self.note) && !linked.contains(&entry.path)
301 })
302 .map(|(entry, content)| {
303 let (_, filename) = entry.path.get_parent_path();
304 LinkEntry {
305 path: entry.path,
306 title: content.title,
307 filename,
308 }
309 })
310 .collect()
311 }
312 };
313 emit.replace(entries);
314 }
315
316 fn reload_on_query(&self) -> bool {
317 false
318 }
319}
320
321pub struct LinksSpec;
324
325impl ListPanelSpec for LinksSpec {
326 type Row = LinkEntry;
327 const TITLE: &'static str = "Links";
328 const HAS_FILTER: bool = false;
329
330 fn submit(row: &LinkEntry, tx: &AppTx) {
331 tx.send(AppEvent::open(row.path.clone())).ok();
332 }
333
334 fn context_event(row: &LinkEntry) -> Option<AppEvent> {
335 Some(AppEvent::FileOp(FileOp::ShowMenu(row.path.clone())))
336 }
337
338 fn hints() -> Vec<(String, String)> {
339 vec![
340 ("b/o/u".into(), "Sub-view".into()),
341 ("Enter".into(), "Open".into()),
342 ]
343 }
344}
345
346pub struct LinksPanel {
349 vault: Arc<NoteVault>,
350 note: VaultPath,
351 tab: LinksTab,
352 body: QueryListPanel<LinksSpec>,
353 tab_cells: Vec<(LinksTab, Rect)>,
356}
357
358impl LinksPanel {
359 pub fn new(vault: Arc<NoteVault>, icons: Icons, yank_combos: Vec<KeyCombo>) -> Self {
360 Self {
361 vault,
362 note: VaultPath::empty(),
363 tab: LinksTab::Backlinks,
364 body: QueryListPanel::new(icons, yank_combos),
365 tab_cells: Vec::new(),
366 }
367 }
368
369 pub fn set_note(&mut self, note: VaultPath, tx: &AppTx) {
370 if note != self.note || !self.body.is_loaded() {
371 self.note = note;
372 self.refresh(tx);
373 }
374 }
375
376 pub fn tab(&self) -> LinksTab {
377 self.tab
378 }
379
380 pub fn show_tab(&mut self, tab: LinksTab, tx: &AppTx) {
382 self.set_tab(tab, tx);
383 }
384
385 fn set_tab(&mut self, tab: LinksTab, tx: &AppTx) {
386 if tab != self.tab {
387 self.tab = tab;
388 self.refresh(tx);
389 }
390 }
391
392 fn refresh(&mut self, tx: &AppTx) {
393 self.body.set_source(
394 LinksSource {
395 vault: self.vault.clone(),
396 note: self.note.clone(),
397 tab: self.tab,
398 },
399 tx,
400 );
401 }
402
403 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
404 self.body.hint_shortcuts()
405 }
406
407 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
408 match event {
411 InputEvent::Key(key) => match key.code {
412 KeyCode::Char('b') => {
413 self.set_tab(LinksTab::Backlinks, tx);
414 return EventState::Consumed;
415 }
416 KeyCode::Char('o') => {
417 self.set_tab(LinksTab::Outgoing, tx);
418 return EventState::Consumed;
419 }
420 KeyCode::Char('u') => {
421 self.set_tab(LinksTab::Unlinked, tx);
422 return EventState::Consumed;
423 }
424 KeyCode::Left => {
425 self.set_tab(self.tab.cycled(-1), tx);
426 return EventState::Consumed;
427 }
428 KeyCode::Right => {
429 self.set_tab(self.tab.cycled(1), tx);
430 return EventState::Consumed;
431 }
432 _ => {}
433 },
434 InputEvent::Mouse(mouse) => {
435 if matches!(
437 mouse.kind,
438 ratatui::crossterm::event::MouseEventKind::Down(
439 ratatui::crossterm::event::MouseButton::Left
440 )
441 ) && let Some(tab) = self
442 .tab_cells
443 .iter()
444 .find(|(_, r)| {
445 r.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
446 })
447 .map(|(t, _)| *t)
448 {
449 self.set_tab(tab, tx);
450 return EventState::Consumed;
451 }
452 }
453 _ => {}
454 }
455 self.body.handle_input(event, tx)
456 }
457
458 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
459 let block = panel_block("Links", theme, focused);
460 let inner = block.inner(rect);
461 f.render_widget(block, rect);
462 let rows = Layout::default()
463 .direction(Direction::Vertical)
464 .constraints([Constraint::Length(1), Constraint::Min(0)])
465 .split(inner);
466
467 self.tab_cells.clear();
470 let mut spans = Vec::new();
471 let mut x = rows[0].x;
472 for (i, tab) in LinksTab::ORDER.into_iter().enumerate() {
473 if i > 0 {
474 spans.push(Span::styled(
475 " · ",
476 Style::default().fg(theme.gray.to_ratatui()),
477 ));
478 x += 3;
479 }
480 let style = if tab == self.tab {
481 Style::default()
482 .fg(theme.aqua.to_ratatui())
483 .add_modifier(Modifier::BOLD)
484 } else {
485 Style::default().fg(theme.gray.to_ratatui())
486 };
487 let w = tab.label().len() as u16; if x < rows[0].right() {
489 let r = Rect::new(x, rows[0].y, w.min(rows[0].right() - x), 1);
490 crate::components::clickable::register(r);
491 self.tab_cells.push((tab, r));
492 }
493 spans.push(Span::styled(tab.label(), style));
494 x += w;
495 }
496 f.render_widget(Paragraph::new(Line::from(spans)), rows[0]);
497
498 self.body.render_in(f, rows[1], rect, theme, focused);
499 }
500}
501
502#[derive(Clone, PartialEq, Eq, Debug)]
510pub struct HeadingTarget {
511 pub text: String,
512 pub occurrence: usize,
513 pub count: usize,
514 pub line: usize,
515}
516
517#[derive(Clone, PartialEq, Eq, Debug)]
518pub struct OutlineEntry {
519 pub depth: usize,
522 pub target: HeadingTarget,
523}
524
525impl OutlineEntry {
526 fn all_of(text: &str) -> Vec<Self> {
528 let headings = kimun_core::note::note_headings(text);
529 let mut counts: HashMap<&str, usize> = HashMap::new();
530 for heading in &headings {
531 *counts.entry(heading.text.as_str()).or_default() += 1;
532 }
533 let mut open_levels: Vec<u8> = Vec::new();
536 let mut seen: HashMap<&str, usize> = HashMap::new();
537 headings
538 .iter()
539 .map(|heading| {
540 open_levels.retain(|lvl| *lvl < heading.level);
541 open_levels.push(heading.level);
542 let seen = seen.entry(heading.text.as_str()).or_default();
543 let occurrence = *seen;
544 *seen += 1;
545 OutlineEntry {
546 depth: open_levels.len(),
547 target: HeadingTarget {
548 text: heading.text.clone(),
549 occurrence,
550 count: counts[heading.text.as_str()],
551 line: heading.line,
552 },
553 }
554 })
555 .collect()
556 }
557
558 pub fn heading(&self) -> &str {
559 &self.target.text
560 }
561
562 fn same_row(&self, other: &Self) -> bool {
565 fn key(e: &OutlineEntry) -> (usize, &str, usize, usize) {
566 (e.depth, &e.target.text, e.target.occurrence, e.target.count)
567 }
568 key(self) == key(other)
569 }
570}
571
572impl SearchRow for OutlineEntry {
573 fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
574 let indent = " ".repeat(self.depth.saturating_sub(1));
575 RichRow::new(format!("{indent}≡"), self.target.text.clone())
576 .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
577 .into_list_item(theme)
578 }
579
580 fn match_text(&self) -> Option<&str> {
581 Some(&self.target.text)
582 }
583
584 fn visual_height(&self) -> u16 {
585 1
586 }
587
588 fn yank_target(&self) -> Option<YankTarget> {
589 Some(YankTarget::new(self.target.text.clone(), "heading"))
592 }
593}
594
595pub struct OutlineSpec;
597
598impl ListPanelSpec for OutlineSpec {
599 type Row = OutlineEntry;
600 const TITLE: &'static str = "Outline";
601
602 fn submit(row: &OutlineEntry, tx: &AppTx) {
603 tx.send(AppEvent::JumpToHeading(row.target.clone())).ok();
604 }
605
606 fn hints() -> Vec<(String, String)> {
607 vec![("Enter".into(), "Jump to heading".into())]
608 }
609}
610
611pub struct OutlinePanel {
617 note: VaultPath,
618 revision: Option<NonZeroU64>,
620 entries: Vec<OutlineEntry>,
621 body: QueryListPanel<OutlineSpec>,
622}
623
624impl OutlinePanel {
625 pub fn new(icons: Icons, yank_combos: Vec<KeyCombo>) -> Self {
626 Self {
627 note: VaultPath::empty(),
628 revision: None,
629 entries: Vec::new(),
630 body: QueryListPanel::new(icons, yank_combos),
631 }
632 }
633
634 pub fn sync(
640 &mut self,
641 note: &VaultPath,
642 revision: Option<NonZeroU64>,
643 text: impl FnOnce() -> String,
644 tx: &AppTx,
645 ) {
646 let same_note = *note == self.note && self.body.is_loaded();
647 if same_note && revision.is_some() && revision == self.revision {
648 return;
649 }
650 let entries = OutlineEntry::all_of(&text());
651 self.revision = revision;
652 let unchanged = same_note
653 && entries.len() == self.entries.len()
654 && entries
655 .iter()
656 .zip(&self.entries)
657 .all(|(a, b)| a.same_row(b));
658 self.entries = entries;
661 if unchanged {
662 return;
663 }
664 self.note = note.clone();
665 self.body.set_rows(self.entries.clone(), tx);
666 }
667
668 pub fn resolve(&self, target: &HeadingTarget) -> Option<usize> {
675 let lines: Vec<usize> = self
676 .entries
677 .iter()
678 .filter(|e| e.target.text == target.text)
679 .map(|e| e.target.line)
680 .collect();
681 if lines.len() == target.count {
682 return lines.get(target.occurrence).copied();
683 }
684 lines
685 .into_iter()
686 .min_by_key(|line| (line.abs_diff(target.line), *line))
687 }
688
689 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
690 self.body.hint_shortcuts()
691 }
692
693 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
694 self.body.handle_input(event, tx)
695 }
696
697 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
698 self.body.render(f, rect, theme, focused);
699 }
700
701 #[cfg(test)]
703 pub fn headings_for_test(&self) -> Vec<String> {
704 self.body
705 .list()
706 .map(|l| {
707 l.visible_rows()
708 .iter()
709 .map(|r| r.heading().to_string())
710 .collect()
711 })
712 .unwrap_or_default()
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use crate::test_support::temp_vault;
720
721 use crate::components::search_list::SearchList;
722
723 async fn drain<R: SearchRow + Clone + Send + Sync + 'static>(list: &mut SearchList<R>) {
725 for _ in 0..50 {
726 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
727 list.poll();
728 }
729 }
730
731 #[tokio::test(flavor = "multi_thread")]
732 async fn tags_panel_lists_label_counts() {
733 let vault = temp_vault("tags-panel").await;
734 vault.validate_and_init().await.unwrap();
735 vault
736 .save_note(&VaultPath::note_path_from("a"), "x #alpha #beta")
737 .await
738 .unwrap();
739 vault
740 .save_note(&VaultPath::note_path_from("b"), "y #alpha")
741 .await
742 .unwrap();
743
744 let mut panel = TagsPanel::new(
745 vault,
746 Icons::new(false),
747 vec![crate::keys::default_yank_combo()],
748 );
749 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
750 panel.refresh(&tx);
751 drain(panel.body.list_mut().unwrap()).await;
752
753 let rows = panel.body.list().unwrap().visible_rows();
754 let labels: Vec<(&str, usize)> = rows.iter().map(|r| (r.label.as_str(), r.count)).collect();
755 assert_eq!(labels, vec![("alpha", 2), ("beta", 1)]);
757 }
758
759 #[tokio::test(flavor = "multi_thread")]
760 async fn links_panel_tabs_track_note() {
761 let vault = temp_vault("links-panel").await;
762 vault.validate_and_init().await.unwrap();
763 vault
765 .save_note(&VaultPath::note_path_from("projectx"), "the note body")
766 .await
767 .unwrap();
768 vault
769 .save_note(
770 &VaultPath::note_path_from("linker"),
771 "links to [[projectx]] here",
772 )
773 .await
774 .unwrap();
775 vault
776 .save_note(
777 &VaultPath::note_path_from("mentions"),
778 "talks about projectx without linking",
779 )
780 .await
781 .unwrap();
782
783 let mut panel = LinksPanel::new(
784 vault,
785 Icons::new(false),
786 vec![crate::keys::default_yank_combo()],
787 );
788 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
789
790 panel.set_note(VaultPath::note_path_from("projectx"), &tx);
792 drain(panel.body.list_mut().unwrap()).await;
793 let names: Vec<&str> = panel
794 .body
795 .list()
796 .unwrap()
797 .visible_rows()
798 .iter()
799 .map(|r| r.filename.as_str())
800 .collect();
801 assert_eq!(names, vec!["linker.md"], "backlinks tab");
802
803 panel.set_note(VaultPath::note_path_from("linker"), &tx);
805 panel.set_tab(LinksTab::Outgoing, &tx);
806 drain(panel.body.list_mut().unwrap()).await;
807 let names: Vec<&str> = panel
808 .body
809 .list()
810 .unwrap()
811 .visible_rows()
812 .iter()
813 .map(|r| r.filename.as_str())
814 .collect();
815 assert_eq!(names, vec!["projectx.md"], "outgoing tab");
816
817 panel.set_note(VaultPath::note_path_from("projectx"), &tx);
819 panel.set_tab(LinksTab::Unlinked, &tx);
820 drain(panel.body.list_mut().unwrap()).await;
821 let names: Vec<&str> = panel
822 .body
823 .list()
824 .unwrap()
825 .visible_rows()
826 .iter()
827 .map(|r| r.filename.as_str())
828 .collect();
829 assert!(
830 names.contains(&"mentions.md") && !names.contains(&"linker.md"),
831 "unlinked tab: got {names:?}"
832 );
833 }
834
835 fn outline_panel() -> OutlinePanel {
836 OutlinePanel::new(Icons::new(false), vec![crate::keys::default_yank_combo()])
837 }
838
839 fn rows(panel: &OutlinePanel) -> Vec<(String, usize)> {
840 panel
841 .body
842 .list()
843 .unwrap()
844 .visible_rows()
845 .iter()
846 .map(|r| (r.heading().to_string(), r.depth))
847 .collect()
848 }
849
850 fn sync(panel: &mut OutlinePanel, revision: Option<u64>, text: &str, tx: &AppTx) {
851 let text = text.to_string();
852 panel.sync(
853 &VaultPath::note_path_from("doc"),
854 revision.and_then(NonZeroU64::new),
855 move || text,
856 tx,
857 );
858 }
859
860 fn owned(rows: &[(&str, usize)]) -> Vec<(String, usize)> {
861 rows.iter().map(|(h, d)| (h.to_string(), *d)).collect()
862 }
863
864 #[test]
865 fn outline_panel_lists_headings_in_order() {
866 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
867 let mut panel = outline_panel();
868 let text = "# Top\nintro\n## Sub One\nbody\n## Sub Two\nmore\n# Second\nend\n";
869 sync(&mut panel, Some(1), text, &tx);
870 assert_eq!(
871 rows(&panel),
872 owned(&[("Top", 1), ("Sub One", 2), ("Sub Two", 2), ("Second", 1)])
873 );
874 }
875
876 #[test]
877 fn outline_panel_lists_a_heading_with_no_body() {
878 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
879 let mut panel = outline_panel();
880 let text = "# Title\n## Title2\nbody\n### Skipped [[link|Shown]] #tag\n";
881 sync(&mut panel, Some(1), text, &tx);
882 assert_eq!(
884 rows(&panel),
885 owned(&[("Title", 1), ("Title2", 2), ("Skipped Shown tag", 3)])
886 );
887 }
888
889 #[test]
890 fn outline_rows_number_same_named_headings_and_jump_by_that() {
891 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
892 let mut panel = outline_panel();
893 sync(&mut panel, Some(1), "# Notes\n## Other\n## Notes\n", &tx);
894 let list = panel.body.list().unwrap();
895 let occurrences: Vec<usize> = list
896 .visible_rows()
897 .iter()
898 .map(|r| r.target.occurrence)
899 .collect();
900 assert_eq!(occurrences, [0, 0, 1]);
901
902 OutlineSpec::submit(list.visible_rows()[2], &tx);
903 assert!(matches!(
904 rx.try_recv(),
905 Ok(AppEvent::JumpToHeading(t)) if t.text == "Notes" && t.occurrence == 1 && t.count == 2 && t.line == 2
906 ));
907 }
908
909 fn target_of(panel: &OutlinePanel, index: usize) -> HeadingTarget {
910 panel.entries[index].target.clone()
911 }
912
913 #[test]
914 fn resolve_follows_a_heading_through_moved_lines() {
915 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
916 let mut panel = outline_panel();
917 sync(&mut panel, Some(1), "# Notes\n## Notes\n", &tx);
918 let second = target_of(&panel, 1);
919 sync(&mut panel, Some(2), "new\nnew\n# Notes\n## Notes\n", &tx);
921 assert_eq!(panel.resolve(&second), Some(3));
922 }
923
924 #[test]
925 fn resolve_takes_the_nearest_when_a_same_named_heading_was_added() {
926 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
927 let mut panel = outline_panel();
928 let mut text = String::from("# Notes\n");
929 text.push_str(&"x\n".repeat(9));
930 text.push_str("## Notes\n");
931 sync(&mut panel, Some(1), &text, &tx);
932 let second = target_of(&panel, 1);
933 assert_eq!(second.line, 10);
934 let mut edited = String::from("# Notes\n");
936 edited.push_str(&"x\n".repeat(4));
937 edited.push_str("## Notes\n");
938 edited.push_str(&"x\n".repeat(5));
939 edited.push_str("## Notes\n");
940 sync(&mut panel, Some(2), &edited, &tx);
941 assert_eq!(panel.resolve(&second), Some(11));
942 }
943
944 #[test]
945 fn resolve_finds_nothing_for_a_renamed_heading() {
946 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
947 let mut panel = outline_panel();
948 sync(&mut panel, Some(1), "# A\n## Foo\n", &tx);
949 let foo = target_of(&panel, 1);
950 sync(&mut panel, Some(2), "# A\n## Bar\n", &tx);
951 assert_eq!(panel.resolve(&foo), None);
952 }
953
954 #[test]
955 fn outline_sync_keeps_the_list_when_headings_only_move() {
956 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
957 let mut panel = outline_panel();
958 sync(&mut panel, Some(1), "# A\n## Beta\n", &tx);
959 panel.body.list_mut().unwrap().set_query("bet");
960 sync(&mut panel, Some(2), "intro\n\n# A\n## Beta\n", &tx);
961 assert_eq!(panel.body.list().unwrap().input_value(), "bet");
962 assert_eq!(panel.resolve(&target_of(&panel, 1)), Some(3));
964 }
965
966 #[test]
967 fn outline_depth_is_nesting_not_heading_level() {
968 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
969 let mut panel = outline_panel();
970 sync(&mut panel, Some(1), "## A\n#### B\n# C\n", &tx);
971 assert_eq!(rows(&panel), owned(&[("A", 1), ("B", 2), ("C", 1)]));
972 }
973
974 #[test]
975 fn outline_sync_keeps_filter_and_selection_while_headings_are_unchanged() {
976 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
977 let mut panel = outline_panel();
978 sync(&mut panel, Some(1), "# A\n## Beta\n## Bet\n", &tx);
979 let list = panel.body.list_mut().unwrap();
980 list.set_query("bet");
981 list.select_next();
982 let picked = list.selected_row().unwrap().heading().to_string();
983
984 sync(&mut panel, Some(2), "# A\nnew body\n## Beta\n## Bet\n", &tx);
986 let list = panel.body.list().unwrap();
987 assert_eq!(list.input_value(), "bet");
988 assert_eq!(list.selected_row().unwrap().heading(), picked);
989
990 sync(&mut panel, Some(3), "# A\n## Beta\n## Bet\n## Gamma\n", &tx);
992 assert_eq!(panel.body.list().unwrap().input_value(), "");
993 assert_eq!(rows(&panel).len(), 4);
994 }
995
996 #[test]
997 fn outline_sync_skips_the_parse_at_an_unchanged_revision() {
998 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
999 let mut panel = outline_panel();
1000 let note = VaultPath::note_path_from("doc");
1001 let rev = NonZeroU64::new(7);
1002 panel.sync(¬e, rev, || "# A\n".to_string(), &tx);
1003 panel.sync(
1004 ¬e,
1005 rev,
1006 || panic!("parsed at an unchanged revision"),
1007 &tx,
1008 );
1009 let mut read = false;
1011 panel.sync(
1012 ¬e,
1013 None,
1014 || {
1015 read = true;
1016 "# A\n".to_string()
1017 },
1018 &tx,
1019 );
1020 assert!(read);
1021 }
1022}