use std::sync::Arc;
use kimun_core::nfs::VaultPath;
use kimun_core::NoteVault;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use ratatui::Frame;
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx};
use crate::components::file_list::{SortField, SortOrder};
use crate::keys::action_shortcuts::ActionShortcuts;
use crate::keys::{key_event_to_combo, KeyBindings};
use crate::settings::themes::Theme;
#[derive(Debug, Clone)]
pub struct BacklinkEntry {
pub path: VaultPath,
pub title: String,
pub filename: String,
pub context: String,
pub full_text: Option<String>,
}
#[derive(Clone, Copy, PartialEq)]
enum ExpandState {
Collapsed,
Context,
Full,
}
pub struct BacklinksPanel {
entries: Vec<BacklinkEntry>,
expand_states: Vec<ExpandState>,
list_state: ListState,
loading: bool,
current_note: VaultPath,
sort_field: SortField,
sort_order: SortOrder,
vault: Arc<NoteVault>,
key_bindings: KeyBindings,
content_scroll: usize,
content_scroll_max: usize,
}
impl BacklinksPanel {
pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings) -> Self {
Self {
entries: Vec::new(),
expand_states: Vec::new(),
list_state: ListState::default(),
loading: false,
current_note: VaultPath::empty(),
sort_field: SortField::Name,
sort_order: SortOrder::Ascending,
vault,
key_bindings,
content_scroll: 0,
content_scroll_max: 0,
}
}
fn is_full_expanded(&self) -> bool {
self.list_state
.selected()
.and_then(|i| self.expand_states.get(i))
.is_some_and(|s| *s == ExpandState::Full)
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn selected_path(&self) -> Option<&VaultPath> {
self.list_state
.selected()
.and_then(|i| self.entries.get(i))
.map(|e| &e.path)
}
pub fn load(&mut self, note_path: VaultPath, tx: AppTx) {
self.entries.clear();
self.expand_states.clear();
self.list_state.select(None);
self.loading = true;
self.current_note = note_path.clone();
self.content_scroll = 0;
self.content_scroll_max = 0;
let vault = Arc::clone(&self.vault);
tokio::spawn(async move {
let entries = load_backlinks(&vault, ¬e_path).await;
let _ = tx.send(AppEvent::BacklinksLoaded(entries));
});
}
pub fn on_loaded(&mut self, entries: Vec<BacklinkEntry>) {
self.entries = entries;
self.apply_sort();
self.expand_states = vec![ExpandState::Collapsed; self.entries.len()];
self.loading = false;
if !self.entries.is_empty() {
self.list_state.select(Some(0));
}
}
pub fn apply_sort(&mut self) {
let field = self.sort_field;
let order = self.sort_order;
let mut indices: Vec<usize> = (0..self.entries.len()).collect();
indices.sort_by(|&a, &b| {
let cmp = match field {
SortField::Name => self.entries[a]
.filename
.to_lowercase()
.cmp(&self.entries[b].filename.to_lowercase()),
SortField::Title => self.entries[a]
.title
.to_lowercase()
.cmp(&self.entries[b].title.to_lowercase()),
};
match order {
SortOrder::Ascending => cmp,
SortOrder::Descending => cmp.reverse(),
}
});
let sorted_entries: Vec<BacklinkEntry> =
indices.iter().map(|&i| self.entries[i].clone()).collect();
let sorted_states: Vec<ExpandState> = if self.expand_states.len() == self.entries.len() {
indices.iter().map(|&i| self.expand_states[i]).collect()
} else {
vec![ExpandState::Collapsed; sorted_entries.len()]
};
self.entries = sorted_entries;
self.expand_states = sorted_states;
}
pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
if let Some(combo) = key_event_to_combo(key) {
match self.key_bindings.get_action(&combo) {
Some(ActionShortcuts::CycleSortField) => {
self.sort_field = self.sort_field.cycle();
self.apply_sort();
self.expand_states = vec![ExpandState::Collapsed; self.entries.len()];
return EventState::Consumed;
}
Some(ActionShortcuts::SortReverseOrder) => {
self.sort_order = self.sort_order.toggle();
self.apply_sort();
self.expand_states = vec![ExpandState::Collapsed; self.entries.len()];
return EventState::Consumed;
}
Some(ActionShortcuts::FollowLink) => {
if let Some(path) = self.selected_path().cloned() {
tx.send(AppEvent::OpenPath(path)).ok();
}
return EventState::Consumed;
}
_ => {}
}
}
match key.code {
KeyCode::Up => {
if self.is_full_expanded() {
self.content_scroll = self.content_scroll.saturating_sub(1);
} else {
self.move_selection(-1);
}
EventState::Consumed
}
KeyCode::Down => {
if self.is_full_expanded() {
self.content_scroll += 1;
} else {
self.move_selection(1);
}
EventState::Consumed
}
KeyCode::Enter => {
self.toggle_expand();
EventState::Consumed
}
KeyCode::Esc => EventState::NotConsumed,
_ => EventState::NotConsumed,
}
}
fn move_selection(&mut self, delta: i32) {
if self.entries.is_empty() {
return;
}
let current = self.list_state.selected().unwrap_or(0) as i32;
let next = (current + delta).clamp(0, self.entries.len() as i32 - 1) as usize;
self.list_state.select(Some(next));
}
fn toggle_expand(&mut self) {
let Some(idx) = self.list_state.selected() else {
return;
};
if idx >= self.expand_states.len() {
return;
}
match self.expand_states[idx] {
ExpandState::Collapsed => {
self.expand_states[idx] = ExpandState::Context;
}
ExpandState::Context => {
self.content_scroll = 0;
self.expand_states[idx] = ExpandState::Full;
}
ExpandState::Full => {
self.content_scroll = 0;
self.expand_states[idx] = ExpandState::Collapsed;
}
}
}
pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
[
(ActionShortcuts::FocusSidebar, "\u{2190} editor"),
(ActionShortcuts::FollowLink, "open note"),
(ActionShortcuts::CycleSortField, "sort"),
]
.iter()
.filter_map(|(action, label)| {
self.key_bindings
.first_combo_for(action)
.map(|k| (k, label.to_string()))
})
.collect()
}
pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
let border_style = theme.border_style(focused);
let fg = theme.fg.to_ratatui();
let fg_muted = theme.fg_muted.to_ratatui();
let bg = theme.bg_panel.to_ratatui();
let sort_indicator = format!("{}{}", self.sort_field.label(), self.sort_order.label());
let title = format!("Backlinks ({}) {}", self.entries.len(), sort_indicator);
let outer = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style)
.style(theme.panel_style());
let inner = outer.inner(rect);
f.render_widget(outer, rect);
if self.loading {
f.render_widget(
Paragraph::new(" Loading...")
.style(Style::default().fg(fg_muted).bg(bg)),
inner,
);
return;
}
if self.entries.is_empty() {
f.render_widget(
Paragraph::new(" No backlinks")
.style(Style::default().fg(fg_muted).bg(bg)),
inner,
);
return;
}
let selected = self.list_state.selected();
let selected_state = selected
.and_then(|i| self.expand_states.get(i).copied())
.unwrap_or(ExpandState::Collapsed);
if selected_state == ExpandState::Full {
if let Some(idx) = selected
&& let Some(entry) = self.entries.get(idx)
{
let text = entry
.full_text
.as_deref()
.unwrap_or(&entry.context);
let title_display = if entry.title.is_empty() {
&entry.filename
} else {
&entry.title
};
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("\u{25BC} {} ", title_display),
Style::default()
.fg(theme.fg_selected.to_ratatui())
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", entry.filename),
Style::default().fg(fg_muted).bg(bg),
),
]))
.style(Style::default().bg(bg)),
parts[0],
);
f.render_widget(
Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
.style(Style::default().fg(fg_muted).bg(bg)),
parts[1],
);
let indent = 2usize;
let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
let target = self.current_note.get_clean_name().to_lowercase();
let mut lines = Vec::new();
for line in text.lines() {
let wrapped = wrap_line(line, wrap_width);
for wline in wrapped {
let spans = highlight_link(&wline, &target, fg_muted, bg, theme);
let mut indented = vec![Span::styled(
" ".repeat(indent),
Style::default().bg(bg),
)];
indented.extend(spans);
lines.push(Line::from(indented));
}
}
let total_lines = lines.len();
let viewport = parts[2].height as usize;
self.content_scroll_max = total_lines.saturating_sub(viewport);
self.content_scroll = self.content_scroll.min(self.content_scroll_max);
f.render_widget(
Paragraph::new(lines)
.scroll((self.content_scroll as u16, 0))
.style(Style::default().bg(bg)),
parts[2],
);
}
return;
}
let has_context = selected_state == ExpandState::Context;
let (list_area, divider_area, content_area) = if has_context {
let max_list = inner.height / 2;
let list_height = (self.entries.len() as u16).min(max_list).max(1);
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(list_height),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
(areas[0], Some(areas[1]), Some(areas[2]))
} else {
(inner, None, None)
};
let items: Vec<ListItem> = self
.entries
.iter()
.enumerate()
.map(|(i, entry)| {
let is_selected = selected == Some(i);
let title_style = if is_selected {
Style::default()
.fg(theme.fg_selected.to_ratatui())
.bg(theme.bg_selected.to_ratatui())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg).bg(bg)
};
let title_display = if entry.title.is_empty() {
&entry.filename
} else {
&entry.title
};
let expand_marker = match self.expand_states.get(i) {
Some(ExpandState::Context) => "\u{25BC}",
_ => " ",
};
ListItem::new(Line::from(vec![
Span::styled(format!("{} {} ", expand_marker, title_display), title_style),
Span::styled(
format!(" {}", entry.filename),
Style::default().fg(fg_muted).bg(if is_selected {
theme.bg_selected.to_ratatui()
} else {
bg
}),
),
]))
})
.collect();
let list = List::new(items)
.style(Style::default().bg(bg))
.highlight_style(Style::default().bg(theme.bg_selected.to_ratatui()));
f.render_stateful_widget(list, list_area, &mut self.list_state);
if let Some(div) = divider_area {
f.render_widget(
Paragraph::new("\u{2500}".repeat(div.width as usize))
.style(Style::default().fg(fg_muted).bg(bg)),
div,
);
}
if let Some(area) = content_area
&& let Some(idx) = selected
&& let Some(entry) = self.entries.get(idx)
&& selected_state == ExpandState::Context
{
let text = entry.full_text.as_deref().unwrap_or(&entry.context);
let indent = 2usize;
let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
let target = self.current_note.get_clean_name().to_lowercase();
let mut lines = Vec::new();
let mut link_line: Option<usize> = None;
for line in text.lines() {
let wrapped = wrap_line(line, wrap_width);
for wline in wrapped {
if link_line.is_none()
&& (find_case_insensitive(&wline, &format!("[[{}", target)).is_some()
|| find_case_insensitive(&wline, &format!("({})", target)).is_some())
{
link_line = Some(lines.len());
}
let spans = highlight_link(&wline, &target, fg_muted, bg, theme);
let mut indented = vec![Span::styled(
" ".repeat(indent),
Style::default().bg(bg),
)];
indented.extend(spans);
lines.push(Line::from(indented));
}
}
let viewport = area.height as usize;
let total = lines.len();
let link_pos = link_line.unwrap_or(0);
let lines_after_link = total.saturating_sub(link_pos);
let scroll_to = if lines_after_link <= viewport {
total.saturating_sub(viewport)
} else {
link_pos.saturating_sub(2)
} as u16;
f.render_widget(
Paragraph::new(lines)
.scroll((scroll_to, 0))
.style(Style::default().bg(bg)),
area,
);
}
}
}
async fn load_backlinks(vault: &NoteVault, note_path: &VaultPath) -> Vec<BacklinkEntry> {
let backlinks = match vault.get_backlinks(note_path).await {
Ok(bl) => bl,
Err(_) => return Vec::new(),
};
let target_name = note_path.get_clean_name();
let mut entries = Vec::with_capacity(backlinks.len());
for (entry_data, content_data) in backlinks {
let text = vault
.get_note_text(&entry_data.path)
.await
.unwrap_or_default();
let context = extract_context(&text, &target_name);
let (_parent, filename) = entry_data.path.get_parent_path();
entries.push(BacklinkEntry {
path: entry_data.path,
title: content_data.title,
filename,
context,
full_text: Some(text),
});
}
entries
}
fn extract_context(text: &str, target_name: &str) -> String {
let target_lower = target_name.to_lowercase();
let with_ext = VaultPath::note_path_from(&target_lower)
.to_string_with_ext()
.to_lowercase();
let filename_ext = with_ext
.rsplit('/')
.next()
.unwrap_or(&with_ext);
let wikilink_full = format!("[[{}]]", target_lower);
let wikilink_partial = format!("[[{}", target_lower);
let md_link = format!("({})", target_lower);
let md_link_ext = format!("({})", filename_ext);
let paragraphs = split_paragraphs(text);
for para in ¶graphs {
let lower = para.to_lowercase();
if lower.contains(&wikilink_full)
|| lower.contains(&wikilink_partial)
|| lower.contains(&md_link)
|| lower.contains(&md_link_ext)
{
return para.clone();
}
}
text.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.to_string()
}
fn split_paragraphs(text: &str) -> Vec<String> {
let mut paragraphs = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in text.lines() {
if line.trim().is_empty() {
if !current.is_empty() {
paragraphs.push(current.join("\n"));
current.clear();
}
} else {
current.push(line);
}
}
if !current.is_empty() {
paragraphs.push(current.join("\n"));
}
paragraphs
}
fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
if max_width == 0 || line.chars().count() <= max_width {
return vec![line.to_string()];
}
let mut result = Vec::new();
let mut remaining = line;
while remaining.chars().count() > max_width {
let byte_limit = remaining
.char_indices()
.nth(max_width)
.map(|(i, _)| i)
.unwrap_or(remaining.len());
let break_at = remaining[..byte_limit]
.rfind(' ')
.map(|i| i + 1) .unwrap_or(byte_limit); result.push(remaining[..break_at].trim_end().to_string());
remaining = &remaining[break_at..];
}
if !remaining.is_empty() {
result.push(remaining.to_string());
}
result
}
fn find_case_insensitive(haystack: &str, needle: &str) -> Option<(usize, usize)> {
let needle_chars: Vec<char> = needle.chars().collect();
if needle_chars.is_empty() {
return None;
}
let hay_indices: Vec<(usize, char)> = haystack.char_indices().collect();
'outer: for start_idx in 0..hay_indices.len() {
if start_idx + needle_chars.len() > hay_indices.len() {
break;
}
for (j, &nc) in needle_chars.iter().enumerate() {
let hc = hay_indices[start_idx + j].1;
let mut h_lower = hc.to_lowercase();
let mut n_lower = nc.to_lowercase();
if h_lower.next() != n_lower.next() {
continue 'outer;
}
}
let byte_start = hay_indices[start_idx].0;
let byte_end = if start_idx + needle_chars.len() < hay_indices.len() {
hay_indices[start_idx + needle_chars.len()].0
} else {
haystack.len()
};
return Some((byte_start, byte_end));
}
None
}
fn highlight_link(
line: &str,
target: &str,
fg_muted: ratatui::style::Color,
bg: ratatui::style::Color,
theme: &Theme,
) -> Vec<Span<'static>> {
let normal_style = Style::default().fg(fg_muted).bg(bg);
let bold_style = Style::default()
.fg(theme.accent.to_ratatui())
.bg(bg)
.add_modifier(Modifier::BOLD);
let with_ext = VaultPath::note_path_from(target)
.to_string_with_ext()
.to_lowercase();
let filename_ext = with_ext
.rsplit('/')
.next()
.unwrap_or(&with_ext)
.to_string();
let needles = [
format!("[[{}]]", target),
format!("[[{}", target),
format!("({})", target),
format!("({})", filename_ext),
];
let mut best_match: Option<(usize, usize)> = None; for needle in &needles {
if let Some((start, end)) = find_case_insensitive(line, needle)
&& (best_match.is_none() || start < best_match.unwrap().0)
{
best_match = Some((start, end));
}
}
let Some((start, end)) = best_match else {
return vec![Span::styled(line.to_string(), normal_style)];
};
let mut spans = Vec::new();
if start > 0 {
spans.push(Span::styled(line[..start].to_string(), normal_style));
}
spans.push(Span::styled(line[start..end].to_string(), bold_style));
if end < line.len() {
spans.push(Span::styled(line[end..].to_string(), normal_style));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_context_finds_wikilink_paragraph() {
let text = "\
# Heading
This is an intro paragraph.
Here I reference [[my-note]] in some context
that spans two lines.
Another paragraph without links.";
let result = extract_context(text, "my-note");
assert!(result.contains("[[my-note]]"));
assert!(result.contains("that spans two lines"));
}
#[test]
fn extract_context_fallback_to_first_line() {
let text = "\
# No links here
Just a normal paragraph.";
let result = extract_context(text, "other-note");
assert_eq!(result, "# No links here");
}
#[test]
fn extract_context_finds_markdown_link() {
let text = "\
# Title
See [related](my-note.md) for details.
Unrelated content.";
let result = extract_context(text, "my-note");
assert!(result.contains("(my-note.md)"));
}
#[test]
fn wrap_line_fits_within_width() {
let result = wrap_line("short", 20);
assert_eq!(result, vec!["short"]);
}
#[test]
fn wrap_line_breaks_at_word_boundary() {
let result = wrap_line("hello world foo bar", 12);
assert_eq!(result, vec!["hello world", "foo bar"]);
}
#[test]
fn wrap_line_hard_breaks_long_word() {
let result = wrap_line("abcdefghij", 5);
assert_eq!(result, vec!["abcde", "fghij"]);
}
#[test]
fn wrap_line_handles_multibyte_chars() {
let result = wrap_line("日本語テスト", 3);
assert_eq!(result, vec!["日本語", "テスト"]);
}
#[test]
fn wrap_line_empty_string() {
let result = wrap_line("", 10);
assert_eq!(result, vec![""]);
}
#[test]
fn highlight_link_bolds_wikilink() {
let spans = highlight_link(
"see [[my-note]] here",
"my-note",
ratatui::style::Color::Gray,
ratatui::style::Color::Black,
&crate::settings::themes::Theme::default(),
);
assert_eq!(spans.len(), 3);
assert_eq!(spans[0].content, "see ");
assert_eq!(spans[1].content, "[[my-note]]");
assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
assert_eq!(spans[2].content, " here");
}
#[test]
fn highlight_link_case_insensitive() {
let spans = highlight_link(
"See [[My-Note]] here",
"my-note",
ratatui::style::Color::Gray,
ratatui::style::Color::Black,
&crate::settings::themes::Theme::default(),
);
assert_eq!(spans.len(), 3);
assert_eq!(spans[1].content, "[[My-Note]]");
assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn highlight_link_markdown_with_extension() {
let spans = highlight_link(
"see [link](my-note.md) here",
"my-note",
ratatui::style::Color::Gray,
ratatui::style::Color::Black,
&crate::settings::themes::Theme::default(),
);
assert_eq!(spans.len(), 3);
assert!(spans[1].content.contains("my-note.md"));
assert!(spans[1].style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn highlight_link_no_match_returns_single_span() {
let spans = highlight_link(
"nothing here",
"other",
ratatui::style::Color::Gray,
ratatui::style::Color::Black,
&crate::settings::themes::Theme::default(),
);
assert_eq!(spans.len(), 1);
}
}