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