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