use crate::store::GitHubRepo;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Row, Table, TableState, Wrap},
};
pub struct StoreTuiState {
pub plugins: Vec<GitHubRepo>,
pub table_state: TableState,
pub search_mode: bool,
pub search_input: String,
pub readme_content: Option<String>,
pub readme_loading: bool,
pub readme_scroll: u16,
pub sort_mode: SortMode,
pub message: Option<String>,
}
#[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,
}
}
}
impl StoreTuiState {
pub fn new() -> Self {
Self {
plugins: Vec::new(),
table_state: TableState::default(),
search_mode: false,
search_input: String::new(),
readme_content: None,
readme_loading: false,
readme_scroll: 0,
sort_mode: SortMode::Stars,
message: None,
}
}
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;
}
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))
}),
}
}
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 scroll_readme_down(&mut self, n: u16) {
self.readme_scroll = self.readme_scroll.saturating_add(n);
}
pub fn scroll_readme_up(&mut self, n: u16) {
self.readme_scroll = self.readme_scroll.saturating_sub(n);
}
pub fn draw(&mut self, f: &mut Frame) {
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 {
let match_info = format!(" ({} results)", self.plugins.len());
Line::from(vec![
Span::styled(
" rvpm store ",
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" / ",
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 {
Span::styled(
format!(
" {} plugins sort:{}",
self.plugins.len(),
self.sort_mode.label()
),
Style::default().fg(Color::DarkGray),
)
};
Line::from(vec![
Span::styled(
" rvpm store ",
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 main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(chunks[1]);
let rows: Vec<Row> = self
.plugins
.iter()
.map(|repo| {
let desc = repo.description.as_deref().unwrap_or("");
let desc_truncated: String = desc.chars().take(40).collect();
Row::new(vec![
ratatui::widgets::Cell::from(format!(" \u{2605}{}", repo.stars_display()))
.style(Style::default().fg(Color::Yellow)),
ratatui::widgets::Cell::from(repo.plugin_name().to_string())
.style(Style::default().fg(Color::White)),
ratatui::widgets::Cell::from(desc_truncated)
.style(Style::default().fg(Color::DarkGray)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(8),
Constraint::Length(30),
Constraint::Min(10),
],
)
.block(
Block::default()
.title(" Plugins ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.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);
let readme_text = if self.readme_loading {
"Loading README...".to_string()
} else {
self.readme_content.clone().unwrap_or_else(|| {
if self.plugins.is_empty() {
"Press / to search for plugins".to_string()
} else {
"Loading...".to_string()
}
})
};
let readme_title = self
.selected_repo()
.map(|r| format!(" {} ", r.full_name))
.unwrap_or_else(|| " README ".to_string());
let readme = Paragraph::new(readme_text)
.block(
Block::default()
.title(readme_title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.wrap(Wrap { trim: false })
.scroll((self.readme_scroll, 0));
f.render_widget(readme, main_chunks[1]);
let footer = if self.search_mode {
Paragraph::new(Line::from(vec![
Span::styled(" Enter", Style::default().fg(Color::Yellow)),
Span::styled(":search ", Style::default().fg(Color::DarkGray)),
Span::styled("Esc", Style::default().fg(Color::Yellow)),
Span::styled(":cancel", Style::default().fg(Color::DarkGray)),
]))
} else {
Paragraph::new(Line::from(vec![
Span::styled(" /", Style::default().fg(Color::Yellow)),
Span::styled(":search ", Style::default().fg(Color::DarkGray)),
Span::styled("j/k", Style::default().fg(Color::Yellow)),
Span::styled(":move ", Style::default().fg(Color::DarkGray)),
Span::styled("C-d/C-u", Style::default().fg(Color::Yellow)),
Span::styled(":scroll ", Style::default().fg(Color::DarkGray)),
Span::styled("s", Style::default().fg(Color::Yellow)),
Span::styled(":sort ", Style::default().fg(Color::DarkGray)),
Span::styled("Enter", Style::default().fg(Color::Yellow)),
Span::styled(":add ", Style::default().fg(Color::DarkGray)),
Span::styled("o", Style::default().fg(Color::Yellow)),
Span::styled(":open ", Style::default().fg(Color::DarkGray)),
Span::styled("R", Style::default().fg(Color::Yellow)),
Span::styled(":refresh ", 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],
);
}
}
#[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 = StoreTuiState::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 = StoreTuiState::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 = StoreTuiState::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 = StoreTuiState::new();
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);
}
}