1use std::collections::HashSet;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use kimun_core::NoteVault;
12use kimun_core::nfs::VaultPath;
13use kimun_core::note::LinkType;
14use ratatui::Frame;
15use ratatui::crossterm::event::KeyCode;
16use ratatui::layout::{Constraint, Direction, Layout, Rect};
17use ratatui::style::{Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{ListItem, Paragraph};
20
21use crate::components::event_state::EventState;
22use crate::components::events::{AppEvent, AppTx, FileOp, InputEvent};
23use crate::components::panel::panel_block;
24use crate::components::query_list_panel::{ListPanelSpec, QueryListPanel};
25use crate::components::rich_row::RichRow;
26use crate::components::search_list::{Emit, RowSource, SearchRow, YankTarget};
27use crate::keys::key_combo::KeyCombo;
28use crate::settings::icons::Icons;
29use crate::settings::themes::Theme;
30
31#[derive(Clone)]
36pub struct TagEntry {
37 pub label: String,
38 pub count: usize,
39}
40
41impl SearchRow for TagEntry {
42 fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
43 let aqua = Style::default().fg(theme.aqua.to_ratatui());
44 RichRow::new("#", self.label.clone())
45 .glyph_style(aqua)
46 .title_style(aqua)
47 .meta(self.count.to_string())
48 .into_list_item(theme)
49 }
50
51 fn match_text(&self) -> Option<&str> {
52 Some(&self.label)
53 }
54
55 fn visual_height(&self) -> u16 {
56 1
57 }
58
59 fn yank_target(&self) -> Option<YankTarget> {
60 Some(YankTarget::new(format!("#{}", self.label), "tag"))
62 }
63}
64
65struct TagSource {
66 vault: Arc<NoteVault>,
67}
68
69#[async_trait]
70impl RowSource<TagEntry> for TagSource {
71 async fn load(&self, _query: &str, emit: Emit<TagEntry>) {
72 let mut rows: Vec<TagEntry> = self
73 .vault
74 .label_counts()
75 .await
76 .unwrap_or_default()
77 .into_iter()
78 .map(|(label, count)| TagEntry { label, count })
79 .collect();
80 rows.sort_by_key(|r| std::cmp::Reverse(r.count));
82 emit.replace(rows);
83 }
84
85 fn reload_on_query(&self) -> bool {
86 false }
88}
89
90pub struct TagsSpec;
92
93impl ListPanelSpec for TagsSpec {
94 type Row = TagEntry;
95 const TITLE: &'static str = "Tags";
96
97 fn submit(row: &TagEntry, tx: &AppTx) {
98 tx.send(AppEvent::RunTagQuery(row.label.clone())).ok();
99 }
100
101 fn hints() -> Vec<(String, String)> {
102 vec![("Enter".into(), "Run tag query".into())]
103 }
104}
105
106pub struct TagsPanel {
108 vault: Arc<NoteVault>,
109 body: QueryListPanel<TagsSpec>,
110}
111
112impl TagsPanel {
113 pub fn new(vault: Arc<NoteVault>, icons: Icons, yank_combos: Vec<KeyCombo>) -> Self {
114 Self {
115 vault,
116 body: QueryListPanel::new(icons, yank_combos),
117 }
118 }
119
120 pub fn refresh(&mut self, tx: &AppTx) {
122 self.body.set_source(
123 TagSource {
124 vault: self.vault.clone(),
125 },
126 tx,
127 );
128 }
129
130 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
131 self.body.hint_shortcuts()
132 }
133
134 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
135 self.body.handle_input(event, tx)
136 }
137
138 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
139 self.body.render(f, rect, theme, focused);
140 }
141}
142
143#[derive(Clone, Copy, PartialEq, Eq, Debug)]
148pub enum LinksTab {
149 Backlinks,
150 Outgoing,
151 Unlinked,
152}
153
154impl LinksTab {
155 pub const ORDER: [LinksTab; 3] = [LinksTab::Backlinks, LinksTab::Outgoing, LinksTab::Unlinked];
157
158 fn cycled(self, steps: isize) -> LinksTab {
160 let n = Self::ORDER.len() as isize;
161 let i = Self::ORDER.iter().position(|t| *t == self).unwrap_or(0) as isize;
162 Self::ORDER[((i + steps).rem_euclid(n)) as usize]
163 }
164
165 fn label(self) -> &'static str {
166 match self {
167 LinksTab::Backlinks => "backlinks",
168 LinksTab::Outgoing => "outgoing",
169 LinksTab::Unlinked => "unlinked",
170 }
171 }
172}
173
174#[derive(Clone)]
175pub struct LinkEntry {
176 pub path: VaultPath,
177 pub title: String,
178 pub filename: String,
179}
180
181impl LinkEntry {
182 fn from_path(path: VaultPath) -> Self {
183 let title = path.get_clean_name();
184 let (_, filename) = path.get_parent_path();
185 Self {
186 path,
187 title,
188 filename,
189 }
190 }
191}
192
193impl SearchRow for LinkEntry {
194 fn to_list_item(&self, theme: &Theme, icons: &Icons, _selected: bool) -> ListItem<'static> {
195 let title = if self.title.is_empty() {
196 self.filename.clone()
197 } else {
198 self.title.clone()
199 };
200 RichRow::new(icons.note, title)
201 .filename(self.filename.clone())
202 .into_list_item(theme)
203 }
204
205 fn match_text(&self) -> Option<&str> {
206 Some(&self.filename)
207 }
208
209 fn yank_target(&self) -> Option<YankTarget> {
210 Some(YankTarget::path(self.path.to_string()))
211 }
212
213 fn visual_height(&self) -> u16 {
214 2
215 }
216}
217
218struct LinksSource {
219 vault: Arc<NoteVault>,
220 note: VaultPath,
221 tab: LinksTab,
222}
223
224#[async_trait]
225impl RowSource<LinkEntry> for LinksSource {
226 async fn load(&self, _query: &str, emit: Emit<LinkEntry>) {
227 if self.note.is_root_or_empty() {
228 emit.replace(Vec::new());
229 return;
230 }
231 let entries = match self.tab {
232 LinksTab::Backlinks => self
233 .vault
234 .get_backlinks(&self.note)
235 .await
236 .unwrap_or_default()
237 .into_iter()
238 .map(|(entry, content)| {
239 let (_, filename) = entry.path.get_parent_path();
240 LinkEntry {
241 path: entry.path,
242 title: content.title,
243 filename,
244 }
245 })
246 .collect(),
247 LinksTab::Outgoing => {
248 let links = self
249 .vault
250 .get_markdown_and_links(&self.note)
251 .await
252 .map(|md| md.links)
253 .unwrap_or_default();
254 let mut seen = HashSet::new();
255 links
256 .into_iter()
257 .filter_map(|link| match link.ltype {
258 LinkType::Note(path) => seen
259 .insert(path.clone())
260 .then(|| LinkEntry::from_path(path)),
261 _ => None,
262 })
263 .collect()
264 }
265 LinksTab::Unlinked => {
266 let name = self.note.get_clean_name();
270 if name.is_empty() {
271 emit.replace(Vec::new());
272 return;
273 }
274 let (backlinks, mentions) = tokio::join!(
277 self.vault.get_backlinks(&self.note),
278 self.vault.search_notes(kimun_core::quote_query_term(&name))
279 );
280 let linked: HashSet<VaultPath> = backlinks
281 .unwrap_or_default()
282 .into_iter()
283 .map(|(entry, _)| entry.path)
284 .collect();
285 mentions
286 .unwrap_or_default()
287 .into_iter()
288 .filter(|(entry, _)| {
292 !entry.path.is_like(&self.note) && !linked.contains(&entry.path)
293 })
294 .map(|(entry, content)| {
295 let (_, filename) = entry.path.get_parent_path();
296 LinkEntry {
297 path: entry.path,
298 title: content.title,
299 filename,
300 }
301 })
302 .collect()
303 }
304 };
305 emit.replace(entries);
306 }
307
308 fn reload_on_query(&self) -> bool {
309 false
310 }
311}
312
313pub struct LinksSpec;
316
317impl ListPanelSpec for LinksSpec {
318 type Row = LinkEntry;
319 const TITLE: &'static str = "Links";
320 const HAS_FILTER: bool = false;
321
322 fn submit(row: &LinkEntry, tx: &AppTx) {
323 tx.send(AppEvent::open(row.path.clone())).ok();
324 }
325
326 fn context_event(row: &LinkEntry) -> Option<AppEvent> {
327 Some(AppEvent::FileOp(FileOp::ShowMenu(row.path.clone())))
328 }
329
330 fn hints() -> Vec<(String, String)> {
331 vec![
332 ("b/o/u".into(), "Sub-view".into()),
333 ("Enter".into(), "Open".into()),
334 ]
335 }
336}
337
338pub struct LinksPanel {
341 vault: Arc<NoteVault>,
342 note: VaultPath,
343 tab: LinksTab,
344 body: QueryListPanel<LinksSpec>,
345 tab_cells: Vec<(LinksTab, Rect)>,
348}
349
350impl LinksPanel {
351 pub fn new(vault: Arc<NoteVault>, icons: Icons, yank_combos: Vec<KeyCombo>) -> Self {
352 Self {
353 vault,
354 note: VaultPath::empty(),
355 tab: LinksTab::Backlinks,
356 body: QueryListPanel::new(icons, yank_combos),
357 tab_cells: Vec::new(),
358 }
359 }
360
361 pub fn set_note(&mut self, note: VaultPath, tx: &AppTx) {
362 if note != self.note || !self.body.is_loaded() {
363 self.note = note;
364 self.refresh(tx);
365 }
366 }
367
368 pub fn tab(&self) -> LinksTab {
369 self.tab
370 }
371
372 pub fn show_tab(&mut self, tab: LinksTab, tx: &AppTx) {
374 self.set_tab(tab, tx);
375 }
376
377 fn set_tab(&mut self, tab: LinksTab, tx: &AppTx) {
378 if tab != self.tab {
379 self.tab = tab;
380 self.refresh(tx);
381 }
382 }
383
384 fn refresh(&mut self, tx: &AppTx) {
385 self.body.set_source(
386 LinksSource {
387 vault: self.vault.clone(),
388 note: self.note.clone(),
389 tab: self.tab,
390 },
391 tx,
392 );
393 }
394
395 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
396 self.body.hint_shortcuts()
397 }
398
399 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
400 match event {
403 InputEvent::Key(key) => match key.code {
404 KeyCode::Char('b') => {
405 self.set_tab(LinksTab::Backlinks, tx);
406 return EventState::Consumed;
407 }
408 KeyCode::Char('o') => {
409 self.set_tab(LinksTab::Outgoing, tx);
410 return EventState::Consumed;
411 }
412 KeyCode::Char('u') => {
413 self.set_tab(LinksTab::Unlinked, tx);
414 return EventState::Consumed;
415 }
416 KeyCode::Left => {
417 self.set_tab(self.tab.cycled(-1), tx);
418 return EventState::Consumed;
419 }
420 KeyCode::Right => {
421 self.set_tab(self.tab.cycled(1), tx);
422 return EventState::Consumed;
423 }
424 _ => {}
425 },
426 InputEvent::Mouse(mouse) => {
427 if matches!(
429 mouse.kind,
430 ratatui::crossterm::event::MouseEventKind::Down(
431 ratatui::crossterm::event::MouseButton::Left
432 )
433 ) && let Some(tab) = self
434 .tab_cells
435 .iter()
436 .find(|(_, r)| {
437 r.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
438 })
439 .map(|(t, _)| *t)
440 {
441 self.set_tab(tab, tx);
442 return EventState::Consumed;
443 }
444 }
445 _ => {}
446 }
447 self.body.handle_input(event, tx)
448 }
449
450 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
451 let block = panel_block("Links", theme, focused);
452 let inner = block.inner(rect);
453 f.render_widget(block, rect);
454 let rows = Layout::default()
455 .direction(Direction::Vertical)
456 .constraints([Constraint::Length(1), Constraint::Min(0)])
457 .split(inner);
458
459 self.tab_cells.clear();
462 let mut spans = Vec::new();
463 let mut x = rows[0].x;
464 for (i, tab) in LinksTab::ORDER.into_iter().enumerate() {
465 if i > 0 {
466 spans.push(Span::styled(
467 " · ",
468 Style::default().fg(theme.gray.to_ratatui()),
469 ));
470 x += 3;
471 }
472 let style = if tab == self.tab {
473 Style::default()
474 .fg(theme.aqua.to_ratatui())
475 .add_modifier(Modifier::BOLD)
476 } else {
477 Style::default().fg(theme.gray.to_ratatui())
478 };
479 let w = tab.label().len() as u16; if x < rows[0].right() {
481 self.tab_cells
482 .push((tab, Rect::new(x, rows[0].y, w.min(rows[0].right() - x), 1)));
483 }
484 spans.push(Span::styled(tab.label(), style));
485 x += w;
486 }
487 f.render_widget(Paragraph::new(Line::from(spans)), rows[0]);
488
489 self.body.render_in(f, rows[1], rect, theme, focused);
490 }
491}
492
493#[derive(Clone)]
498pub struct OutlineEntry {
499 pub heading: String,
500 pub depth: usize,
502}
503
504impl SearchRow for OutlineEntry {
505 fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
506 let indent = " ".repeat(self.depth.saturating_sub(1));
507 RichRow::new(format!("{indent}≡"), self.heading.clone())
508 .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
509 .into_list_item(theme)
510 }
511
512 fn match_text(&self) -> Option<&str> {
513 Some(&self.heading)
514 }
515
516 fn visual_height(&self) -> u16 {
517 1
518 }
519
520 fn yank_target(&self) -> Option<YankTarget> {
521 Some(YankTarget::new(self.heading.clone(), "heading"))
524 }
525}
526
527struct OutlineSource {
528 vault: Arc<NoteVault>,
529 note: VaultPath,
530}
531
532#[async_trait]
533impl RowSource<OutlineEntry> for OutlineSource {
534 async fn load(&self, _query: &str, emit: Emit<OutlineEntry>) {
535 if self.note.is_root_or_empty() {
536 emit.replace(Vec::new());
537 return;
538 }
539 let Ok(details) = self.vault.load_note(&self.note).await else {
543 emit.replace(Vec::new());
544 return;
545 };
546 let entries: Vec<OutlineEntry> = details
549 .get_content_chunks()
550 .into_iter()
551 .filter_map(|chunk| {
552 let depth = chunk.breadcrumb_parts().count();
553 chunk.breadcrumb_last().map(|heading| OutlineEntry {
554 heading: heading.to_string(),
555 depth,
556 })
557 })
558 .collect();
559 emit.replace(entries);
560 }
561
562 fn reload_on_query(&self) -> bool {
563 false
564 }
565}
566
567pub struct OutlineSpec;
569
570impl ListPanelSpec for OutlineSpec {
571 type Row = OutlineEntry;
572 const TITLE: &'static str = "Outline";
573
574 fn submit(row: &OutlineEntry, tx: &AppTx) {
575 tx.send(AppEvent::JumpToHeading(row.heading.clone())).ok();
576 }
577
578 fn hints() -> Vec<(String, String)> {
579 vec![("Enter".into(), "Jump to heading".into())]
580 }
581}
582
583pub struct OutlinePanel {
585 vault: Arc<NoteVault>,
586 note: VaultPath,
587 body: QueryListPanel<OutlineSpec>,
588}
589
590impl OutlinePanel {
591 pub fn new(vault: Arc<NoteVault>, icons: Icons, yank_combos: Vec<KeyCombo>) -> Self {
592 Self {
593 vault,
594 note: VaultPath::empty(),
595 body: QueryListPanel::new(icons, yank_combos),
596 }
597 }
598
599 pub fn set_note(&mut self, note: VaultPath, tx: &AppTx) {
600 if note != self.note || !self.body.is_loaded() {
601 self.note = note;
602 self.refresh(tx);
603 }
604 }
605
606 pub fn refresh(&mut self, tx: &AppTx) {
608 self.body.set_source(
609 OutlineSource {
610 vault: self.vault.clone(),
611 note: self.note.clone(),
612 },
613 tx,
614 );
615 }
616
617 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
618 self.body.hint_shortcuts()
619 }
620
621 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
622 self.body.handle_input(event, tx)
623 }
624
625 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
626 self.body.render(f, rect, theme, focused);
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::test_support::temp_vault;
634
635 use crate::components::search_list::SearchList;
636
637 async fn drain<R: SearchRow + Clone + Send + Sync + 'static>(list: &mut SearchList<R>) {
639 for _ in 0..50 {
640 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
641 list.poll();
642 }
643 }
644
645 #[tokio::test(flavor = "multi_thread")]
646 async fn tags_panel_lists_label_counts() {
647 let vault = temp_vault("tags-panel").await;
648 vault.validate_and_init().await.unwrap();
649 vault
650 .save_note(&VaultPath::note_path_from("a"), "x #alpha #beta")
651 .await
652 .unwrap();
653 vault
654 .save_note(&VaultPath::note_path_from("b"), "y #alpha")
655 .await
656 .unwrap();
657
658 let mut panel = TagsPanel::new(
659 vault,
660 Icons::new(false),
661 vec![crate::keys::default_yank_combo()],
662 );
663 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
664 panel.refresh(&tx);
665 drain(panel.body.list_mut().unwrap()).await;
666
667 let rows = panel.body.list().unwrap().visible_rows();
668 let labels: Vec<(&str, usize)> = rows.iter().map(|r| (r.label.as_str(), r.count)).collect();
669 assert_eq!(labels, vec![("alpha", 2), ("beta", 1)]);
671 }
672
673 #[tokio::test(flavor = "multi_thread")]
674 async fn links_panel_tabs_track_note() {
675 let vault = temp_vault("links-panel").await;
676 vault.validate_and_init().await.unwrap();
677 vault
679 .save_note(&VaultPath::note_path_from("projectx"), "the note body")
680 .await
681 .unwrap();
682 vault
683 .save_note(
684 &VaultPath::note_path_from("linker"),
685 "links to [[projectx]] here",
686 )
687 .await
688 .unwrap();
689 vault
690 .save_note(
691 &VaultPath::note_path_from("mentions"),
692 "talks about projectx without linking",
693 )
694 .await
695 .unwrap();
696
697 let mut panel = LinksPanel::new(
698 vault,
699 Icons::new(false),
700 vec![crate::keys::default_yank_combo()],
701 );
702 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
703
704 panel.set_note(VaultPath::note_path_from("projectx"), &tx);
706 drain(panel.body.list_mut().unwrap()).await;
707 let names: Vec<&str> = panel
708 .body
709 .list()
710 .unwrap()
711 .visible_rows()
712 .iter()
713 .map(|r| r.filename.as_str())
714 .collect();
715 assert_eq!(names, vec!["linker.md"], "backlinks tab");
716
717 panel.set_note(VaultPath::note_path_from("linker"), &tx);
719 panel.set_tab(LinksTab::Outgoing, &tx);
720 drain(panel.body.list_mut().unwrap()).await;
721 let names: Vec<&str> = panel
722 .body
723 .list()
724 .unwrap()
725 .visible_rows()
726 .iter()
727 .map(|r| r.filename.as_str())
728 .collect();
729 assert_eq!(names, vec!["projectx.md"], "outgoing tab");
730
731 panel.set_note(VaultPath::note_path_from("projectx"), &tx);
733 panel.set_tab(LinksTab::Unlinked, &tx);
734 drain(panel.body.list_mut().unwrap()).await;
735 let names: Vec<&str> = panel
736 .body
737 .list()
738 .unwrap()
739 .visible_rows()
740 .iter()
741 .map(|r| r.filename.as_str())
742 .collect();
743 assert!(
744 names.contains(&"mentions.md") && !names.contains(&"linker.md"),
745 "unlinked tab: got {names:?}"
746 );
747 }
748
749 #[tokio::test(flavor = "multi_thread")]
750 async fn outline_panel_lists_headings_in_order() {
751 let vault = temp_vault("outline-panel").await;
752 vault.validate_and_init().await.unwrap();
753 vault
754 .save_note(
755 &VaultPath::note_path_from("doc"),
756 "# Top\nintro\n## Sub One\nbody\n## Sub Two\nmore\n# Second\nend\n",
757 )
758 .await
759 .unwrap();
760
761 let mut panel = OutlinePanel::new(
762 vault,
763 Icons::new(false),
764 vec![crate::keys::default_yank_combo()],
765 );
766 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
767 panel.set_note(VaultPath::note_path_from("doc"), &tx);
768 drain(panel.body.list_mut().unwrap()).await;
769
770 let rows = panel.body.list().unwrap().visible_rows();
771 let headings: Vec<(&str, usize)> =
772 rows.iter().map(|r| (r.heading.as_str(), r.depth)).collect();
773 assert_eq!(
774 headings,
775 vec![("Top", 1), ("Sub One", 2), ("Sub Two", 2), ("Second", 1)]
776 );
777 }
778}