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, 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, _)| entry.path != self.note && !linked.contains(&entry.path))
279 .map(|(entry, content)| {
280 let (_, filename) = entry.path.get_parent_path();
281 LinkEntry {
282 path: entry.path,
283 title: content.title,
284 filename,
285 }
286 })
287 .collect()
288 }
289 };
290 emit.replace(entries);
291 }
292
293 fn reload_on_query(&self) -> bool {
294 false
295 }
296}
297
298pub struct LinksSpec;
301
302impl ListPanelSpec for LinksSpec {
303 type Row = LinkEntry;
304 const TITLE: &'static str = "Links";
305 const HAS_FILTER: bool = false;
306
307 fn submit(row: &LinkEntry, tx: &AppTx) {
308 tx.send(AppEvent::open(row.path.clone())).ok();
309 }
310
311 fn context_event(row: &LinkEntry) -> Option<AppEvent> {
312 Some(AppEvent::ShowFileOpsMenu(row.path.clone()))
313 }
314
315 fn hints() -> Vec<(String, String)> {
316 vec![
317 ("b/o/u".into(), "Sub-view".into()),
318 ("Enter".into(), "Open".into()),
319 ]
320 }
321}
322
323pub struct LinksPanel {
326 vault: Arc<NoteVault>,
327 note: VaultPath,
328 tab: LinksTab,
329 body: QueryListPanel<LinksSpec>,
330 tab_cells: Vec<(LinksTab, Rect)>,
333}
334
335impl LinksPanel {
336 pub fn new(vault: Arc<NoteVault>, icons: Icons) -> Self {
337 Self {
338 vault,
339 note: VaultPath::empty(),
340 tab: LinksTab::Backlinks,
341 body: QueryListPanel::new(icons),
342 tab_cells: Vec::new(),
343 }
344 }
345
346 pub fn set_note(&mut self, note: VaultPath, tx: &AppTx) {
347 if note != self.note || !self.body.is_loaded() {
348 self.note = note;
349 self.refresh(tx);
350 }
351 }
352
353 pub fn tab(&self) -> LinksTab {
354 self.tab
355 }
356
357 pub fn show_tab(&mut self, tab: LinksTab, tx: &AppTx) {
359 self.set_tab(tab, tx);
360 }
361
362 fn set_tab(&mut self, tab: LinksTab, tx: &AppTx) {
363 if tab != self.tab {
364 self.tab = tab;
365 self.refresh(tx);
366 }
367 }
368
369 fn refresh(&mut self, tx: &AppTx) {
370 self.body.set_source(
371 LinksSource {
372 vault: self.vault.clone(),
373 note: self.note.clone(),
374 tab: self.tab,
375 },
376 tx,
377 );
378 }
379
380 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
381 self.body.hint_shortcuts()
382 }
383
384 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
385 match event {
388 InputEvent::Key(key) => match key.code {
389 KeyCode::Char('b') => {
390 self.set_tab(LinksTab::Backlinks, tx);
391 return EventState::Consumed;
392 }
393 KeyCode::Char('o') => {
394 self.set_tab(LinksTab::Outgoing, tx);
395 return EventState::Consumed;
396 }
397 KeyCode::Char('u') => {
398 self.set_tab(LinksTab::Unlinked, tx);
399 return EventState::Consumed;
400 }
401 KeyCode::Left => {
402 self.set_tab(self.tab.cycled(-1), tx);
403 return EventState::Consumed;
404 }
405 KeyCode::Right => {
406 self.set_tab(self.tab.cycled(1), tx);
407 return EventState::Consumed;
408 }
409 _ => {}
410 },
411 InputEvent::Mouse(mouse) => {
412 if matches!(
414 mouse.kind,
415 ratatui::crossterm::event::MouseEventKind::Down(
416 ratatui::crossterm::event::MouseButton::Left
417 )
418 ) && let Some(tab) = self
419 .tab_cells
420 .iter()
421 .find(|(_, r)| {
422 r.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
423 })
424 .map(|(t, _)| *t)
425 {
426 self.set_tab(tab, tx);
427 return EventState::Consumed;
428 }
429 }
430 _ => {}
431 }
432 self.body.handle_input(event, tx)
433 }
434
435 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
436 let block = panel_block("Links", theme, focused);
437 let inner = block.inner(rect);
438 f.render_widget(block, rect);
439 let rows = Layout::default()
440 .direction(Direction::Vertical)
441 .constraints([Constraint::Length(1), Constraint::Min(0)])
442 .split(inner);
443
444 self.tab_cells.clear();
447 let mut spans = Vec::new();
448 let mut x = rows[0].x;
449 for (i, tab) in LinksTab::ORDER.into_iter().enumerate() {
450 if i > 0 {
451 spans.push(Span::styled(
452 " · ",
453 Style::default().fg(theme.gray.to_ratatui()),
454 ));
455 x += 3;
456 }
457 let style = if tab == self.tab {
458 Style::default()
459 .fg(theme.aqua.to_ratatui())
460 .add_modifier(Modifier::BOLD)
461 } else {
462 Style::default().fg(theme.gray.to_ratatui())
463 };
464 let w = tab.label().len() as u16; if x < rows[0].right() {
466 self.tab_cells
467 .push((tab, Rect::new(x, rows[0].y, w.min(rows[0].right() - x), 1)));
468 }
469 spans.push(Span::styled(tab.label(), style));
470 x += w;
471 }
472 f.render_widget(Paragraph::new(Line::from(spans)), rows[0]);
473
474 self.body.render_in(f, rows[1], rect, theme, focused);
475 }
476}
477
478#[derive(Clone)]
483pub struct OutlineEntry {
484 pub heading: String,
485 pub depth: usize,
487}
488
489impl SearchRow for OutlineEntry {
490 fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
491 let indent = " ".repeat(self.depth.saturating_sub(1));
492 RichRow::new(format!("{indent}≡"), self.heading.clone())
493 .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
494 .into_list_item(theme)
495 }
496
497 fn match_text(&self) -> Option<&str> {
498 Some(&self.heading)
499 }
500
501 fn visual_height(&self) -> u16 {
502 1
503 }
504}
505
506struct OutlineSource {
507 vault: Arc<NoteVault>,
508 note: VaultPath,
509}
510
511#[async_trait]
512impl RowSource<OutlineEntry> for OutlineSource {
513 async fn load(&self, _query: &str, emit: Emit<OutlineEntry>) {
514 if self.note.is_root_or_empty() {
515 emit.replace(Vec::new());
516 return;
517 }
518 let Ok(details) = self.vault.load_note(&self.note).await else {
522 emit.replace(Vec::new());
523 return;
524 };
525 let entries: Vec<OutlineEntry> = details
528 .get_content_chunks()
529 .into_iter()
530 .filter_map(|chunk| {
531 let depth = chunk.breadcrumb_parts().count();
532 chunk.breadcrumb_last().map(|heading| OutlineEntry {
533 heading: heading.to_string(),
534 depth,
535 })
536 })
537 .collect();
538 emit.replace(entries);
539 }
540
541 fn reload_on_query(&self) -> bool {
542 false
543 }
544}
545
546pub struct OutlineSpec;
548
549impl ListPanelSpec for OutlineSpec {
550 type Row = OutlineEntry;
551 const TITLE: &'static str = "Outline";
552
553 fn submit(row: &OutlineEntry, tx: &AppTx) {
554 tx.send(AppEvent::JumpToHeading(row.heading.clone())).ok();
555 }
556
557 fn hints() -> Vec<(String, String)> {
558 vec![("Enter".into(), "Jump to heading".into())]
559 }
560}
561
562pub struct OutlinePanel {
564 vault: Arc<NoteVault>,
565 note: VaultPath,
566 body: QueryListPanel<OutlineSpec>,
567}
568
569impl OutlinePanel {
570 pub fn new(vault: Arc<NoteVault>, icons: Icons) -> Self {
571 Self {
572 vault,
573 note: VaultPath::empty(),
574 body: QueryListPanel::new(icons),
575 }
576 }
577
578 pub fn set_note(&mut self, note: VaultPath, tx: &AppTx) {
579 if note != self.note || !self.body.is_loaded() {
580 self.note = note;
581 self.refresh(tx);
582 }
583 }
584
585 pub fn refresh(&mut self, tx: &AppTx) {
587 self.body.set_source(
588 OutlineSource {
589 vault: self.vault.clone(),
590 note: self.note.clone(),
591 },
592 tx,
593 );
594 }
595
596 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
597 self.body.hint_shortcuts()
598 }
599
600 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
601 self.body.handle_input(event, tx)
602 }
603
604 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
605 self.body.render(f, rect, theme, focused);
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use crate::test_support::temp_vault;
613
614 use crate::components::search_list::SearchList;
615
616 async fn drain<R: SearchRow + Clone + Send + Sync + 'static>(list: &mut SearchList<R>) {
618 for _ in 0..50 {
619 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
620 list.poll();
621 }
622 }
623
624 #[tokio::test(flavor = "multi_thread")]
625 async fn tags_panel_lists_label_counts() {
626 let vault = temp_vault("tags-panel").await;
627 vault.validate_and_init().await.unwrap();
628 vault
629 .save_note(&VaultPath::note_path_from("a"), "x #alpha #beta")
630 .await
631 .unwrap();
632 vault
633 .save_note(&VaultPath::note_path_from("b"), "y #alpha")
634 .await
635 .unwrap();
636
637 let mut panel = TagsPanel::new(vault, Icons::new(false));
638 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
639 panel.refresh(&tx);
640 drain(panel.body.list_mut().unwrap()).await;
641
642 let rows = panel.body.list().unwrap().visible_rows();
643 let labels: Vec<(&str, usize)> = rows.iter().map(|r| (r.label.as_str(), r.count)).collect();
644 assert_eq!(labels, vec![("alpha", 2), ("beta", 1)]);
646 }
647
648 #[tokio::test(flavor = "multi_thread")]
649 async fn links_panel_tabs_track_note() {
650 let vault = temp_vault("links-panel").await;
651 vault.validate_and_init().await.unwrap();
652 vault
654 .save_note(&VaultPath::note_path_from("projectx"), "the note body")
655 .await
656 .unwrap();
657 vault
658 .save_note(
659 &VaultPath::note_path_from("linker"),
660 "links to [[projectx]] here",
661 )
662 .await
663 .unwrap();
664 vault
665 .save_note(
666 &VaultPath::note_path_from("mentions"),
667 "talks about projectx without linking",
668 )
669 .await
670 .unwrap();
671
672 let mut panel = LinksPanel::new(vault, Icons::new(false));
673 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
674
675 panel.set_note(VaultPath::note_path_from("projectx"), &tx);
677 drain(panel.body.list_mut().unwrap()).await;
678 let names: Vec<&str> = panel
679 .body
680 .list()
681 .unwrap()
682 .visible_rows()
683 .iter()
684 .map(|r| r.filename.as_str())
685 .collect();
686 assert_eq!(names, vec!["linker.md"], "backlinks tab");
687
688 panel.set_note(VaultPath::note_path_from("linker"), &tx);
690 panel.set_tab(LinksTab::Outgoing, &tx);
691 drain(panel.body.list_mut().unwrap()).await;
692 let names: Vec<&str> = panel
693 .body
694 .list()
695 .unwrap()
696 .visible_rows()
697 .iter()
698 .map(|r| r.filename.as_str())
699 .collect();
700 assert_eq!(names, vec!["projectx.md"], "outgoing tab");
701
702 panel.set_note(VaultPath::note_path_from("projectx"), &tx);
704 panel.set_tab(LinksTab::Unlinked, &tx);
705 drain(panel.body.list_mut().unwrap()).await;
706 let names: Vec<&str> = panel
707 .body
708 .list()
709 .unwrap()
710 .visible_rows()
711 .iter()
712 .map(|r| r.filename.as_str())
713 .collect();
714 assert!(
715 names.contains(&"mentions.md") && !names.contains(&"linker.md"),
716 "unlinked tab: got {names:?}"
717 );
718 }
719
720 #[tokio::test(flavor = "multi_thread")]
721 async fn outline_panel_lists_headings_in_order() {
722 let vault = temp_vault("outline-panel").await;
723 vault.validate_and_init().await.unwrap();
724 vault
725 .save_note(
726 &VaultPath::note_path_from("doc"),
727 "# Top\nintro\n## Sub One\nbody\n## Sub Two\nmore\n# Second\nend\n",
728 )
729 .await
730 .unwrap();
731
732 let mut panel = OutlinePanel::new(vault, Icons::new(false));
733 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
734 panel.set_note(VaultPath::note_path_from("doc"), &tx);
735 drain(panel.body.list_mut().unwrap()).await;
736
737 let rows = panel.body.list().unwrap().visible_rows();
738 let headings: Vec<(&str, usize)> =
739 rows.iter().map(|r| (r.heading.as_str(), r.depth)).collect();
740 assert_eq!(
741 headings,
742 vec![("Top", 1), ("Sub One", 2), ("Sub Two", 2), ("Second", 1)]
743 );
744 }
745}