use crate::browse::GitHubRepo;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Row, Table, TableState, Wrap},
};
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
List,
Readme,
}
pub struct BrowseTuiState {
pub plugins: Vec<GitHubRepo>,
pub table_state: TableState,
pub search_mode: bool,
pub api_search_mode: bool,
pub search_input: String,
pub search_pattern: Option<String>,
pub search_matches: Vec<usize>,
pub search_cursor: usize,
pub installed: HashSet<String>,
pub readme_command: Option<Vec<String>>,
pub readme_content: Option<String>,
pub readme_loading: bool,
pub readme_scroll: u16,
pub readme_prepared: String,
readme_prepared_key: Option<(String, usize, bool, u16)>,
pub readme_rendered: Option<ratatui::text::Text<'static>>,
pub readme_external_rendered: Option<ratatui::text::Text<'static>>,
pub readme_external_key: Option<(String, usize, u16)>,
pub readme_line_count: u16,
pub readme_visible_height: u16,
pub readme_visible_width: u16,
pub sort_mode: SortMode,
pub message: Option<String>,
pub focus: Focus,
pub show_help: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SortMode {
Stars,
Updated,
Name,
}
impl SortMode {
pub fn label(&self) -> &str {
match self {
SortMode::Stars => "stars",
SortMode::Updated => "updated",
SortMode::Name => "name",
}
}
pub fn next(&self) -> Self {
match self {
SortMode::Stars => SortMode::Updated,
SortMode::Updated => SortMode::Name,
SortMode::Name => SortMode::Stars,
}
}
}
fn strip_common_html(input: &str) -> String {
const REMOVE_TAGS: &[&str] = &[
"a", "br", "div", "p", "picture", "source", "sub", "sup", "kbd", "details", "summary",
"center", "span", "table", "tr", "td", "th", "tbody", "thead", "ul", "li", "ol",
];
let mut out = String::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
let Some(lt_rel) = input[i..].find('<') else {
out.push_str(&input[i..]);
break;
};
let lt = i + lt_rel;
out.push_str(&input[i..lt]);
if input[lt..].starts_with("<!--") {
if let Some(end_rel) = input[lt + 4..].find("-->") {
i = lt + 4 + end_rel + 3;
continue;
}
out.push_str(&input[lt..]);
break;
}
let Some(gt_rel) = input[lt..].find('>') else {
out.push_str(&input[lt..]);
break;
};
let gt = lt + gt_rel;
let tag = &input[lt..=gt];
let name = parse_tag_name(tag);
let lname = name.to_ascii_lowercase();
if lname == "img" {
if let Some(alt) = extract_alt(tag)
&& !alt.is_empty()
{
out.push_str(&alt);
}
} else if REMOVE_TAGS.iter().any(|t| *t == lname) {
} else {
out.push_str(tag);
}
i = gt + 1;
}
out
}
fn parse_tag_name(tag: &str) -> String {
let inner = tag
.trim_start_matches('<')
.trim_start_matches('/')
.trim_start_matches('!');
inner
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect()
}
fn wrap_tables_as_code_blocks(input: &str) -> String {
use unicode_width::UnicodeWidthStr;
let lines: Vec<&str> = input.lines().collect();
let mut out = String::new();
let mut i = 0;
let mut in_fence = false;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
out.push_str(line);
out.push('\n');
i += 1;
continue;
}
if !in_fence
&& is_table_row(line)
&& i + 1 < lines.len()
&& is_table_separator(lines[i + 1])
{
let mut end = i + 2;
while end < lines.len() && is_table_row(lines[end]) {
end += 1;
}
let rows = &lines[i..end];
let parsed: Vec<Vec<&str>> = rows
.iter()
.map(|l| {
let t = l.trim();
let inner = t.strip_prefix('|').unwrap_or(t);
let inner = inner.strip_suffix('|').unwrap_or(inner);
inner.split('|').map(|c| c.trim()).collect()
})
.collect();
let ncols = parsed.iter().map(|r| r.len()).max().unwrap_or(0);
let mut widths = vec![0usize; ncols];
for (row_idx, row) in parsed.iter().enumerate() {
if row_idx == 1 {
continue;
}
for (ci, cell) in row.iter().enumerate() {
widths[ci] = widths[ci].max(UnicodeWidthStr::width(*cell));
}
}
out.push_str("\n```text\n");
for (row_idx, row) in parsed.iter().enumerate() {
out.push('|');
for (ci, width) in widths.iter().enumerate() {
out.push(' ');
let cell = row.get(ci).copied().unwrap_or("");
if row_idx == 1 {
out.push_str(&"-".repeat((*width).max(3)));
} else {
out.push_str(cell);
let pad = width.saturating_sub(UnicodeWidthStr::width(cell));
for _ in 0..pad {
out.push(' ');
}
}
out.push(' ');
out.push('|');
}
out.push('\n');
}
out.push_str("```\n");
i = end;
continue;
}
out.push_str(line);
out.push('\n');
i += 1;
}
out
}
fn is_table_row(line: &str) -> bool {
let t = line.trim();
t.starts_with('|') && t.len() >= 2 && t.contains('|')
}
fn is_table_separator(line: &str) -> bool {
let t = line.trim();
if !t.starts_with('|') || !t.ends_with('|') || t.len() < 3 {
return false;
}
let inner = &t[1..t.len() - 1];
inner.split('|').all(|cell| {
let c = cell.trim();
!c.is_empty() && c.chars().all(|ch| matches!(ch, '-' | ':' | ' '))
})
}
fn text_to_owned(text: ratatui::text::Text<'_>) -> ratatui::text::Text<'static> {
use ratatui::text::{Line, Span, Text};
let lines: Vec<Line<'static>> = text
.lines
.into_iter()
.map(|line| {
let spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| Span::styled(span.content.into_owned(), span.style))
.collect();
let mut l = Line::from(spans);
l.style = line.style;
l.alignment = line.alignment;
l
})
.collect();
let mut out = Text::from(lines);
out.style = text.style;
out.alignment = text.alignment;
out
}
fn estimate_wrapped_rows(text: &ratatui::text::Text<'_>, pane_width: u16) -> u16 {
use unicode_width::UnicodeWidthStr;
if pane_width == 0 {
return text.lines.len().try_into().unwrap_or(u16::MAX);
}
let w = pane_width as usize;
let total: usize = text
.lines
.iter()
.map(|line| {
let display: usize = line
.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
display.max(1).div_ceil(w)
})
.sum();
total.try_into().unwrap_or(u16::MAX)
}
fn sanitize_cell_text(s: &str) -> String {
s.chars()
.filter(|c| {
let code = *c as u32;
!(0xE000..=0xF8FF).contains(&code)
&& !(0xF0000..=0xFFFFD).contains(&code)
&& !(0x100000..=0x10FFFD).contains(&code)
&& !(0xFE00..=0xFE0F).contains(&code)
&& !(0xE0100..=0xE01EF).contains(&code)
})
.collect()
}
fn extract_alt(tag: &str) -> Option<String> {
let lower = tag.to_ascii_lowercase();
let pos = lower.find("alt=")?;
let rest = &tag[pos + 4..];
let delim = rest.chars().next()?;
if delim != '"' && delim != '\'' {
return None;
}
let after = &rest[delim.len_utf8()..];
let end = after.find(delim)?;
Some(after[..end].to_string())
}
impl BrowseTuiState {
pub fn new() -> Self {
Self {
plugins: Vec::new(),
table_state: TableState::default(),
search_mode: false,
api_search_mode: false,
search_input: String::new(),
search_pattern: None,
search_matches: Vec::new(),
search_cursor: 0,
installed: HashSet::new(),
readme_command: None,
readme_content: None,
readme_loading: false,
readme_scroll: 0,
readme_prepared: String::new(),
readme_prepared_key: None,
readme_rendered: None,
readme_external_rendered: None,
readme_external_key: None,
readme_line_count: 0,
readme_visible_height: 0,
readme_visible_width: 0,
sort_mode: SortMode::Stars,
message: None,
focus: Focus::List,
show_help: false,
}
}
pub fn set_plugins(&mut self, plugins: Vec<GitHubRepo>) {
self.plugins = plugins;
self.sort_plugins();
if !self.plugins.is_empty() {
self.table_state.select(Some(0));
}
self.readme_content = None;
self.readme_scroll = 0;
self.search_pattern = None;
self.search_matches.clear();
self.search_cursor = 0;
}
pub fn sort_plugins(&mut self) {
match self.sort_mode {
SortMode::Stars => self
.plugins
.sort_by_key(|p| std::cmp::Reverse(p.stargazers_count)),
SortMode::Updated => self.plugins.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)),
SortMode::Name => self.plugins.sort_by(|a, b| {
a.plugin_name()
.cmp(b.plugin_name())
.then_with(|| a.full_name.cmp(&b.full_name))
}),
}
if let Some(pat) = self.search_pattern.clone() {
self.run_local_search(&pat);
}
}
pub fn selected_repo(&self) -> Option<&GitHubRepo> {
self.table_state
.selected()
.and_then(|i| self.plugins.get(i))
}
pub fn next(&mut self) {
if self.plugins.is_empty() {
return;
}
let i = self
.table_state
.selected()
.map(|i| {
if i >= self.plugins.len() - 1 {
0
} else {
i + 1
}
})
.unwrap_or(0);
self.table_state.select(Some(i));
self.readme_content = None;
self.readme_scroll = 0;
}
pub fn previous(&mut self) {
if self.plugins.is_empty() {
return;
}
let i = self
.table_state
.selected()
.map(|i| {
if i == 0 {
self.plugins.len() - 1
} else {
i - 1
}
})
.unwrap_or(0);
self.table_state.select(Some(i));
self.readme_content = None;
self.readme_scroll = 0;
}
pub fn go_top(&mut self) {
if !self.plugins.is_empty() {
self.table_state.select(Some(0));
self.readme_content = None;
self.readme_scroll = 0;
}
}
pub fn go_bottom(&mut self) {
if !self.plugins.is_empty() {
self.table_state.select(Some(self.plugins.len() - 1));
self.readme_content = None;
self.readme_scroll = 0;
}
}
pub fn move_down(&mut self, n: usize) {
if self.plugins.is_empty() {
return;
}
let current = self.table_state.selected().unwrap_or(0);
let target = (current + n).min(self.plugins.len() - 1);
if target != current {
self.table_state.select(Some(target));
self.readme_content = None;
self.readme_scroll = 0;
}
}
pub fn move_up(&mut self, n: usize) {
let current = self.table_state.selected().unwrap_or(0);
let target = current.saturating_sub(n);
if target != current {
self.table_state.select(Some(target));
self.readme_content = None;
self.readme_scroll = 0;
}
}
pub fn toggle_focus(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::Readme,
Focus::Readme => Focus::List,
};
}
pub fn scroll_readme_down(&mut self, n: u16) {
let max = self.readme_max_scroll();
self.readme_scroll = self.readme_scroll.saturating_add(n).min(max);
}
pub fn scroll_readme_up(&mut self, n: u16) {
self.readme_scroll = self.readme_scroll.saturating_sub(n);
}
pub fn scroll_readme_to_bottom(&mut self) {
self.readme_scroll = self.readme_max_scroll();
}
fn readme_max_scroll(&self) -> u16 {
self.readme_line_count
.saturating_sub(self.readme_visible_height)
}
pub fn start_search(&mut self) {
self.search_mode = true;
self.api_search_mode = false;
self.search_input.clear();
self.message = None;
}
pub fn start_api_search(&mut self) {
self.api_search_mode = true;
self.search_mode = false;
self.search_input.clear();
self.message = None;
}
pub fn search_cancel(&mut self) {
self.search_mode = false;
self.api_search_mode = false;
self.search_input.clear();
self.search_pattern = None;
self.search_matches.clear();
self.search_cursor = 0;
}
pub fn search_confirm(&mut self) {
self.search_mode = false;
}
pub fn search_type(&mut self, c: char) {
self.search_input.push(c);
self.run_local_search(&self.search_input.clone());
}
pub fn search_backspace(&mut self) {
self.search_input.pop();
if self.search_input.is_empty() {
self.search_pattern = None;
self.search_matches.clear();
self.search_cursor = 0;
} else {
self.run_local_search(&self.search_input.clone());
}
}
fn run_local_search(&mut self, pattern: &str) {
let pat = pattern.to_lowercase();
self.search_matches = self
.plugins
.iter()
.enumerate()
.filter(|(_, r)| {
let name_hit = r.plugin_name().to_lowercase().contains(&pat);
let desc_hit = r
.description
.as_deref()
.map(|d| d.to_lowercase().contains(&pat))
.unwrap_or(false);
let topic_hit = r.topics.iter().any(|t| t.to_lowercase().contains(&pat));
name_hit || desc_hit || topic_hit
})
.map(|(i, _)| i)
.collect();
self.search_pattern = Some(pattern.to_string());
self.search_cursor = 0;
if let Some(&idx) = self.search_matches.first() {
self.table_state.select(Some(idx));
self.readme_content = None;
self.readme_scroll = 0;
}
}
pub fn search_next(&mut self) {
if self.search_matches.is_empty() {
return;
}
self.search_cursor = (self.search_cursor + 1) % self.search_matches.len();
let idx = self.search_matches[self.search_cursor];
self.table_state.select(Some(idx));
self.readme_content = None;
self.readme_scroll = 0;
}
pub fn search_prev(&mut self) {
if self.search_matches.is_empty() {
return;
}
self.search_cursor = if self.search_cursor == 0 {
self.search_matches.len() - 1
} else {
self.search_cursor - 1
};
let idx = self.search_matches[self.search_cursor];
self.table_state.select(Some(idx));
self.readme_content = None;
self.readme_scroll = 0;
}
pub fn is_installed(&self, repo: &GitHubRepo) -> bool {
self.installed.contains(&repo.full_name.to_lowercase())
}
pub fn mark_installed(&mut self, repo: &GitHubRepo) {
self.installed.insert(repo.full_name.to_lowercase());
}
pub fn external_key_matches(&self, key: &(String, usize, u16)) -> bool {
let name_ok = self
.selected_repo()
.map(|r| r.full_name == key.0)
.unwrap_or(false);
let len_ok = self.readme_content.as_ref().map(|c| c.len()).unwrap_or(0) == key.1;
let width_ok = self.readme_visible_width == key.2;
name_ok && len_ok && width_ok
}
pub fn external_key_current(&self) -> Option<(String, usize, u16)> {
let full_name = self.selected_repo()?.full_name.clone();
let content_len = self.readme_content.as_ref().map(|c| c.len()).unwrap_or(0);
Some((full_name, content_len, self.readme_visible_width))
}
pub fn build_external_source(&self) -> Option<String> {
self.readme_content.clone()
}
fn ensure_readme_prepared(&mut self) {
let selected_name = self
.selected_repo()
.map(|r| r.full_name.clone())
.unwrap_or_default();
let content_len = self.readme_content.as_ref().map(|c| c.len()).unwrap_or(0);
let key = (
selected_name,
content_len,
self.readme_loading,
self.readme_visible_width,
);
if self.readme_prepared_key.as_ref() == Some(&key) {
return;
}
let body = if self.readme_loading {
"_Loading README..._".to_string()
} else {
self.readme_content.clone().unwrap_or_else(|| {
if self.plugins.is_empty() {
"_Press / to search or S to fetch more._".to_string()
} else {
"_Loading..._".to_string()
}
})
};
let topics_prefix = self
.selected_repo()
.map(|r| {
if r.topics.is_empty() {
String::new()
} else {
let joined = r
.topics
.iter()
.map(|t| format!("`{}`", t))
.collect::<Vec<_>>()
.join(" ");
format!("**Topics:** {}\n\n---\n\n", joined)
}
})
.unwrap_or_default();
let cleaned = strip_common_html(&body);
let sanitized = sanitize_cell_text(&cleaned);
let tabled = wrap_tables_as_code_blocks(&sanitized);
self.readme_prepared = format!("{}{}", topics_prefix, tabled);
let mut rendered = tui_markdown::from_str(&self.readme_prepared);
for line in &mut rendered.lines {
for span in &mut line.spans {
span.style.bg = None;
}
line.style.bg = None;
}
self.readme_line_count = estimate_wrapped_rows(&rendered, self.readme_visible_width);
self.readme_rendered = Some(text_to_owned(rendered));
self.readme_prepared_key = Some(key);
}
pub fn draw(&mut self, f: &mut Frame) {
f.render_widget(ratatui::widgets::Clear, f.area());
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
.split(f.area());
let title_content = if self.search_mode || self.api_search_mode {
let prompt = if self.api_search_mode { " S " } else { " / " };
let match_info = if self.api_search_mode {
format!(" (GitHub API, {} cached)", self.plugins.len())
} else if self.search_input.is_empty() {
String::new()
} else {
format!(
" ({}/{} matches)",
self.search_matches.len(),
self.plugins.len()
)
};
Line::from(vec![
Span::styled(
" rvpm browse ",
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
prompt,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(&self.search_input, Style::default().fg(Color::White)),
Span::styled("\u{2588}", Style::default().fg(Color::Yellow)), Span::styled(match_info, Style::default().fg(Color::DarkGray)),
])
} else {
let info = if let Some(msg) = &self.message {
Span::styled(format!(" {}", msg), Style::default().fg(Color::Green))
} else if let Some(pat) = &self.search_pattern {
Span::styled(
format!(
" /{} {} matches sort:{}",
pat,
self.search_matches.len(),
self.sort_mode.label()
),
Style::default().fg(Color::DarkGray),
)
} else {
Span::styled(
format!(
" {} plugins sort:{}",
self.plugins.len(),
self.sort_mode.label()
),
Style::default().fg(Color::DarkGray),
)
};
Line::from(vec![
Span::styled(
" rvpm browse ",
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
info,
])
};
let title = Paragraph::new(title_content).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(title, chunks[0]);
let total_width = f.area().width;
let side_by_side = total_width >= 160;
let main_chunks = if side_by_side {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(chunks[1])
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(chunks[1])
};
let rows: Vec<Row> = self
.plugins
.iter()
.map(|repo| {
let desc = repo.description.as_deref().unwrap_or("");
let desc_truncated: String = sanitize_cell_text(desc).chars().take(40).collect();
let installed_cell = if self.is_installed(repo) {
ratatui::widgets::Cell::from(Span::styled(
"\u{2713}",
Style::default().fg(Color::Green),
))
} else {
ratatui::widgets::Cell::from(" ")
};
let topics_str: String = sanitize_cell_text(
&repo
.topics
.iter()
.take(3)
.map(|t| format!("#{}", t))
.collect::<Vec<_>>()
.join(" "),
);
let name_str = sanitize_cell_text(repo.plugin_name());
Row::new(vec![
installed_cell,
ratatui::widgets::Cell::from(format!(" \u{2605}{}", repo.stars_display()))
.style(Style::default().fg(Color::Yellow)),
ratatui::widgets::Cell::from(name_str).style(Style::default().fg(Color::White)),
ratatui::widgets::Cell::from(desc_truncated)
.style(Style::default().fg(Color::DarkGray)),
ratatui::widgets::Cell::from(topics_str)
.style(Style::default().fg(Color::DarkGray)),
])
})
.collect();
let name_col = Constraint::Min(15);
let desc_col = Constraint::Min(20);
let topics_col = if side_by_side {
Constraint::Length(18)
} else {
Constraint::Length(30)
};
let table = Table::new(
rows,
[
Constraint::Length(2),
Constraint::Length(8),
name_col,
desc_col,
topics_col,
],
)
.block(
Block::default()
.title(" Plugins ")
.borders(Borders::ALL)
.border_style(Style::default().fg(if self.focus == Focus::List {
Color::Yellow
} else {
Color::DarkGray
})),
)
.row_highlight_style(
Style::default()
.bg(Color::Indexed(237))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{25b8} ");
f.render_stateful_widget(table, main_chunks[0], &mut self.table_state);
self.readme_visible_height = main_chunks[1].height.saturating_sub(2);
self.readme_visible_width = main_chunks[1].width.saturating_sub(2);
self.ensure_readme_prepared();
let external_is_fresh = self
.readme_external_key
.as_ref()
.map(|k| self.external_key_matches(k))
.unwrap_or(false);
let rendered = if external_is_fresh {
if let Some(ext) = self.readme_external_rendered.as_ref() {
self.readme_line_count = estimate_wrapped_rows(ext, self.readme_visible_width);
ext.clone()
} else {
self.readme_rendered.clone().unwrap_or_default()
}
} else {
self.readme_rendered.clone().unwrap_or_default()
};
self.readme_scroll = self.readme_scroll.min(self.readme_max_scroll());
let readme_title = self
.selected_repo()
.map(|r| format!(" {} ", r.full_name))
.unwrap_or_else(|| " README ".to_string());
let readme = Paragraph::new(rendered)
.block(
Block::default()
.title(readme_title)
.borders(Borders::ALL)
.border_style(Style::default().fg(if self.focus == Focus::Readme {
Color::Cyan
} else {
Color::DarkGray
})),
)
.wrap(Wrap { trim: false })
.scroll((self.readme_scroll, 0));
f.render_widget(ratatui::widgets::Clear, main_chunks[1]);
f.render_widget(readme, main_chunks[1]);
let footer = if self.search_mode || self.api_search_mode {
let confirm_label = if self.api_search_mode {
":api-search "
} else {
":confirm "
};
Paragraph::new(Line::from(vec![
Span::styled(" Enter", Style::default().fg(Color::Yellow)),
Span::styled(confirm_label, Style::default().fg(Color::DarkGray)),
Span::styled("Esc", Style::default().fg(Color::Yellow)),
Span::styled(":cancel", Style::default().fg(Color::DarkGray)),
]))
} else {
let focus_label = match self.focus {
Focus::List => "readme",
Focus::Readme => "list",
};
Paragraph::new(Line::from(vec![
Span::styled(" /", Style::default().fg(Color::Yellow)),
Span::styled(":search ", Style::default().fg(Color::DarkGray)),
Span::styled("n/N", Style::default().fg(Color::Yellow)),
Span::styled(":next/prev ", Style::default().fg(Color::DarkGray)),
Span::styled("S", Style::default().fg(Color::Yellow)),
Span::styled(":api-search ", Style::default().fg(Color::DarkGray)),
Span::styled("Tab", Style::default().fg(Color::Yellow)),
Span::styled(
format!(":{} ", focus_label),
Style::default().fg(Color::DarkGray),
),
Span::styled("Enter", Style::default().fg(Color::Yellow)),
Span::styled(":add ", Style::default().fg(Color::DarkGray)),
Span::styled("l", Style::default().fg(Color::Yellow)),
Span::styled(":list ", Style::default().fg(Color::DarkGray)),
Span::styled("c", Style::default().fg(Color::Yellow)),
Span::styled(":config ", Style::default().fg(Color::DarkGray)),
Span::styled("?", Style::default().fg(Color::Yellow)),
Span::styled(":help ", Style::default().fg(Color::DarkGray)),
Span::styled("q", Style::default().fg(Color::Yellow)),
Span::styled(":quit", Style::default().fg(Color::DarkGray)),
]))
};
f.render_widget(
footer.block(Block::default().borders(Borders::ALL)),
chunks[2],
);
if self.show_help {
use ratatui::layout::Rect;
use ratatui::widgets::Clear;
let help_lines = vec![
Line::from(vec![Span::styled(
" Navigation",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![
Span::styled(" j / k ", Style::default().fg(Color::Yellow)),
Span::styled("Move / scroll down / up", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" g / G ", Style::default().fg(Color::Yellow)),
Span::styled("Go to top / bottom", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" C-d / C-u ", Style::default().fg(Color::Yellow)),
Span::styled("Half page down / up", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" C-f / C-b ", Style::default().fg(Color::Yellow)),
Span::styled("Full page down / up", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" Tab ", Style::default().fg(Color::Yellow)),
Span::styled(
"Switch focus: list / readme",
Style::default().fg(Color::White),
),
]),
Line::from(""),
Line::from(vec![Span::styled(
" Search",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![
Span::styled(" / ", Style::default().fg(Color::Yellow)),
Span::styled(
"Local incremental (name + desc + topics)",
Style::default().fg(Color::White),
),
]),
Line::from(vec![
Span::styled(" n / N ", Style::default().fg(Color::Yellow)),
Span::styled("Next / prev match", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" S ", Style::default().fg(Color::Yellow)),
Span::styled(
"GitHub API search (fetch)",
Style::default().fg(Color::White),
),
]),
Line::from(""),
Line::from(vec![Span::styled(
" Actions",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Yellow)),
Span::styled("Add plugin to config", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" l ", Style::default().fg(Color::Yellow)),
Span::styled("Switch to list TUI", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" c ", Style::default().fg(Color::Yellow)),
Span::styled("Open config.toml", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" o ", Style::default().fg(Color::Yellow)),
Span::styled("Open in browser", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" s ", Style::default().fg(Color::Yellow)),
Span::styled("Cycle sort mode", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" R ", Style::default().fg(Color::Yellow)),
Span::styled("Refresh (clear cache)", Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" q / Esc ", Style::default().fg(Color::Yellow)),
Span::styled("Quit", Style::default().fg(Color::White)),
]),
Line::from(""),
Line::from(vec![Span::styled(
" Legend",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![
Span::styled(" \u{2713} ", Style::default().fg(Color::Green)),
Span::styled(
"Already installed in your config",
Style::default().fg(Color::White),
),
]),
];
let area = f.area();
let popup_w = 60u16.min(area.width.saturating_sub(4));
let popup_h = (help_lines.len() as u16 + 2).min(area.height.saturating_sub(4));
let popup = Rect::new(
(area.width.saturating_sub(popup_w)) / 2,
(area.height.saturating_sub(popup_h)) / 2,
popup_w,
popup_h,
);
f.render_widget(Clear, popup);
f.render_widget(
Paragraph::new(help_lines).block(
Block::default()
.title(" Help [?] ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
),
popup,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_repo(name: &str, stars: u64) -> GitHubRepo {
GitHubRepo {
full_name: format!("owner/{}", name),
html_url: format!("https://github.com/owner/{}", name),
description: Some(format!("{} plugin", name)),
stargazers_count: stars,
updated_at: "2026-01-01".to_string(),
topics: vec![],
default_branch: Some("main".to_string()),
}
}
#[test]
fn test_sort_by_stars() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo("low", 10),
make_repo("high", 1000),
make_repo("mid", 100),
]);
assert_eq!(state.plugins[0].plugin_name(), "high");
assert_eq!(state.plugins[1].plugin_name(), "mid");
assert_eq!(state.plugins[2].plugin_name(), "low");
}
#[test]
fn test_sort_by_name() {
let mut state = BrowseTuiState::new();
state.sort_mode = SortMode::Name;
state.set_plugins(vec![make_repo("zebra", 10), make_repo("alpha", 1000)]);
assert_eq!(state.plugins[0].plugin_name(), "alpha");
assert_eq!(state.plugins[1].plugin_name(), "zebra");
}
#[test]
fn test_navigation() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo("a", 100),
make_repo("b", 50),
make_repo("c", 10),
]);
assert_eq!(state.table_state.selected(), Some(0));
state.next();
assert_eq!(state.table_state.selected(), Some(1));
state.next();
assert_eq!(state.table_state.selected(), Some(2));
state.next(); assert_eq!(state.table_state.selected(), Some(0));
state.previous(); assert_eq!(state.table_state.selected(), Some(2));
}
#[test]
fn test_readme_scroll() {
let mut state = BrowseTuiState::new();
state.readme_line_count = 100;
state.readme_visible_height = 20;
state.scroll_readme_down(10);
assert_eq!(state.readme_scroll, 10);
state.scroll_readme_up(3);
assert_eq!(state.readme_scroll, 7);
state.scroll_readme_up(100);
assert_eq!(state.readme_scroll, 0);
}
#[test]
fn test_scroll_readme_down_clamps_to_max() {
let mut state = BrowseTuiState::new();
state.readme_line_count = 50;
state.readme_visible_height = 20;
state.scroll_readme_down(u16::MAX);
assert_eq!(state.readme_scroll, 30);
}
#[test]
fn test_scroll_readme_to_bottom_lands_at_max() {
let mut state = BrowseTuiState::new();
state.readme_line_count = 80;
state.readme_visible_height = 25;
state.scroll_readme_to_bottom();
assert_eq!(state.readme_scroll, 55);
}
#[test]
fn test_scroll_readme_to_bottom_on_short_content_stays_at_top() {
let mut state = BrowseTuiState::new();
state.readme_line_count = 10;
state.readme_visible_height = 25;
state.scroll_readme_to_bottom();
assert_eq!(state.readme_scroll, 0);
}
#[test]
fn test_toggle_focus() {
let mut state = BrowseTuiState::new();
assert_eq!(state.focus, Focus::List);
state.toggle_focus();
assert_eq!(state.focus, Focus::Readme);
state.toggle_focus();
assert_eq!(state.focus, Focus::List);
}
#[test]
fn test_go_top_and_bottom() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo("a", 100),
make_repo("b", 50),
make_repo("c", 10),
]);
state.next();
state.next();
assert_eq!(state.table_state.selected(), Some(2));
state.readme_content = Some("old".to_string());
state.readme_scroll = 42;
state.go_top();
assert_eq!(state.table_state.selected(), Some(0));
assert!(state.readme_content.is_none());
assert_eq!(state.readme_scroll, 0);
state.go_bottom();
assert_eq!(state.table_state.selected(), Some(2));
assert!(state.readme_content.is_none());
assert_eq!(state.readme_scroll, 0);
}
#[test]
fn test_move_down_up() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo("a", 100),
make_repo("b", 90),
make_repo("c", 80),
make_repo("d", 70),
make_repo("e", 60),
]);
state.readme_content = Some("test".to_string());
state.readme_scroll = 10;
state.move_down(3);
assert_eq!(state.table_state.selected(), Some(3));
assert!(state.readme_content.is_none());
assert_eq!(state.readme_scroll, 0);
state.move_up(2);
assert_eq!(state.table_state.selected(), Some(1));
state.move_down(100);
assert_eq!(state.table_state.selected(), Some(4));
state.move_up(100);
assert_eq!(state.table_state.selected(), Some(0));
}
fn make_repo_full(
name: &str,
stars: u64,
description: Option<&str>,
topics: Vec<&str>,
) -> GitHubRepo {
GitHubRepo {
full_name: format!("owner/{}", name),
html_url: format!("https://github.com/owner/{}", name),
description: description.map(|d| d.to_string()),
stargazers_count: stars,
updated_at: "2026-01-01".to_string(),
topics: topics.iter().map(|t| t.to_string()).collect(),
default_branch: Some("main".to_string()),
}
}
#[test]
fn test_search_matches_plugin_name() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo_full("telescope", 100, Some("fuzzy"), vec![]),
make_repo_full("snacks", 90, Some("misc"), vec![]),
]);
state.start_search();
state.search_type('t');
state.search_type('e');
state.search_type('l');
assert_eq!(state.search_matches, vec![0]);
assert_eq!(state.table_state.selected(), Some(0));
}
#[test]
fn test_search_matches_description() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo_full("telescope", 100, Some("fuzzy finder"), vec![]),
make_repo_full("snacks", 90, Some("misc utilities"), vec![]),
]);
state.start_search();
state.search_type('f');
state.search_type('u');
state.search_type('z');
assert_eq!(state.search_matches, vec![0]);
}
#[test]
fn test_search_matches_topic() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo_full("telescope", 100, Some("x"), vec!["lua"]),
make_repo_full("snacks", 90, Some("y"), vec!["utility"]),
]);
state.start_search();
state.search_type('l');
state.search_type('u');
state.search_type('a');
assert_eq!(state.search_matches, vec![0]);
}
#[test]
fn test_search_case_insensitive() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo_full("Telescope", 100, Some("Fuzzy"), vec!["Lua"]),
make_repo_full("snacks", 90, Some("z"), vec![]),
]);
state.start_search();
state.search_type('L');
state.search_type('u');
state.search_type('A');
assert_eq!(state.search_matches, vec![0]);
}
#[test]
fn test_search_next_wraps() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo_full("aaa-nvim", 300, None, vec![]),
make_repo_full("bbb", 200, None, vec![]),
make_repo_full("ccc-nvim", 100, None, vec![]),
]);
state.start_search();
state.search_type('n');
state.search_type('v');
state.search_type('i');
state.search_type('m');
assert_eq!(state.search_matches, vec![0, 2]);
assert_eq!(state.table_state.selected(), Some(0));
state.search_next();
assert_eq!(state.table_state.selected(), Some(2));
state.search_next(); assert_eq!(state.table_state.selected(), Some(0));
}
#[test]
fn test_search_prev_wraps() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo_full("aaa-nvim", 300, None, vec![]),
make_repo_full("bbb", 200, None, vec![]),
make_repo_full("ccc-nvim", 100, None, vec![]),
]);
state.start_search();
state.search_type('n');
state.search_type('v');
state.search_type('i');
state.search_type('m');
assert_eq!(state.table_state.selected(), Some(0));
state.search_prev(); assert_eq!(state.table_state.selected(), Some(2));
state.search_prev();
assert_eq!(state.table_state.selected(), Some(0));
}
#[test]
fn test_search_backspace_clears_matches_when_empty() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![make_repo_full("telescope", 100, None, vec![])]);
state.start_search();
state.search_type('t');
assert!(!state.search_matches.is_empty());
state.search_backspace();
assert!(state.search_matches.is_empty());
assert!(state.search_pattern.is_none());
}
#[test]
fn test_search_cancel_clears_state() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![make_repo_full("telescope", 100, None, vec![])]);
state.start_search();
state.search_type('t');
state.search_cancel();
assert!(!state.search_mode);
assert!(!state.api_search_mode);
assert!(state.search_input.is_empty());
assert!(state.search_pattern.is_none());
assert!(state.search_matches.is_empty());
}
#[test]
fn test_search_confirm_keeps_pattern_for_next() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo_full("aaa-nvim", 300, None, vec![]),
make_repo_full("bbb-nvim", 200, None, vec![]),
]);
state.start_search();
state.search_type('n');
state.search_type('v');
state.search_confirm();
assert!(!state.search_mode);
assert_eq!(state.search_pattern.as_deref(), Some("nv"));
state.search_next();
assert_eq!(state.table_state.selected(), Some(1));
}
#[test]
fn test_start_api_search_cancels_local_search() {
let mut state = BrowseTuiState::new();
state.start_search();
state.search_type('a');
state.start_api_search();
assert!(!state.search_mode);
assert!(state.api_search_mode);
assert!(state.search_input.is_empty());
}
#[test]
fn test_set_plugins_clears_search_state() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![make_repo_full("telescope", 100, None, vec![])]);
state.start_search();
state.search_type('t');
assert!(!state.search_matches.is_empty());
state.set_plugins(vec![make_repo_full("other", 50, None, vec![])]);
assert!(state.search_matches.is_empty());
assert!(state.search_pattern.is_none());
assert_eq!(state.search_cursor, 0);
}
#[test]
fn test_is_installed_case_insensitive() {
let mut state = BrowseTuiState::new();
state.installed.insert("folke/snacks.nvim".to_string());
let repo = make_repo_full("Snacks.nvim", 100, None, vec![]);
assert!(!state.is_installed(&repo));
state.installed.clear();
state.installed.insert("owner/snacks.nvim".to_string());
let repo2 = make_repo_full("Snacks.NVIM", 100, None, vec![]);
assert!(state.is_installed(&repo2));
}
#[test]
fn test_mark_installed_adds_to_set() {
let mut state = BrowseTuiState::new();
let repo = make_repo_full("telescope.nvim", 100, None, vec![]);
assert!(!state.is_installed(&repo));
state.mark_installed(&repo);
assert!(state.is_installed(&repo));
}
#[test]
fn test_build_external_source_returns_raw_content_unchanged() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![make_repo_full(
"x.nvim",
0,
Some("desc"),
vec!["lua", "ui"], )]);
let raw = "# Title\n\n<div>html here</div>\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n";
state.readme_content = Some(raw.to_string());
assert_eq!(state.build_external_source().as_deref(), Some(raw));
}
#[test]
fn test_build_external_source_none_when_no_content() {
let state = BrowseTuiState::new();
assert!(state.build_external_source().is_none());
}
#[test]
fn test_strip_html_preserves_japanese_text() {
let input = "これは README です。\n<a href=\"...\">リンク</a> の後。";
let out = strip_common_html(input);
assert!(out.contains("これは README です。"));
assert!(out.contains("リンク"));
assert!(!out.contains("<a"));
assert!(!out.contains("</a>"));
}
#[test]
fn test_strip_html_preserves_emoji_around_img() {
let input = "🎉 hi <img alt=\"X\" src=\"y\"/> あ い";
let out = strip_common_html(input);
assert!(out.contains("🎉"));
assert!(out.contains("X"));
assert!(out.contains("あ い"));
assert!(!out.contains("<img"));
}
#[test]
fn test_strip_html_img_alt_extracted() {
let out = strip_common_html("<img src=\"x.png\" alt=\"Build Status\">");
assert_eq!(out, "Build Status");
}
#[test]
fn test_strip_html_img_no_alt_dropped() {
let out = strip_common_html("<img src=\"x.png\">");
assert_eq!(out, "");
}
#[test]
fn test_strip_html_abbr_not_false_matched_as_a() {
let input = "<abbr title=\"x\">TLA</abbr> followed by <a>link</a>";
let out = strip_common_html(input);
assert!(out.contains("<abbr"));
assert!(out.contains("TLA"));
assert!(out.contains("link"));
assert!(!out.contains("<a>"));
assert!(!out.contains("</a>"));
}
#[test]
fn test_strip_html_comment_removed() {
let out = strip_common_html("before <!-- skip me --> after");
assert!(out.contains("before"));
assert!(out.contains("after"));
assert!(!out.contains("skip me"));
}
#[test]
fn test_strip_html_unknown_tag_preserved() {
let out = strip_common_html("<mark>note</mark>");
assert!(out.contains("<mark>"));
}
#[test]
fn test_sanitize_strips_nerd_font_pua() {
let input = "icon \u{e801} here";
assert_eq!(sanitize_cell_text(input), "icon here");
}
#[test]
fn test_sanitize_strips_variation_selectors() {
let input = "gear\u{FE0F} icon";
assert_eq!(sanitize_cell_text(input), "gear icon");
}
#[test]
fn test_sanitize_keeps_ascii() {
let input = "A collection of small qol plugins for Neovim";
assert_eq!(sanitize_cell_text(input), input);
}
#[test]
fn test_wrap_tables_puts_fence_around_pipe_table() {
let input = "\
intro
| col | other |
| --- | --- |
| a | b |
outro
";
let out = wrap_tables_as_code_blocks(input);
assert!(out.contains("```text\n"));
assert!(out.contains("\n```\n"));
assert!(out.contains("intro"));
assert!(out.contains("outro"));
}
#[test]
fn test_wrap_tables_aligns_columns_inside_fence() {
let input = "\
| short | name |
| --- | --- |
| VeryLongCell | desc |
";
let out = wrap_tables_as_code_blocks(input);
let table_lines: Vec<&str> = out.lines().filter(|l| l.starts_with('|')).collect();
assert_eq!(table_lines.len(), 3);
let first_pipes: Vec<usize> = table_lines[0]
.char_indices()
.filter(|(_, c)| *c == '|')
.map(|(i, _)| i)
.collect();
for l in &table_lines[1..] {
let pipes: Vec<usize> = l
.char_indices()
.filter(|(_, c)| *c == '|')
.map(|(i, _)| i)
.collect();
assert_eq!(pipes, first_pipes, "pipe positions differ on `{}`", l);
}
}
#[test]
fn test_wrap_tables_preserves_non_table_lines() {
let input = "Hello\n\nWorld\n";
let out = wrap_tables_as_code_blocks(input);
assert!(!out.contains("```"));
assert!(out.contains("Hello"));
assert!(out.contains("World"));
}
#[test]
fn test_wrap_tables_skips_inside_fenced_code_block() {
let input = "\
intro
```markdown
| col | other |
| --- | --- |
| a | b |
```
outro
";
let out = wrap_tables_as_code_blocks(input);
assert_eq!(out.matches("```").count(), 2);
assert!(out.contains("```markdown"));
}
#[test]
fn test_wrap_tables_still_wraps_tables_outside_fences() {
let input = "\
```lua
print(1)
```
| a | b |
| --- | --- |
| 1 | 2 |
";
let out = wrap_tables_as_code_blocks(input);
assert_eq!(out.matches("```").count(), 4);
assert!(out.contains("```text"));
}
#[test]
fn test_sort_rebuilds_search_matches() {
let mut state = BrowseTuiState::new();
state.set_plugins(vec![
make_repo_full("aaa-nvim", 10, None, vec![]),
make_repo_full("zzz", 100, None, vec![]),
make_repo_full("bbb-nvim", 50, None, vec![]),
]);
state.start_search();
state.search_type('n');
state.search_type('v');
state.search_type('i');
state.search_type('m');
assert_eq!(state.search_matches, vec![1, 2]);
state.sort_mode = SortMode::Name;
state.sort_plugins();
assert_eq!(state.search_matches, vec![0, 1]);
state.search_next();
assert_eq!(
state.selected_repo().map(|r| r.plugin_name().to_string()),
Some("bbb-nvim".to_string())
);
}
#[test]
fn test_wrap_tables_without_separator_is_noop() {
let input = "| not | a table |\nbut a line";
let out = wrap_tables_as_code_blocks(input);
assert!(!out.contains("```"));
}
#[test]
fn test_sanitize_keeps_japanese_and_standard_emoji() {
let input = "プラグイン 🎉 ready";
assert_eq!(sanitize_cell_text(input), input);
}
#[test]
fn test_strip_html_multibyte_inside_tag() {
let input = "<img alt=\"ロゴ\" src=\"logo.png\"/> 本文";
let out = strip_common_html(input);
assert!(out.contains("ロゴ"));
assert!(out.contains("本文"));
}
}