Skip to main content

kimun_notes/components/
backlinks_panel.rs

1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use kimun_core::nfs::VaultPath;
5use ratatui::Frame;
6use ratatui::crossterm::event::{KeyCode, KeyEvent};
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
11
12use crate::components::event_state::EventState;
13use crate::components::events::{AppEvent, AppTx};
14use crate::components::file_list::{SortField, SortOrder};
15use crate::keys::action_shortcuts::ActionShortcuts;
16use crate::keys::{KeyBindings, key_event_to_combo};
17use crate::settings::themes::Theme;
18
19// ---------------------------------------------------------------------------
20// BacklinkEntry
21// ---------------------------------------------------------------------------
22
23/// A single backlink entry with preloaded context.
24#[derive(Debug, Clone)]
25pub struct BacklinkEntry {
26    pub path: VaultPath,
27    pub title: String,
28    pub filename: String,
29    /// The paragraph in this note that contains the link to the current note.
30    pub context: String,
31    /// Full note text, loaded when backlinks are fetched.
32    pub full_text: Option<String>,
33}
34
35// ---------------------------------------------------------------------------
36// ExpandState (private)
37// ---------------------------------------------------------------------------
38
39#[derive(Clone, Copy, PartialEq)]
40enum ExpandState {
41    Collapsed,
42    Context,
43    Full,
44}
45
46// ---------------------------------------------------------------------------
47// BacklinksPanel
48// ---------------------------------------------------------------------------
49
50pub struct BacklinksPanel {
51    entries: Vec<BacklinkEntry>,
52    expand_states: Vec<ExpandState>,
53    list_state: ListState,
54    loading: bool,
55    current_note: VaultPath,
56    sort_field: SortField,
57    sort_order: SortOrder,
58    vault: Arc<NoteVault>,
59    key_bindings: KeyBindings,
60    /// Scroll offset for full-expanded content view.
61    content_scroll: usize,
62    /// Maximum scroll offset (computed during render).
63    content_scroll_max: usize,
64}
65
66impl BacklinksPanel {
67    pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings) -> Self {
68        Self {
69            entries: Vec::new(),
70            expand_states: Vec::new(),
71            list_state: ListState::default(),
72            loading: false,
73            current_note: VaultPath::empty(),
74            sort_field: SortField::Name,
75            sort_order: SortOrder::Ascending,
76            vault,
77            key_bindings,
78            content_scroll: 0,
79            content_scroll_max: 0,
80        }
81    }
82
83    // ── Helpers ─────────────────────────────────────────────────────────
84
85    /// Returns true if the selected entry is in full-expand mode (content takes
86    /// the whole panel, up/down scrolls content).
87    fn is_full_expanded(&self) -> bool {
88        self.list_state
89            .selected()
90            .and_then(|i| self.expand_states.get(i))
91            .is_some_and(|s| *s == ExpandState::Full)
92    }
93
94    pub fn is_empty(&self) -> bool {
95        self.entries.is_empty()
96    }
97
98    pub fn selected_path(&self) -> Option<&VaultPath> {
99        self.list_state
100            .selected()
101            .and_then(|i| self.entries.get(i))
102            .map(|e| &e.path)
103    }
104
105    // ── Loading ─────────────────────────────────────────────────────────
106
107    /// Begin loading backlinks for `note_path`. Clears existing state, sets
108    /// `loading = true`, and spawns a background task that sends
109    /// `AppEvent::BacklinksLoaded` when finished.
110    pub fn load(&mut self, note_path: VaultPath, tx: AppTx) {
111        self.entries.clear();
112        self.expand_states.clear();
113        self.list_state.select(None);
114        self.loading = true;
115        self.current_note = note_path.clone();
116        self.content_scroll = 0;
117        self.content_scroll_max = 0;
118
119        let vault = Arc::clone(&self.vault);
120        tokio::spawn(async move {
121            let entries = load_backlinks(&vault, &note_path).await;
122            let _ = tx.send(AppEvent::BacklinksLoaded(entries));
123        });
124    }
125
126    /// Called when the background task completes. Stores the entries, applies
127    /// the current sort, and initialises expand states.
128    pub fn on_loaded(&mut self, entries: Vec<BacklinkEntry>) {
129        self.entries = entries;
130        self.apply_sort();
131        self.expand_states = vec![ExpandState::Collapsed; self.entries.len()];
132        self.loading = false;
133        if !self.entries.is_empty() {
134            self.list_state.select(Some(0));
135        }
136    }
137
138    /// Sort `entries` (and their parallel `expand_states`) by the active
139    /// sort field and order.
140    pub fn apply_sort(&mut self) {
141        let field = self.sort_field;
142        let order = self.sort_order;
143
144        // Build index permutation so we can reorder expand_states in sync.
145        let mut indices: Vec<usize> = (0..self.entries.len()).collect();
146        indices.sort_by(|&a, &b| {
147            let cmp = match field {
148                SortField::Name => self.entries[a]
149                    .filename
150                    .to_lowercase()
151                    .cmp(&self.entries[b].filename.to_lowercase()),
152                SortField::Title => self.entries[a]
153                    .title
154                    .to_lowercase()
155                    .cmp(&self.entries[b].title.to_lowercase()),
156            };
157            match order {
158                SortOrder::Ascending => cmp,
159                SortOrder::Descending => cmp.reverse(),
160            }
161        });
162
163        let sorted_entries: Vec<BacklinkEntry> =
164            indices.iter().map(|&i| self.entries[i].clone()).collect();
165        let sorted_states: Vec<ExpandState> = if self.expand_states.len() == self.entries.len() {
166            indices.iter().map(|&i| self.expand_states[i]).collect()
167        } else {
168            vec![ExpandState::Collapsed; sorted_entries.len()]
169        };
170
171        self.entries = sorted_entries;
172        self.expand_states = sorted_states;
173    }
174
175    // ── Input handling ──────────────────────────────────────────────────
176
177    pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
178        // Check for action shortcuts first.
179        if let Some(combo) = key_event_to_combo(key) {
180            match self.key_bindings.get_action(&combo) {
181                Some(ActionShortcuts::CycleSortField) => {
182                    self.sort_field = self.sort_field.cycle();
183                    self.apply_sort();
184                    self.expand_states = vec![ExpandState::Collapsed; self.entries.len()];
185                    return EventState::Consumed;
186                }
187                Some(ActionShortcuts::SortReverseOrder) => {
188                    self.sort_order = self.sort_order.toggle();
189                    self.apply_sort();
190                    self.expand_states = vec![ExpandState::Collapsed; self.entries.len()];
191                    return EventState::Consumed;
192                }
193                // FocusSidebar / FocusEditor are intercepted at the
194                // EditorScreen level for directional navigation.
195                Some(ActionShortcuts::FollowLink) => {
196                    if let Some(path) = self.selected_path().cloned() {
197                        tx.send(AppEvent::OpenPath(path)).ok();
198                    }
199                    return EventState::Consumed;
200                }
201                _ => {}
202            }
203        }
204
205        match key.code {
206            KeyCode::Up => {
207                if self.is_full_expanded() {
208                    self.content_scroll = self.content_scroll.saturating_sub(1);
209                } else {
210                    self.move_selection(-1);
211                }
212                EventState::Consumed
213            }
214            KeyCode::Down => {
215                if self.is_full_expanded() {
216                    // Increment freely; render() clamps to content_scroll_max.
217                    self.content_scroll += 1;
218                } else {
219                    self.move_selection(1);
220                }
221                EventState::Consumed
222            }
223            KeyCode::Enter => {
224                self.toggle_expand();
225                EventState::Consumed
226            }
227            KeyCode::Esc => EventState::NotConsumed,
228            _ => EventState::NotConsumed,
229        }
230    }
231
232    fn move_selection(&mut self, delta: i32) {
233        if self.entries.is_empty() {
234            return;
235        }
236        let current = self.list_state.selected().unwrap_or(0) as i32;
237        let next = (current + delta).clamp(0, self.entries.len() as i32 - 1) as usize;
238        self.list_state.select(Some(next));
239    }
240
241    fn toggle_expand(&mut self) {
242        let Some(idx) = self.list_state.selected() else {
243            return;
244        };
245        if idx >= self.expand_states.len() {
246            return;
247        }
248
249        match self.expand_states[idx] {
250            ExpandState::Collapsed => {
251                self.expand_states[idx] = ExpandState::Context;
252            }
253            ExpandState::Context => {
254                self.content_scroll = 0;
255                self.expand_states[idx] = ExpandState::Full;
256            }
257            ExpandState::Full => {
258                self.content_scroll = 0;
259                self.expand_states[idx] = ExpandState::Collapsed;
260            }
261        }
262    }
263
264    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
265        [
266            (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
267            (ActionShortcuts::FollowLink, "open note"),
268            (ActionShortcuts::CycleSortField, "sort"),
269        ]
270        .iter()
271        .filter_map(|(action, label)| {
272            self.key_bindings
273                .first_combo_for(action)
274                .map(|k| (k, label.to_string()))
275        })
276        .collect()
277    }
278
279    // ── Rendering ──────────────────────────────────────────────────────
280
281    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
282        let border_style = theme.border_style(focused);
283        let fg = theme.fg.to_ratatui();
284        let fg_muted = theme.fg_muted.to_ratatui();
285        let bg = theme.bg_panel.to_ratatui();
286
287        let sort_indicator = format!("{}{}", self.sort_field.label(), self.sort_order.label());
288        let title = format!("Backlinks ({}) {}", self.entries.len(), sort_indicator);
289
290        let outer = Block::default()
291            .title(title)
292            .borders(Borders::ALL)
293            .border_style(border_style)
294            .style(theme.panel_style());
295        let inner = outer.inner(rect);
296        f.render_widget(outer, rect);
297
298        if self.loading {
299            f.render_widget(
300                Paragraph::new("  Loading...").style(Style::default().fg(fg_muted).bg(bg)),
301                inner,
302            );
303            return;
304        }
305
306        if self.entries.is_empty() {
307            f.render_widget(
308                Paragraph::new("  No backlinks").style(Style::default().fg(fg_muted).bg(bg)),
309                inner,
310            );
311            return;
312        }
313
314        let selected = self.list_state.selected();
315        let selected_state = selected
316            .and_then(|i| self.expand_states.get(i).copied())
317            .unwrap_or(ExpandState::Collapsed);
318
319        // Full mode: content takes the entire panel, no list visible.
320        if selected_state == ExpandState::Full {
321            if let Some(idx) = selected
322                && let Some(entry) = self.entries.get(idx)
323            {
324                let text = entry.full_text.as_deref().unwrap_or(&entry.context);
325
326                // Split into fixed header (title + divider) and scrollable content.
327                let title_display = if entry.title.is_empty() {
328                    &entry.filename
329                } else {
330                    &entry.title
331                };
332
333                let parts = Layout::default()
334                    .direction(Direction::Vertical)
335                    .constraints([
336                        Constraint::Length(1), // title
337                        Constraint::Length(1), // divider
338                        Constraint::Min(0),    // content
339                    ])
340                    .split(inner);
341
342                // Fixed title header.
343                f.render_widget(
344                    Paragraph::new(Line::from(vec![
345                        Span::styled(
346                            format!("\u{25BC} {} ", title_display),
347                            Style::default()
348                                .fg(theme.fg_selected.to_ratatui())
349                                .bg(bg)
350                                .add_modifier(Modifier::BOLD),
351                        ),
352                        Span::styled(
353                            format!(" {}", entry.filename),
354                            Style::default().fg(fg_muted).bg(bg),
355                        ),
356                    ]))
357                    .style(Style::default().bg(bg)),
358                    parts[0],
359                );
360
361                // Fixed divider.
362                f.render_widget(
363                    Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
364                        .style(Style::default().fg(fg_muted).bg(bg)),
365                    parts[1],
366                );
367
368                // Scrollable content.
369                let indent = 2usize;
370                let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
371                let target = self.current_note.get_clean_name().to_lowercase();
372
373                let mut lines = Vec::new();
374                for line in text.lines() {
375                    let wrapped = wrap_line(line, wrap_width);
376                    for wline in wrapped {
377                        let spans = highlight_link(&wline, &target, fg_muted, bg, theme);
378                        let mut indented =
379                            vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
380                        indented.extend(spans);
381                        lines.push(Line::from(indented));
382                    }
383                }
384
385                let total_lines = lines.len();
386                let viewport = parts[2].height as usize;
387                self.content_scroll_max = total_lines.saturating_sub(viewport);
388                self.content_scroll = self.content_scroll.min(self.content_scroll_max);
389
390                f.render_widget(
391                    Paragraph::new(lines)
392                        .scroll((self.content_scroll as u16, 0))
393                        .style(Style::default().bg(bg)),
394                    parts[2],
395                );
396            }
397            return;
398        }
399
400        // Context or Collapsed: show the list, optionally with preview below.
401        let has_context = selected_state == ExpandState::Context;
402
403        let (list_area, divider_area, content_area) = if has_context {
404            let max_list = inner.height / 2;
405            let list_height = (self.entries.len() as u16).min(max_list).max(1);
406            let areas = Layout::default()
407                .direction(Direction::Vertical)
408                .constraints([
409                    Constraint::Length(list_height),
410                    Constraint::Length(1),
411                    Constraint::Min(0),
412                ])
413                .split(inner);
414            (areas[0], Some(areas[1]), Some(areas[2]))
415        } else {
416            (inner, None, None)
417        };
418
419        // Build list items (1 line per entry).
420        let items: Vec<ListItem> = self
421            .entries
422            .iter()
423            .enumerate()
424            .map(|(i, entry)| {
425                let is_selected = selected == Some(i);
426                let title_style = if is_selected {
427                    Style::default()
428                        .fg(theme.fg_selected.to_ratatui())
429                        .bg(theme.bg_selected.to_ratatui())
430                        .add_modifier(Modifier::BOLD)
431                } else {
432                    Style::default().fg(fg).bg(bg)
433                };
434                let title_display = if entry.title.is_empty() {
435                    &entry.filename
436                } else {
437                    &entry.title
438                };
439                let expand_marker = match self.expand_states.get(i) {
440                    Some(ExpandState::Context) => "\u{25BC}",
441                    _ => " ",
442                };
443                ListItem::new(Line::from(vec![
444                    Span::styled(format!("{} {} ", expand_marker, title_display), title_style),
445                    Span::styled(
446                        format!(" {}", entry.filename),
447                        Style::default().fg(fg_muted).bg(if is_selected {
448                            theme.bg_selected.to_ratatui()
449                        } else {
450                            bg
451                        }),
452                    ),
453                ]))
454            })
455            .collect();
456
457        let list = List::new(items)
458            .style(Style::default().bg(bg))
459            .highlight_style(Style::default().bg(theme.bg_selected.to_ratatui()));
460
461        f.render_stateful_widget(list, list_area, &mut self.list_state);
462
463        // Divider between list and content.
464        if let Some(div) = divider_area {
465            f.render_widget(
466                Paragraph::new("\u{2500}".repeat(div.width as usize))
467                    .style(Style::default().fg(fg_muted).bg(bg)),
468                div,
469            );
470        }
471
472        // Render context preview below the list: show the full note text
473        // scrolled so the first link occurrence is visible with context above.
474        if let Some(area) = content_area
475            && let Some(idx) = selected
476            && let Some(entry) = self.entries.get(idx)
477            && selected_state == ExpandState::Context
478        {
479            let text = entry.full_text.as_deref().unwrap_or(&entry.context);
480            let indent = 2usize;
481            let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
482            let target = self.current_note.get_clean_name().to_lowercase();
483
484            let mut lines = Vec::new();
485
486            // Track which rendered line contains the first link match.
487            let mut link_line: Option<usize> = None;
488
489            for line in text.lines() {
490                let wrapped = wrap_line(line, wrap_width);
491                for wline in wrapped {
492                    if link_line.is_none()
493                        && (find_case_insensitive(&wline, &format!("[[{}", target)).is_some()
494                            || find_case_insensitive(&wline, &format!("({})", target)).is_some())
495                    {
496                        link_line = Some(lines.len());
497                    }
498                    let spans = highlight_link(&wline, &target, fg_muted, bg, theme);
499                    let mut indented =
500                        vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
501                    indented.extend(spans);
502                    lines.push(Line::from(indented));
503                }
504            }
505
506            // Scroll to show the link with context above. If the content from
507            // the link to the end fits within the viewport, scroll back further
508            // to fill the available space.
509            let viewport = area.height as usize;
510            let total = lines.len();
511            let link_pos = link_line.unwrap_or(0);
512            let lines_after_link = total.saturating_sub(link_pos);
513            let scroll_to = if lines_after_link <= viewport {
514                // Content from link to end fits — scroll back to fill the viewport.
515                total.saturating_sub(viewport)
516            } else {
517                // More content below the link — show 2 lines of context above.
518                link_pos.saturating_sub(2)
519            } as u16;
520
521            f.render_widget(
522                Paragraph::new(lines)
523                    .scroll((scroll_to, 0))
524                    .style(Style::default().bg(bg)),
525                area,
526            );
527        }
528    }
529}
530
531// ---------------------------------------------------------------------------
532// Standalone async helpers
533// ---------------------------------------------------------------------------
534
535/// Load all backlinks for `note_path` from the vault, fetching note text and
536/// extracting context for each one.
537async fn load_backlinks(vault: &NoteVault, note_path: &VaultPath) -> Vec<BacklinkEntry> {
538    let backlinks = match vault.get_backlinks(note_path).await {
539        Ok(bl) => bl,
540        Err(_) => return Vec::new(),
541    };
542
543    let target_name = note_path.get_clean_name();
544
545    let mut entries = Vec::with_capacity(backlinks.len());
546    for (entry_data, content_data) in backlinks {
547        let text = vault
548            .get_note_text(&entry_data.path)
549            .await
550            .unwrap_or_default();
551        let context = extract_context(&text, &target_name);
552        let (_parent, filename) = entry_data.path.get_parent_path();
553
554        entries.push(BacklinkEntry {
555            path: entry_data.path,
556            title: content_data.title,
557            filename,
558            context,
559            full_text: Some(text),
560        });
561    }
562
563    entries
564}
565
566/// Find the paragraph in `text` that contains a link to `target_name`.
567///
568/// A "paragraph" is a run of consecutive non-blank lines. The function
569/// searches for several link patterns (case-insensitive):
570/// - `[[target_name]]`        — full wikilink
571/// - `[[target_name`          — partial wikilink (e.g. with alias)
572/// - `(target_name)`          — markdown link
573/// - `(target_name.md)`       — markdown link with extension
574///
575/// If no match is found, returns the first non-blank line as a fallback.
576fn extract_context(text: &str, target_name: &str) -> String {
577    let target_lower = target_name.to_lowercase();
578
579    // Build the name with extension via VaultPath (avoids hardcoding `.md`).
580    let with_ext = VaultPath::note_path_from(&target_lower)
581        .to_string_with_ext()
582        .to_lowercase();
583    // Extract just the filename portion (after the last `/`).
584    let filename_ext = with_ext.rsplit('/').next().unwrap_or(&with_ext);
585
586    // Build search needles (lowercase).
587    let wikilink_full = format!("[[{}]]", target_lower);
588    let wikilink_partial = format!("[[{}", target_lower);
589    let md_link = format!("({})", target_lower);
590    let md_link_ext = format!("({})", filename_ext);
591
592    // Split text into paragraphs (groups of consecutive non-blank lines).
593    let paragraphs = split_paragraphs(text);
594
595    for para in &paragraphs {
596        let lower = para.to_lowercase();
597        if lower.contains(&wikilink_full)
598            || lower.contains(&wikilink_partial)
599            || lower.contains(&md_link)
600            || lower.contains(&md_link_ext)
601        {
602            return para.clone();
603        }
604    }
605
606    // Fallback: first non-blank line.
607    text.lines()
608        .find(|l| !l.trim().is_empty())
609        .unwrap_or("")
610        .to_string()
611}
612
613/// Split text into paragraphs. A paragraph is one or more consecutive
614/// non-blank lines. Blank lines act as separators.
615fn split_paragraphs(text: &str) -> Vec<String> {
616    let mut paragraphs = Vec::new();
617    let mut current: Vec<&str> = Vec::new();
618
619    for line in text.lines() {
620        if line.trim().is_empty() {
621            if !current.is_empty() {
622                paragraphs.push(current.join("\n"));
623                current.clear();
624            }
625        } else {
626            current.push(line);
627        }
628    }
629    if !current.is_empty() {
630        paragraphs.push(current.join("\n"));
631    }
632
633    paragraphs
634}
635
636// ---------------------------------------------------------------------------
637// Rendering helpers
638// ---------------------------------------------------------------------------
639
640/// Wrap a single line into multiple lines that fit within `max_width` characters.
641/// Uses character count (not byte length) for width. Wraps at word boundaries
642/// when possible, hard-breaks otherwise.
643fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
644    if max_width == 0 || line.chars().count() <= max_width {
645        return vec![line.to_string()];
646    }
647
648    let mut result = Vec::new();
649    let mut remaining = line;
650
651    while remaining.chars().count() > max_width {
652        // Find the byte index of the max_width-th character.
653        let byte_limit = remaining
654            .char_indices()
655            .nth(max_width)
656            .map(|(i, _)| i)
657            .unwrap_or(remaining.len());
658
659        // Try to find a space to break at (within the allowed character range).
660        let break_at = remaining[..byte_limit]
661            .rfind(' ')
662            .map(|i| i + 1) // include the space on the current line
663            .unwrap_or(byte_limit); // hard break if no space
664        result.push(remaining[..break_at].trim_end().to_string());
665        remaining = &remaining[break_at..];
666    }
667    if !remaining.is_empty() {
668        result.push(remaining.to_string());
669    }
670    result
671}
672
673/// Case-insensitive search for `needle` in `haystack`, returning the byte
674/// range `(start, end)` in `haystack` where the match occurs. Compares
675/// char-by-char via `to_lowercase()` so byte lengths are always derived from
676/// the original string, avoiding the case-folding byte-mismatch problem.
677fn find_case_insensitive(haystack: &str, needle: &str) -> Option<(usize, usize)> {
678    let needle_chars: Vec<char> = needle.chars().collect();
679    if needle_chars.is_empty() {
680        return None;
681    }
682    let hay_indices: Vec<(usize, char)> = haystack.char_indices().collect();
683    'outer: for start_idx in 0..hay_indices.len() {
684        if start_idx + needle_chars.len() > hay_indices.len() {
685            break;
686        }
687        for (j, &nc) in needle_chars.iter().enumerate() {
688            let hc = hay_indices[start_idx + j].1;
689            // Compare lowercased chars.
690            let mut h_lower = hc.to_lowercase();
691            let mut n_lower = nc.to_lowercase();
692            if h_lower.next() != n_lower.next() {
693                continue 'outer;
694            }
695        }
696        // Match found — compute byte range from haystack char indices.
697        let byte_start = hay_indices[start_idx].0;
698        let byte_end = if start_idx + needle_chars.len() < hay_indices.len() {
699            hay_indices[start_idx + needle_chars.len()].0
700        } else {
701            haystack.len()
702        };
703        return Some((byte_start, byte_end));
704    }
705    None
706}
707
708/// Render a line with link references to `target` in bold.
709/// Splits the line into owned spans: normal text in `fg_muted`, link matches in bold accent.
710/// Uses case-insensitive char-by-char matching to avoid byte-index mismatches.
711fn highlight_link(
712    line: &str,
713    target: &str,
714    fg_muted: ratatui::style::Color,
715    bg: ratatui::style::Color,
716    theme: &Theme,
717) -> Vec<Span<'static>> {
718    let normal_style = Style::default().fg(fg_muted).bg(bg);
719    let bold_style = Style::default()
720        .fg(theme.accent.to_ratatui())
721        .bg(bg)
722        .add_modifier(Modifier::BOLD);
723
724    // Build the with-extension form for markdown links.
725    let with_ext = VaultPath::note_path_from(target)
726        .to_string_with_ext()
727        .to_lowercase();
728    let filename_ext = with_ext.rsplit('/').next().unwrap_or(&with_ext).to_string();
729
730    // Build all needles to search for.
731    let needles = [
732        format!("[[{}]]", target),
733        format!("[[{}", target),
734        format!("({})", target),
735        format!("({})", filename_ext),
736    ];
737
738    // Find the earliest matching needle by byte position.
739    let mut best_match: Option<(usize, usize)> = None; // (byte_start, byte_end)
740    for needle in &needles {
741        if let Some((start, end)) = find_case_insensitive(line, needle)
742            && (best_match.is_none() || start < best_match.unwrap().0)
743        {
744            best_match = Some((start, end));
745        }
746    }
747
748    let Some((start, end)) = best_match else {
749        return vec![Span::styled(line.to_string(), normal_style)];
750    };
751
752    let mut spans = Vec::new();
753    if start > 0 {
754        spans.push(Span::styled(line[..start].to_string(), normal_style));
755    }
756    spans.push(Span::styled(line[start..end].to_string(), bold_style));
757    if end < line.len() {
758        spans.push(Span::styled(line[end..].to_string(), normal_style));
759    }
760    spans
761}
762
763// ---------------------------------------------------------------------------
764// Tests
765// ---------------------------------------------------------------------------
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    #[test]
772    fn extract_context_finds_wikilink_paragraph() {
773        let text = "\
774# Heading
775
776This is an intro paragraph.
777
778Here I reference [[my-note]] in some context
779that spans two lines.
780
781Another paragraph without links.";
782
783        let result = extract_context(text, "my-note");
784        assert!(result.contains("[[my-note]]"));
785        assert!(result.contains("that spans two lines"));
786    }
787
788    #[test]
789    fn extract_context_fallback_to_first_line() {
790        let text = "\
791# No links here
792
793Just a normal paragraph.";
794
795        let result = extract_context(text, "other-note");
796        assert_eq!(result, "# No links here");
797    }
798
799    #[test]
800    fn extract_context_finds_markdown_link() {
801        let text = "\
802# Title
803
804See [related](my-note.md) for details.
805
806Unrelated content.";
807
808        let result = extract_context(text, "my-note");
809        assert!(result.contains("(my-note.md)"));
810    }
811
812    #[test]
813    fn wrap_line_fits_within_width() {
814        let result = wrap_line("short", 20);
815        assert_eq!(result, vec!["short"]);
816    }
817
818    #[test]
819    fn wrap_line_breaks_at_word_boundary() {
820        let result = wrap_line("hello world foo bar", 12);
821        assert_eq!(result, vec!["hello world", "foo bar"]);
822    }
823
824    #[test]
825    fn wrap_line_hard_breaks_long_word() {
826        let result = wrap_line("abcdefghij", 5);
827        assert_eq!(result, vec!["abcde", "fghij"]);
828    }
829
830    #[test]
831    fn wrap_line_handles_multibyte_chars() {
832        // 5 CJK characters — each is 1 char, should wrap at char boundary
833        let result = wrap_line("日本語テスト", 3);
834        assert_eq!(result, vec!["日本語", "テスト"]);
835    }
836
837    #[test]
838    fn wrap_line_empty_string() {
839        let result = wrap_line("", 10);
840        assert_eq!(result, vec![""]);
841    }
842
843    #[test]
844    fn highlight_link_bolds_wikilink() {
845        let spans = highlight_link(
846            "see [[my-note]] here",
847            "my-note",
848            ratatui::style::Color::Gray,
849            ratatui::style::Color::Black,
850            &crate::settings::themes::Theme::default(),
851        );
852        assert_eq!(spans.len(), 3);
853        assert_eq!(spans[0].content, "see ");
854        assert_eq!(spans[1].content, "[[my-note]]");
855        assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
856        assert_eq!(spans[2].content, " here");
857    }
858
859    #[test]
860    fn highlight_link_case_insensitive() {
861        let spans = highlight_link(
862            "See [[My-Note]] here",
863            "my-note",
864            ratatui::style::Color::Gray,
865            ratatui::style::Color::Black,
866            &crate::settings::themes::Theme::default(),
867        );
868        assert_eq!(spans.len(), 3);
869        assert_eq!(spans[1].content, "[[My-Note]]");
870        assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
871    }
872
873    #[test]
874    fn highlight_link_markdown_with_extension() {
875        let spans = highlight_link(
876            "see [link](my-note.md) here",
877            "my-note",
878            ratatui::style::Color::Gray,
879            ratatui::style::Color::Black,
880            &crate::settings::themes::Theme::default(),
881        );
882        assert_eq!(spans.len(), 3);
883        assert!(spans[1].content.contains("my-note.md"));
884        assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
885    }
886
887    #[test]
888    fn highlight_link_no_match_returns_single_span() {
889        let spans = highlight_link(
890            "nothing here",
891            "other",
892            ratatui::style::Color::Gray,
893            ratatui::style::Color::Black,
894            &crate::settings::themes::Theme::default(),
895        );
896        assert_eq!(spans.len(), 1);
897    }
898}