Skip to main content

kimun_notes/components/
drawer_views.rs

1//! The phase-03 drawer views: **TAGS**, **LINKS**, and **OUTLINE** — each a
2//! thin adapter (`ListPanelSpec` + a `RowSource`) of the shared
3//! [`QueryListPanel`] body, over core's vault API. Rebuilt on demand
4//! (`refresh`) — the same engine-per-context pattern the sidebar uses per
5//! directory.
6
7use 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// ---------------------------------------------------------------------------
31// TAGS
32// ---------------------------------------------------------------------------
33
34#[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        // Most-used first; ties alphabetical (counts come in alphabetical).
75        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 // load once; the local fuzzy filter narrows the set
81    }
82}
83
84/// Spec: Enter / click runs the tag's query in the FIND drawer.
85pub 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
100/// The TAGS drawer: every `#tag` in the vault with its note count.
101pub 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    /// (Re)load the tag list. Called when the view is opened.
115    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// ---------------------------------------------------------------------------
138// LINKS
139// ---------------------------------------------------------------------------
140
141#[derive(Clone, Copy, PartialEq, Eq, Debug)]
142pub enum LinksTab {
143    Backlinks,
144    Outgoing,
145    Unlinked,
146}
147
148impl LinksTab {
149    /// The sub-view order, single source for cycling and the tab bar.
150    pub const ORDER: [LinksTab; 3] = [LinksTab::Backlinks, LinksTab::Outgoing, LinksTab::Unlinked];
151
152    /// The tab `steps` away in [`Self::ORDER`], wrapping.
153    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                // Notes whose body mentions this note's name as plain text
257                // but does not link to it: text-search the clean name, then
258                // subtract the linking notes and the note itself.
259                let name = self.note.get_clean_name();
260                if name.is_empty() {
261                    emit.replace(Vec::new());
262                    return;
263                }
264                // Quote the name so multi-word names search as one literal
265                // phrase, not an AND of words. Fetch both sets concurrently.
266                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
298/// Spec: Enter / click opens the entry; rows are real notes, so right-click
299/// opens the file-ops menu. No filter input — `b/o/u` are sub-view keys.
300pub 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
323/// The LINKS drawer for the open note: backlinks / outgoing / unlinked
324/// mentions as sub-tabs (`b` / `o` / `u`, or ←/→) over the shared body.
325pub struct LinksPanel {
326    vault: Arc<NoteVault>,
327    note: VaultPath,
328    tab: LinksTab,
329    body: QueryListPanel<LinksSpec>,
330    /// Screen cell each sub-view tab was drawn into on the last render —
331    /// click-to-switch hit-test (keyboard ↔ mouse parity, spec §10).
332    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    /// Switch to `tab`, used by leader paths (`l b/o/u`).
358    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        // Tab-bar concerns first (sub-view keys / tab clicks); the rest is
386        // the shared body's.
387        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                // A click on the tab bar switches the sub-view.
413                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        // Sub-view tab bar: the active tab pops; each tab's cell is recorded
445        // so a click switches to it.
446        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; // labels are ASCII
465            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// ---------------------------------------------------------------------------
479// OUTLINE
480// ---------------------------------------------------------------------------
481
482#[derive(Clone)]
483pub struct OutlineEntry {
484    pub heading: String,
485    /// 1-based heading depth (H1 = 1).
486    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        // Read the note and take the heading hierarchy from its content
519        // chunks (document order). Each chunk's breadcrumb is the heading
520        // path to it; the innermost part is the chunk's own heading.
521        let Ok(details) = self.vault.load_note(&self.note).await else {
522            emit.replace(Vec::new());
523            return;
524        };
525        // One chunk per heading section (core contract), in document order;
526        // a headingless preamble chunk has an empty breadcrumb and is skipped.
527        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
546/// Spec: Enter / click jumps the editor to the heading.
547pub 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
562/// The OUTLINE drawer: the open note's headings as an indented tree.
563pub 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    /// Re-read the headings (e.g. after the buffer was saved).
586    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    /// Poll a panel's list until the async load lands.
617    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        // Most-used first.
645        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        // projectx is linked from linker, mentioned (no link) in mentions.
653        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        // Backlinks of projectx → linker.
676        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        // Outgoing of linker → projectx.
689        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        // Unlinked mentions of projectx → mentions (linker is excluded).
703        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}