Skip to main content

kimun_notes/components/
drawer_views.rs

1//! The 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::{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// ---------------------------------------------------------------------------
33// TAGS
34// ---------------------------------------------------------------------------
35
36#[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        // With the `#` sigil, so the copied text is usable as-is in a note —
62        // unless the label can't be a hashtag (a frontmatter tag with spaces
63        // or dashes): then the label itself.
64        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        // Most-used first; ties alphabetical (counts come in alphabetical).
89        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 // load once; the local fuzzy filter narrows the set
95    }
96}
97
98/// Spec: Enter / click runs the tag's query in the FIND drawer.
99pub 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
114/// The TAGS drawer: every `#tag` in the vault with its note count.
115pub 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    /// (Re)load the tag list. Called when the view is opened.
129    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// ---------------------------------------------------------------------------
152// LINKS
153// ---------------------------------------------------------------------------
154
155#[derive(Clone, Copy, PartialEq, Eq, Debug)]
156pub enum LinksTab {
157    Backlinks,
158    Outgoing,
159    Unlinked,
160}
161
162impl LinksTab {
163    /// The sub-view order, single source for cycling and the tab bar.
164    pub const ORDER: [LinksTab; 3] = [LinksTab::Backlinks, LinksTab::Outgoing, LinksTab::Unlinked];
165
166    /// The tab `steps` away in [`Self::ORDER`], wrapping.
167    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                // Notes whose body mentions this note's name as plain text
275                // but does not link to it: text-search the clean name, then
276                // subtract the linking notes and the note itself.
277                let name = self.note.get_clean_name();
278                if name.is_empty() {
279                    emit.replace(Vec::new());
280                    return;
281                }
282                // Quote the name so multi-word names search as one literal
283                // phrase, not an AND of words. Fetch both sets concurrently.
284                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                    // `is_like`: `self.note` may be relative while `entry.path` is
297                    // index-absolute, so `==` would fail to exclude the
298                    // open note from its own unlinked-mentions list.
299                    .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
321/// Spec: Enter / click opens the entry; rows are real notes, so right-click
322/// opens the file-ops menu. No filter input — `b/o/u` are sub-view keys.
323pub 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
346/// The LINKS drawer for the open note: backlinks / outgoing / unlinked
347/// mentions as sub-tabs (`b` / `o` / `u`, or ←/→) over the shared body.
348pub struct LinksPanel {
349    vault: Arc<NoteVault>,
350    note: VaultPath,
351    tab: LinksTab,
352    body: QueryListPanel<LinksSpec>,
353    /// Screen cell each sub-view tab was drawn into on the last render —
354    /// click-to-switch hit-test (keyboard ↔ mouse parity).
355    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    /// Switch to `tab`, used by leader paths (`l b/o/u`).
381    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        // Tab-bar concerns first (sub-view keys / tab clicks); the rest is
409        // the shared body's.
410        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                // A click on the tab bar switches the sub-view.
436                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        // Sub-view tab bar: the active tab pops; each tab's cell is recorded
468        // so a click switches to it.
469        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; // labels are ASCII
488            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// ---------------------------------------------------------------------------
503// OUTLINE
504// ---------------------------------------------------------------------------
505
506/// Which heading an OUTLINE row stands for, robust to the buffer moving on
507/// after the row was listed: the `occurrence`-th of the `count` headings
508/// reading `text`, last seen on `line`. See [`OutlinePanel::resolve`].
509#[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    /// 1-based nesting depth: how many headings enclose this one, plus one.
520    /// Not the markdown level — `## A` over `#### B` is depth 1 and 2.
521    pub depth: usize,
522    pub target: HeadingTarget,
523}
524
525impl OutlineEntry {
526    /// The OUTLINE rows of a note body, in document order.
527    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        // Depth counts the open ancestors with a lower level, as a breadcrumb
534        // does.
535        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    /// Everything the row shows or jumps by, but its line: a body edit that
563    /// only moves headings keeps the list (and its filter) as it is.
564    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        // The heading text alone — the row carries no note path, and the depth
590        // is presentation, not content.
591        Some(YankTarget::new(self.target.text.clone(), "heading"))
592    }
593}
594
595/// Spec: Enter / click jumps the editor to the heading.
596pub 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
611/// The OUTLINE drawer: the open note's headings as an indented tree, read
612/// from the editor buffer (not the file). Refreshed when revealed, on each
613/// autosave tick and on a jump — so between ticks it may trail the buffer by
614/// up to `autosave_interval_secs`; the jump itself always reads the live
615/// buffer.
616pub struct OutlinePanel {
617    note: VaultPath,
618    /// Buffer revision `entries` was computed at; `None` = unknown, re-parse.
619    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    /// Show `note`'s headings from its buffer `text` (asked for only when
635    /// needed) at `revision` (`None` when unknown, e.g. a buffer just
636    /// replaced). Cheap to call often: an unchanged revision skips the parse,
637    /// and unchanged headings keep the list — and so its filter and
638    /// selection — as they are.
639    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        // Kept current even when the list is not rebuilt: `resolve` reads
659        // lines from here, the listed rows' lines being hints only.
660        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    /// The buffer row `target` stands for, against the headings as last
669    /// synced — so sync first. The same `occurrence`-th heading when as many
670    /// headings read `target.text` as when the row was listed (lines may have
671    /// moved; occurrence order has not); otherwise one was added or removed,
672    /// and the one nearest `target.line` (the earlier on a tie). `None` when
673    /// no heading reads that text any more.
674    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    /// The listed headings.
702    #[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    /// Poll a panel's list until the async load lands.
724    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        // Most-used first.
756        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        // projectx is linked from linker, mentioned (no link) in mentions.
764        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        // Backlinks of projectx → linker.
791        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        // Outgoing of linker → projectx.
804        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        // Unlinked mentions of projectx → mentions (linker is excluded).
818        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        // Rendered as the chunker renders breadcrumbs: links and tag markers gone.
883        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        // Two lines typed above: same headings, both moved down.
920        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        // A new `## Notes` typed at line 5: occurrence 1 is now that one.
935        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        // The kept rows' lines are stale; resolve reads the fresh ones.
963        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        // A body edit: new revision, same headings — the list stays as it is.
985        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        // A heading edit rebuilds it.
991        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(&note, rev, || "# A\n".to_string(), &tx);
1003        panel.sync(
1004            &note,
1005            rev,
1006            || panic!("parsed at an unchanged revision"),
1007            &tx,
1008        );
1009        // An unknown revision always re-reads.
1010        let mut read = false;
1011        panel.sync(
1012            &note,
1013            None,
1014            || {
1015                read = true;
1016                "# A\n".to_string()
1017            },
1018            &tx,
1019        );
1020        assert!(read);
1021    }
1022}