pub mod color_picker;
pub mod file_manager;
pub mod grid_table;
pub mod menu;
pub mod paginator;
pub mod source_picker;
pub mod tabs;
pub mod theme;
use crate::graveyard::{BuriedEntry, GraveyardConfig};
use crate::pm::{PackageInfo, PackageManager, PmResult};
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, TableState, Wrap},
Terminal,
};
use std::io::{self, stdout};
use std::time::Duration;
const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
#[derive(Clone, PartialEq)]
pub enum AppMode {
Menu,
FileManager,
ColorTheme,
PackManSearch,
PackManProgress,
}
#[derive(Clone, PartialEq)]
enum Mode {
Input, Browse, Confirm, Progress, Done, InstalledView, PackageDetail, GraveyardView, BurialProgress, }
struct App {
mode: Mode,
search_input: String,
cursor_pos: usize,
tick: u64,
results_by_source: Vec<(String, Vec<PackageInfo>)>,
all_results: Vec<PackageInfo>,
tab_titles: Vec<String>,
active_tab: usize,
list_state: TableState,
page: usize,
page_size: usize,
source_options: Vec<String>,
source_selected: usize,
confirm_action: String, confirm_packages: Vec<String>,
confirm_source: String, confirm_selected_yes: bool, progress_items: Vec<ProgressItem>,
status_message: String,
installed_results: Vec<PackageInfo>,
installed_loaded: bool,
detail_package: Option<PackageInfo>,
graveyard_entries: Vec<BuriedEntry>,
graveyard_selected: usize,
burial_files: Vec<String>, burial_results: Vec<Option<String>>, burial_is_exhume: bool,
should_quit: bool,
}
#[derive(Clone)]
struct ProgressItem {
name: String,
done: bool,
success: bool,
}
impl App {
fn new() -> Self {
Self {
mode: Mode::Input,
search_input: String::new(),
cursor_pos: 0,
tick: 0,
results_by_source: Vec::new(),
all_results: Vec::new(),
tab_titles: vec!["All".into()],
active_tab: 0,
list_state: TableState::default(),
page: 0,
page_size: 20,
source_options: Vec::new(),
source_selected: 0,
confirm_action: String::new(),
confirm_packages: Vec::new(),
confirm_source: String::new(),
confirm_selected_yes: true,
progress_items: Vec::new(),
status_message: String::new(),
installed_results: Vec::new(),
installed_loaded: false,
detail_package: None,
graveyard_entries: Vec::new(),
graveyard_selected: 0,
burial_files: Vec::new(),
burial_results: Vec::new(),
burial_is_exhume: false,
should_quit: false,
}
}
fn current_results(&self) -> &[PackageInfo] {
if self.active_tab == 0 {
&self.all_results
} else if let Some((_src, results)) = self.results_by_source.get(self.active_tab - 1) {
results
} else {
&[]
}
}
fn page_items(&self) -> Vec<(usize, &PackageInfo)> {
let results = self.current_results();
let start = self.page * self.page_size;
results
.iter()
.enumerate()
.skip(start)
.take(self.page_size)
.collect()
}
fn total_pages(&self) -> usize {
let total = self.current_results().len();
if total == 0 {
1
} else {
total.div_ceil(self.page_size)
}
}
fn selected_absolute_index(&self) -> Option<usize> {
self.list_state
.selected()
.map(|rel| self.page * self.page_size + rel)
}
fn selected_package(&self) -> Option<&PackageInfo> {
self.selected_absolute_index()
.and_then(|idx| self.current_results().get(idx))
}
fn update_source_options(&mut self) {
if let Some(pkg) = self.selected_package() {
let name = &pkg.name;
let sources: Vec<String> = self
.all_results
.iter()
.filter(|p| p.name == *name)
.map(|p| p.source.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
self.source_options = sources;
self.source_selected = 0;
} else {
self.source_options.clear();
}
}
fn set_results(&mut self, results_by_source: Vec<(String, Vec<PackageInfo>)>) {
self.tab_titles = vec!["All".into()];
self.all_results.clear();
self.results_by_source.clear();
for (source, pkgs) in results_by_source {
self.tab_titles.push(format!("{} ({})", source, pkgs.len()));
self.all_results.extend(pkgs.clone());
self.results_by_source.push((source, pkgs));
}
self.active_tab = 0;
self.page = 0;
self.list_state.select(if self.all_results.is_empty() {
None
} else {
Some(0)
});
self.update_source_options();
self.mark_installed();
}
fn mark_installed(&mut self) {
if self.installed_results.is_empty() {
return;
}
let installed_names: std::collections::HashSet<(String, String)> = self
.installed_results
.iter()
.map(|p| (p.name.clone(), p.source.clone()))
.collect();
for pkg in &mut self.all_results {
if installed_names.contains(&(pkg.name.clone(), pkg.source.clone())) {
pkg.installed = true;
}
}
for (_src, pkgs) in &mut self.results_by_source {
for pkg in pkgs {
if installed_names.contains(&(pkg.name.clone(), pkg.source.clone())) {
pkg.installed = true;
}
}
}
}
}
fn render(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
terminal.draw(|f| {
let size = f.area();
f.render_widget(
Block::default().style(Style::default().bg(theme::bg_color())),
size,
);
match app.mode {
Mode::Input | Mode::Browse => render_search_view(f, app, size),
Mode::Confirm => {
render_search_view(f, app, size);
render_confirm_overlay(f, app, size);
}
Mode::Progress => render_progress_view(f, app, size),
Mode::Done => render_done_view(f, app, size),
Mode::InstalledView => render_installed_view(f, app, size),
Mode::PackageDetail => render_package_detail(f, app, size),
Mode::GraveyardView => render_graveyard_view(f, app, size),
Mode::BurialProgress => render_burial_progress(f, app, size),
}
})?;
Ok(())
}
fn render_search_view(f: &mut ratatui::Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
let tab_mode = if app.mode == Mode::Browse && app.source_options.len() > 1 {
let pkg_name = app
.selected_package()
.map(|p| p.name.as_str())
.unwrap_or("");
tabs::TabMode::PackageSources {
sources: &app.source_options,
selected: app.source_selected,
pkg_name,
}
} else if app.total_pages() > 1 && app.mode == Mode::Browse {
tabs::TabMode::Pages {
current: app.page,
total: app.total_pages(),
}
} else {
tabs::TabMode::Sources {
titles: &app.tab_titles,
active: app.active_tab,
}
};
f.render_widget(tabs::TabBar { mode: tab_mode }, chunks[0]);
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(if app.mode == Mode::Input {
Style::default().fg(theme::hot_pink())
} else {
theme::border()
})
.title(Span::styled(" 🔍 Search ", theme::search_label()))
.style(Style::default().bg(theme::bg_color()));
let input_text = if app.search_input.is_empty() && app.mode != Mode::Input {
Paragraph::new(Span::styled("Type to search packages...", theme::dim()))
} else {
let mut display = app.search_input.clone();
if app.mode == Mode::Input {
let cursor_char = if app.tick % 6 < 3 { '│' } else { '▏' };
display.insert(app.cursor_pos, cursor_char);
}
Paragraph::new(Span::styled(display, theme::input()))
};
f.render_widget(input_text.block(input_block), chunks[1]);
if !app.status_message.is_empty() {
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let frame = spinner_frames[(app.tick as usize) % spinner_frames.len()];
let input_display_width: usize = app
.search_input
.chars()
.map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(1))
.sum();
let spinner_text_width = 2 + 1 + app.status_message.chars().take(30).count();
let available = chunks[1].width.saturating_sub(2) as usize;
let text_w = input_display_width.min(available);
let spinner_x = chunks[1].x + text_w as u16 + 1;
if spinner_x + (spinner_text_width as u16) < chunks[1].x + chunks[1].width {
let spinner_area = Rect::new(spinner_x, chunks[1].y + 1, spinner_text_width as u16, 1);
f.render_widget(
Paragraph::new(Span::styled(
format!(
"{} {}",
frame,
app.status_message.chars().take(30).collect::<String>()
),
Style::default().fg(theme::hot_pink()),
)),
spinner_area,
);
}
}
app.page_size = (chunks[2].height as usize).saturating_sub(4).max(1);
let items = app.page_items();
let selected_rel = app.list_state.selected();
let desc_col_max = (chunks[2].width as usize)
.saturating_sub(5 + 26 + 14 + 12 + 6)
.max(10);
let grid_rows: Vec<grid_table::GridRow> = items
.iter()
.map(|(global_idx, pkg)| {
let installed_badge = if pkg.installed { "✓ " } else { " " };
let name_raw = format!("{}{}", installed_badge, pkg.name);
let ver = if pkg.version.is_empty() {
"—".to_string()
} else {
pkg.version.chars().take(12).collect()
};
let src: String = pkg.source.chars().take(10).collect();
let desc: String = pkg.description.chars().take(desc_col_max).collect();
grid_table::GridRow {
cells: vec![
grid_table::GridCell {
text: format!("{}", global_idx + 1),
style: theme::number(),
},
grid_table::GridCell {
text: name_raw,
style: if pkg.installed {
theme::installed_pkg()
} else {
theme::pkg_name()
},
},
grid_table::GridCell {
text: ver,
style: theme::version(),
},
grid_table::GridCell {
text: src,
style: theme::source_tag(),
},
grid_table::GridCell {
text: desc,
style: theme::desc(),
},
],
}
})
.collect();
let columns = [
grid_table::Column {
header: "#",
width: Constraint::Length(5),
},
grid_table::Column {
header: "Name",
width: Constraint::Percentage(25),
},
grid_table::Column {
header: "Version",
width: Constraint::Length(14),
},
grid_table::Column {
header: "Source",
width: Constraint::Length(12),
},
grid_table::Column {
header: "Description",
width: Constraint::Min(10),
},
];
let result_count = app.current_results().len();
f.render_widget(
Block::default()
.borders(Borders::NONE)
.title(Span::styled(
format!(" ☰ {} packages ", result_count),
theme::title(),
))
.style(Style::default().bg(theme::bg_color())),
chunks[2],
);
f.render_widget(
grid_table::GridTable {
columns: &columns,
rows: &grid_rows,
selected: selected_rel,
header_style: theme::grid_header(),
separator_style: theme::grid_separator(),
selected_style: theme::highlight(),
},
chunks[2],
);
f.render_widget(
paginator::Paginator {
current_page: app.page,
total_pages: app.total_pages(),
tick: app.tick,
},
chunks[3],
);
let help = match app.mode {
Mode::Input => " ↩ search • Tab results • Esc quit",
Mode::Browse => {
" ↑↓ navigate • ←→ source/page • Tab filter • Enter install • i info • d remove • v installed • / search • q quit"
}
_ => " q quit",
};
let status_line = Line::from(vec![
Span::styled(" S8N ", theme::status_bar()),
Span::styled(" ", Style::default()),
Span::styled(help, theme::status_text()),
]);
f.render_widget(Paragraph::new(status_line), chunks[4]);
}
fn render_confirm_overlay(f: &mut ratatui::Frame, app: &App, area: Rect) {
let w = 55.min(area.width.saturating_sub(4));
let h = 8.min(area.height.saturating_sub(2));
let x = (area.width - w) / 2;
let y = (area.height - h) / 2;
let dialog = Rect::new(x, y, w, h);
f.render_widget(Clear, dialog);
let action = if app.confirm_action == "install" {
"Install"
} else {
"Remove"
};
let pkgs = app.confirm_packages.join(", ");
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::vivid_purple()))
.title(Span::styled(
format!(" {} Confirmation ", action),
Style::default()
.fg(theme::hot_pink())
.add_modifier(ratatui::style::Modifier::BOLD),
))
.style(Style::default().bg(theme::overlay_color()));
let yes_style = if app.confirm_selected_yes {
theme::btn_yes()
} else {
theme::btn_dim()
};
let no_style = if !app.confirm_selected_yes {
theme::btn_no()
} else {
theme::btn_dim()
};
let text = vec![
Line::from(""),
Line::from(Span::styled(
format!(
" Are you sure you want to {} {}?",
action.to_lowercase(),
pkgs
),
theme::pkg_name(),
)),
Line::from(""),
Line::from(""),
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(" Yes ", yes_style),
Span::styled(" ", Style::default()),
Span::styled(" Cancel ", no_style),
]),
];
f.render_widget(
Paragraph::new(text).block(block).wrap(Wrap { trim: true }),
dialog,
);
}
fn render_progress_view(f: &mut ratatui::Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(3), Constraint::Length(4), ])
.split(area);
let action_text = if app.confirm_action == "install" {
"Installing"
} else {
"Removing"
};
let frame = SPINNER_FRAMES[(app.tick as usize) % SPINNER_FRAMES.len()];
let spinner_str = format!(
"\x1b[38;2;{};{};{}m{}\x1b[0m",
match theme::vivid_purple() {
Color::Rgb(r, _, _) => r,
_ => 138,
},
match theme::vivid_purple() {
Color::Rgb(_, g, _) => g,
_ => 43,
},
match theme::vivid_purple() {
Color::Rgb(_, _, b) => b,
_ => 226,
},
frame
);
let title = Paragraph::new(Text::raw(format!(
" {} {} packages...",
spinner_str, action_text
)))
.block(Block::default().style(Style::default().bg(theme::bg_color())));
f.render_widget(title, chunks[0]);
let items: Vec<ListItem> = app
.progress_items
.iter()
.enumerate()
.map(|(i, item)| {
let vp = theme::vivid_purple();
let _vp_r = match vp {
Color::Rgb(r, _, _) => r,
_ => 138,
};
let _vp_g = match vp {
Color::Rgb(_, g, _) => g,
_ => 43,
};
let _vp_b = match vp {
Color::Rgb(_, _, b) => b,
_ => 226,
};
let (icon, icon_style) = if item.done {
if item.success {
let ng = theme::neon_green();
let style = Style::default().fg(ng);
(" ✓ ", style)
} else {
let br = theme::THEME.read().unwrap().bright_red;
let style = Style::default().fg(br);
(" ✗ ", style)
}
} else {
let spin = SPINNER_FRAMES[(app.tick as usize + i * 3) % SPINNER_FRAMES.len()];
let style = Style::default().fg(vp);
(spin, style)
};
let name_style = if item.done {
theme::dim()
} else {
Style::default()
.fg(theme::hot_pink())
.add_modifier(ratatui::style::Modifier::BOLD)
};
let line = Line::from(vec![
Span::styled(
if !item.done {
format!(" {} ", icon)
} else {
icon.to_string()
},
icon_style,
),
Span::styled(item.name.clone(), name_style),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(theme::border())
.style(Style::default().bg(theme::bg_color())),
);
f.render_widget(list, chunks[1]);
let done_count = app.progress_items.iter().filter(|i| i.done).count();
let total = app.progress_items.len().max(1);
let ratio = done_count as f64 / total as f64;
let bar_width = (chunks[2].width as usize).saturating_sub(4).min(80);
let filled = (bar_width as f64 * ratio).round() as usize;
let empty = bar_width.saturating_sub(filled);
let gradient_colors = theme::gradient_stops();
let color_count = gradient_colors.len();
let phase = (app.tick as usize) % (color_count * 4);
let mut spans: Vec<Span> = Vec::with_capacity(filled + empty + 4);
spans.push(Span::raw(" "));
for i in 0..filled {
let color_idx = (phase + i * color_count / filled.max(1)) % color_count;
let next_idx = (color_idx + 1) % color_count;
let blend = ((i * color_count) % filled.max(1)) as f64 / filled.max(1) as f64;
let c1 = gradient_colors[color_idx];
let c2 = gradient_colors[next_idx];
let color = match (c1, c2) {
(Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
let b = blend.min(1.0);
Color::Rgb(
(r1 as f64 * (1.0 - b) + r2 as f64 * b) as u8,
(g1 as f64 * (1.0 - b) + g2 as f64 * b) as u8,
(b1 as f64 * (1.0 - b) + b2 as f64 * b) as u8,
)
}
_ => c1,
};
spans.push(Span::styled("█", Style::default().fg(color)));
}
spans.push(Span::styled(
"░".repeat(empty),
Style::default().fg(theme::THEME.read().unwrap().surface),
));
spans.push(Span::raw(format!(" {}/{}", done_count, total)));
let progress_line = Line::from(spans);
f.render_widget(
Paragraph::new(progress_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(theme::border())
.title(Span::styled(" Progress ", theme::search_label()))
.style(Style::default().bg(theme::bg_color())),
),
chunks[2],
);
}
fn render_done_view(f: &mut ratatui::Frame, app: &App, area: Rect) {
render_progress_view(f, app, area);
let msg_w = 50.min(area.width.saturating_sub(4));
let x = (area.width - msg_w) / 2;
let y = area.height / 2;
let msg_area = Rect::new(x, y, msg_w, 5);
f.render_widget(Clear, msg_area);
let success_count = app.progress_items.iter().filter(|i| i.success).count();
let fail_count = app
.progress_items
.iter()
.filter(|i| i.done && !i.success)
.count();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::neon_green()))
.title(Span::styled(" Complete ", theme::success()))
.style(Style::default().bg(theme::overlay_color()));
let text = vec![
Line::from(""),
Line::from(vec![
Span::styled(format!(" ✓ {} succeeded", success_count), theme::success()),
Span::styled(" ", Style::default()),
if fail_count > 0 {
Span::styled(format!("✗ {} failed", fail_count), theme::error())
} else {
Span::styled("", Style::default())
},
]),
Line::from(Span::styled(" Press q to exit", theme::dim())),
];
f.render_widget(Paragraph::new(text).block(block), msg_area);
}
fn render_installed_view(f: &mut ratatui::Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
f.render_widget(
Block::default()
.borders(Borders::NONE)
.title(Span::styled(
format!(" 📦 {} installed packages ", app.installed_results.len()),
theme::title(),
))
.style(Style::default().bg(theme::bg_color())),
chunks[0],
);
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(if app.mode == Mode::InstalledView {
Style::default().fg(theme::hot_pink())
} else {
theme::border()
})
.title(Span::styled(" 🔍 Filter installed ", theme::search_label()))
.style(Style::default().bg(theme::bg_color()));
let input_text = if app.search_input.is_empty() {
Paragraph::new(Span::styled(
"Type to filter installed packages...",
theme::dim(),
))
} else {
let mut display = app.search_input.clone();
let cursor_char = if app.tick % 6 < 3 { '│' } else { '▏' };
display.insert(app.cursor_pos, cursor_char);
Paragraph::new(Span::styled(display, theme::input()))
};
f.render_widget(input_text.block(input_block), chunks[1]);
let filter = app.search_input.to_lowercase();
let filtered: Vec<&PackageInfo> = app
.installed_results
.iter()
.filter(|p| {
filter.is_empty()
|| p.name.to_lowercase().contains(&filter)
|| p.source.to_lowercase().contains(&filter)
|| p.version.to_lowercase().contains(&filter)
})
.collect();
app.page_size = (chunks[2].height as usize).saturating_sub(4).max(1);
let total = filtered.len();
let pages = if total == 0 {
1
} else {
total.div_ceil(app.page_size)
};
let page = app.page.min(pages.saturating_sub(1));
let start = page * app.page_size;
let page_items: Vec<&PackageInfo> = filtered
.iter()
.skip(start)
.take(app.page_size)
.copied()
.collect();
let desc_col_max = (chunks[2].width as usize)
.saturating_sub(5 + 26 + 14 + 12 + 6)
.max(10);
let grid_rows: Vec<grid_table::GridRow> = page_items
.iter()
.enumerate()
.map(|(rel_idx, pkg)| {
let global_idx = start + rel_idx;
let installed_badge = "✓ ";
let name_raw = format!("{}{}", installed_badge, pkg.name);
let ver = if pkg.version.is_empty() {
"—".to_string()
} else {
pkg.version.chars().take(12).collect()
};
let src: String = pkg.source.chars().take(10).collect();
let desc: String = pkg.description.chars().take(desc_col_max).collect();
let is_selected = app.list_state.selected() == Some(rel_idx);
grid_table::GridRow {
cells: vec![
grid_table::GridCell {
text: format!("{}", global_idx + 1),
style: theme::number(),
},
grid_table::GridCell {
text: name_raw,
style: if is_selected {
theme::highlight()
} else {
theme::installed_pkg()
},
},
grid_table::GridCell {
text: ver,
style: theme::version(),
},
grid_table::GridCell {
text: src,
style: theme::source_tag(),
},
grid_table::GridCell {
text: desc,
style: theme::desc(),
},
],
}
})
.collect();
let columns = [
grid_table::Column {
header: "#",
width: Constraint::Length(5),
},
grid_table::Column {
header: "Name",
width: Constraint::Percentage(25),
},
grid_table::Column {
header: "Version",
width: Constraint::Length(14),
},
grid_table::Column {
header: "Source",
width: Constraint::Length(12),
},
grid_table::Column {
header: "Description",
width: Constraint::Min(10),
},
];
f.render_widget(
grid_table::GridTable {
columns: &columns,
rows: &grid_rows,
selected: app.list_state.selected(),
header_style: theme::grid_header(),
separator_style: theme::grid_separator(),
selected_style: theme::highlight(),
},
chunks[2],
);
f.render_widget(
paginator::Paginator {
current_page: page,
total_pages: pages,
tick: app.tick,
},
chunks[3],
);
let status_line = Line::from(vec![
Span::styled(" INSTALLED ", theme::status_bar()),
Span::styled(" ", Style::default()),
Span::styled(
" ↑↓ navigate • Enter/i info • d remove • / filter • q back",
theme::status_text(),
),
]);
f.render_widget(Paragraph::new(status_line), chunks[4]);
}
fn handle_key(app: &mut App, key: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
match app.mode {
Mode::Input => handle_input_key(app, key, modifiers),
Mode::Browse => handle_browse_key(app, key, modifiers),
Mode::Confirm => handle_confirm_key(app, key),
Mode::Done => {
if matches!(key, KeyCode::Char('q') | KeyCode::Esc) {
app.should_quit = true;
}
None
}
Mode::Progress => None,
Mode::InstalledView => handle_installed_key(app, key, modifiers),
Mode::PackageDetail => handle_detail_key(app, key),
Mode::GraveyardView => handle_graveyard_key(app, key),
Mode::BurialProgress => None,
}
}
enum Action {
Search(String),
Install(Vec<String>, String), Remove(Vec<String>, String), FuzzySearch,
FuzzySearchInstalled,
LoadInstalled,
}
fn handle_input_key(app: &mut App, key: KeyCode, _modifiers: KeyModifiers) -> Option<Action> {
match key {
KeyCode::Char('v') => {
if !app.installed_loaded {
return Some(Action::LoadInstalled);
} else {
app.mode = Mode::InstalledView;
app.page = 0;
app.list_state.select(if app.installed_results.is_empty() {
None
} else {
Some(0)
});
app.search_input.clear();
app.cursor_pos = 0;
}
}
KeyCode::Char(c) => {
app.search_input.insert(app.cursor_pos, c);
app.cursor_pos += 1;
}
KeyCode::Backspace => {
if app.cursor_pos > 0 {
app.cursor_pos -= 1;
app.search_input.remove(app.cursor_pos);
}
}
KeyCode::Delete => {
if app.cursor_pos < app.search_input.len() {
app.search_input.remove(app.cursor_pos);
}
}
KeyCode::Left => {
app.cursor_pos = app.cursor_pos.saturating_sub(1);
}
KeyCode::Right => {
app.cursor_pos = (app.cursor_pos + 1).min(app.search_input.len());
}
KeyCode::Home => app.cursor_pos = 0,
KeyCode::End => app.cursor_pos = app.search_input.len(),
KeyCode::Enter => {
if !app.search_input.is_empty() {
let query = app.search_input.clone();
app.status_message = format!("Searching for '{}'...", query);
return Some(Action::Search(query));
}
}
KeyCode::Tab => {
if !app.all_results.is_empty() {
app.mode = Mode::Browse;
if app.list_state.selected().is_none() {
app.list_state.select(Some(0));
app.update_source_options();
}
}
}
KeyCode::Esc => app.should_quit = true,
_ => {}
}
None
}
fn handle_browse_key(app: &mut App, key: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
if (modifiers.contains(KeyModifiers::CONTROL) && key == KeyCode::Char('i'))
|| (!modifiers.contains(KeyModifiers::CONTROL) && key == KeyCode::Char('i'))
{
if let Some(pkg) = app.selected_package() {
app.detail_package = Some(pkg.clone());
app.mode = Mode::PackageDetail;
}
return None;
}
if modifiers.contains(KeyModifiers::CONTROL) && key == KeyCode::Char('f') {
return Some(Action::FuzzySearch);
}
let results_len = app.current_results().len();
let page_start = app.page * app.page_size;
let page_end = (page_start + app.page_size).min(results_len);
let page_items = page_end - page_start;
match key {
KeyCode::Up => {
if let Some(sel) = app.list_state.selected() {
if sel > 0 {
app.list_state.select(Some(sel - 1));
} else if app.page > 0 {
app.page -= 1;
let new_page_items = app.page_size.min(results_len - app.page * app.page_size);
app.list_state
.select(Some(new_page_items.saturating_sub(1)));
}
}
app.update_source_options();
}
KeyCode::Down => {
if let Some(sel) = app.list_state.selected() {
if sel + 1 < page_items {
app.list_state.select(Some(sel + 1));
} else if app.page + 1 < app.total_pages() {
app.page += 1;
app.list_state.select(Some(0));
}
}
app.update_source_options();
}
KeyCode::Left => {
if app.source_options.len() > 1 {
app.source_selected = app.source_selected.saturating_sub(1);
} else if app.page > 0 {
app.page -= 1;
app.list_state.select(Some(0));
app.update_source_options();
}
}
KeyCode::Right => {
if app.source_options.len() > 1 {
app.source_selected = (app.source_selected + 1).min(app.source_options.len() - 1);
} else if app.page + 1 < app.total_pages() {
app.page += 1;
app.list_state.select(Some(0));
app.update_source_options();
}
}
KeyCode::Tab => {
app.active_tab = (app.active_tab + 1) % app.tab_titles.len();
app.page = 0;
app.list_state.select(if app.current_results().is_empty() {
None
} else {
Some(0)
});
app.update_source_options();
}
KeyCode::BackTab => {
app.active_tab = if app.active_tab == 0 {
app.tab_titles.len() - 1
} else {
app.active_tab - 1
};
app.page = 0;
app.list_state.select(if app.current_results().is_empty() {
None
} else {
Some(0)
});
app.update_source_options();
}
KeyCode::Enter => {
if let Some(pkg) = app.selected_package() {
if pkg.installed {
app.status_message = format!("{} is already installed", pkg.name);
return None;
}
let source = if app.source_options.len() > 1 {
app.source_options
.get(app.source_selected)
.cloned()
.unwrap_or_default()
} else {
pkg.source.clone()
};
let pkg_name = pkg.name.clone();
app.confirm_action = "install".to_string();
app.confirm_packages = vec![pkg_name];
app.confirm_source = source;
app.confirm_selected_yes = true;
app.mode = Mode::Confirm;
return None;
}
}
KeyCode::Char('d') | KeyCode::Char('r') => {
if let Some(pkg) = app.selected_package() {
let source = if app.source_options.len() > 1 {
app.source_options
.get(app.source_selected)
.cloned()
.unwrap_or_default()
} else {
pkg.source.clone()
};
let pkg_name = pkg.name.clone();
app.confirm_action = "remove".to_string();
app.confirm_packages = vec![pkg_name];
app.confirm_source = source;
app.confirm_selected_yes = true;
app.mode = Mode::Confirm;
}
}
KeyCode::Char('/') => {
app.mode = Mode::Input;
}
KeyCode::Char('v') => {
if !app.installed_loaded {
return Some(Action::LoadInstalled);
} else {
app.mode = Mode::InstalledView;
app.page = 0;
app.list_state.select(if app.installed_results.is_empty() {
None
} else {
Some(0)
});
app.search_input.clear();
app.cursor_pos = 0;
}
}
KeyCode::Char('q') | KeyCode::Esc => {
app.should_quit = true;
}
_ => {}
}
None
}
fn handle_confirm_key(app: &mut App, key: KeyCode) -> Option<Action> {
let confirm_yes = || {
let pkgs = app.confirm_packages.clone();
let action = app.confirm_action.clone();
let source = app.confirm_source.clone();
let new_app_mode = Mode::Progress;
let progress_items = pkgs
.iter()
.map(|p| ProgressItem {
name: p.clone(),
done: false,
success: false,
})
.collect();
if action == "install" {
Some((new_app_mode, progress_items, Action::Install(pkgs, source)))
} else {
Some((new_app_mode, progress_items, Action::Remove(pkgs, source)))
}
};
match key {
KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
app.confirm_selected_yes = !app.confirm_selected_yes;
None
}
KeyCode::Enter => {
if app.confirm_selected_yes {
if let Some((m, items, act)) = confirm_yes() {
app.mode = m;
app.progress_items = items;
return Some(act);
}
None
} else {
app.mode = Mode::Browse;
None
}
}
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some((m, items, act)) = confirm_yes() {
app.mode = m;
app.progress_items = items;
return Some(act);
}
None
}
KeyCode::Char('n')
| KeyCode::Char('N')
| KeyCode::Char('c')
| KeyCode::Char('C')
| KeyCode::Char('b')
| KeyCode::Char('B')
| KeyCode::Esc => {
app.mode = Mode::Browse;
None
}
_ => None,
}
}
fn handle_installed_key(app: &mut App, key: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
if (modifiers.contains(KeyModifiers::CONTROL) && key == KeyCode::Char('i'))
|| (!modifiers.contains(KeyModifiers::CONTROL) && key == KeyCode::Char('i'))
{
let filter = app.search_input.to_lowercase();
let filtered: Vec<&PackageInfo> = app
.installed_results
.iter()
.filter(|p| {
filter.is_empty()
|| p.name.to_lowercase().contains(&filter)
|| p.source.to_lowercase().contains(&filter)
})
.collect();
if let Some(sel) = app.list_state.selected() {
let global_idx = app.page * app.page_size + sel;
if let Some(pkg) = filtered.get(global_idx) {
app.detail_package = Some((*pkg).clone());
app.mode = Mode::PackageDetail;
}
}
return None;
}
if modifiers.contains(KeyModifiers::CONTROL) && key == KeyCode::Char('f') {
return Some(Action::FuzzySearchInstalled);
}
let _total = app.installed_results.len();
let filter = app.search_input.to_lowercase();
let filtered_count = app
.installed_results
.iter()
.filter(|p| {
filter.is_empty()
|| p.name.to_lowercase().contains(&filter)
|| p.source.to_lowercase().contains(&filter)
})
.count();
let pages = if filtered_count == 0 {
1
} else {
filtered_count.div_ceil(app.page_size)
};
match key {
KeyCode::Up => {
if let Some(sel) = app.list_state.selected() {
if sel > 0 {
app.list_state.select(Some(sel - 1));
} else if app.page > 0 {
app.page -= 1;
let new_page_items =
app.page_size.min(filtered_count - app.page * app.page_size);
app.list_state
.select(Some(new_page_items.saturating_sub(1)));
}
}
}
KeyCode::Down => {
if let Some(sel) = app.list_state.selected() {
if sel + 1 < app.page_size
&& sel + 1 < filtered_count.saturating_sub(app.page * app.page_size)
{
app.list_state.select(Some(sel + 1));
} else if app.page + 1 < pages {
app.page += 1;
app.list_state.select(Some(0));
}
}
}
KeyCode::Char('i') | KeyCode::Enter => {
let filter = app.search_input.to_lowercase();
let filtered: Vec<&PackageInfo> = app
.installed_results
.iter()
.filter(|p| {
filter.is_empty()
|| p.name.to_lowercase().contains(&filter)
|| p.source.to_lowercase().contains(&filter)
})
.collect();
if let Some(sel) = app.list_state.selected() {
let global_idx = app.page * app.page_size + sel;
if let Some(pkg) = filtered.get(global_idx) {
app.detail_package = Some((*pkg).clone());
app.mode = Mode::PackageDetail;
}
}
}
KeyCode::Char('d') | KeyCode::Char('r') => {
let filter = app.search_input.to_lowercase();
let filtered: Vec<&PackageInfo> = app
.installed_results
.iter()
.filter(|p| {
filter.is_empty()
|| p.name.to_lowercase().contains(&filter)
|| p.source.to_lowercase().contains(&filter)
})
.collect();
if let Some(sel) = app.list_state.selected() {
let global_idx = app.page * app.page_size + sel;
if let Some(pkg) = filtered.get(global_idx) {
app.confirm_action = "remove".to_string();
app.confirm_packages = vec![pkg.name.clone()];
app.confirm_source = pkg.source.clone();
app.confirm_selected_yes = true;
app.mode = Mode::Confirm;
}
}
}
KeyCode::Char('/') => {
app.mode = Mode::Input;
}
KeyCode::Char('q') | KeyCode::Esc => {
app.mode = Mode::Browse;
}
_ => {}
}
None
}
fn handle_detail_key(app: &mut App, key: KeyCode) -> Option<Action> {
match key {
KeyCode::Char('i') => {
if let Some(pkg) = app.detail_package.clone() {
if pkg.installed {
app.status_message = format!("{} is already installed", pkg.name);
return None;
}
app.confirm_action = "install".to_string();
app.confirm_packages = vec![pkg.name.clone()];
app.confirm_source = pkg.source.clone();
app.confirm_selected_yes = true;
app.mode = Mode::Confirm;
}
}
KeyCode::Char('d') | KeyCode::Char('r') => {
if let Some(pkg) = app.detail_package.clone() {
app.confirm_action = "remove".to_string();
app.confirm_packages = vec![pkg.name.clone()];
app.confirm_source = pkg.source.clone();
app.confirm_selected_yes = true;
app.mode = Mode::Confirm;
}
}
KeyCode::Esc | KeyCode::Char('q') => {
app.detail_package = None;
if app.installed_loaded
&& !app.installed_results.is_empty()
&& app.all_results.is_empty()
{
app.mode = Mode::InstalledView;
} else {
app.mode = Mode::Browse;
}
}
_ => {}
}
None
}
fn handle_graveyard_key(app: &mut App, key: KeyCode) -> Option<Action> {
match key {
KeyCode::Up => {
if app.graveyard_selected > 0 {
app.graveyard_selected -= 1;
}
}
KeyCode::Down => {
if app.graveyard_selected + 1 < app.graveyard_entries.len() {
app.graveyard_selected += 1;
}
}
KeyCode::Char('q') | KeyCode::Esc => {
app.should_quit = true;
}
_ => {}
}
None
}
fn render_package_detail(f: &mut ratatui::Frame, app: &App, area: Rect) {
f.render_widget(
Block::default().style(Style::default().bg(theme::bg_color())),
area,
);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(5), Constraint::Length(1), ])
.split(area);
let title_text = if let Some(pkg) = &app.detail_package {
format!(" 📦 {} ", pkg.name)
} else {
" Package Detail ".to_string()
};
f.render_widget(
Paragraph::new(Span::styled(title_text, theme::title())),
chunks[0],
);
let Some(pkg) = &app.detail_package else {
return;
};
let wrap_width = chunks[1].width.saturating_sub(6) as usize;
let desc_lines: Vec<Line> = if pkg.description.is_empty() {
vec![Line::from(Span::styled(
" (No description available)",
theme::dim(),
))]
} else {
let mut lines: Vec<Line> = Vec::new();
let mut current = String::new();
for word in pkg.description.split_whitespace() {
if current.len() + word.len() + 1 > wrap_width.saturating_sub(2) && !current.is_empty()
{
lines.push(Line::from(Span::styled(
format!(" {}", current.trim()),
theme::desc(),
)));
current.clear();
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", current.trim()),
theme::desc(),
)));
}
lines
};
let installed_label = if pkg.installed {
Span::styled(" ✓ Installed", Style::default().fg(theme::neon_green()))
} else {
Span::styled(" Not installed", theme::dim())
};
let mut text_lines: Vec<Line> = vec![
Line::from(vec![
Span::styled(" Name: ", theme::grid_header()),
Span::styled(pkg.name.clone(), theme::pkg_name()),
]),
Line::from(vec![
Span::styled(" Version: ", theme::grid_header()),
Span::styled(
if pkg.version.is_empty() {
"—".to_string()
} else {
pkg.version.clone()
},
theme::version(),
),
]),
Line::from(vec![
Span::styled(" Source: ", theme::grid_header()),
Span::styled(pkg.source.clone(), theme::source_tag()),
]),
Line::from(vec![
Span::styled(" Status: ", theme::grid_header()),
installed_label,
]),
Line::from(""),
Line::from(Span::styled(" Description:", theme::grid_header())),
];
text_lines.extend(desc_lines);
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
if !pkg.installed {
Span::styled(" i Install ", theme::btn_yes())
} else {
Span::styled(" i Reinstall ", theme::btn_dim())
},
Span::raw(" "),
Span::styled(" d Remove ", theme::btn_no()),
Span::raw(" "),
Span::styled(" Esc Back ", theme::btn_dim()),
]));
f.render_widget(
Paragraph::new(text_lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(theme::border())
.style(Style::default().bg(theme::bg_color())),
),
chunks[1],
);
f.render_widget(
Paragraph::new(Span::styled(
" Enter/i install • d remove • Esc back",
theme::status_text(),
)),
chunks[2],
);
}
fn render_graveyard_view(f: &mut ratatui::Frame, app: &App, area: Rect) {
f.render_widget(
Block::default().style(Style::default().bg(theme::bg_color())),
area,
);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(1),
])
.split(area);
f.render_widget(
Paragraph::new(Span::styled(
format!(
" 🪦 S8n Graveyard — {} buried packages ",
app.graveyard_entries.len()
),
theme::title(),
)),
chunks[0],
);
if app.graveyard_entries.is_empty() {
f.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(" The graveyard is empty.", theme::dim())),
Line::from(Span::styled(
" Use \"s8n brn <package>\" to bury packages here.",
theme::dim(),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_style(theme::border())
.style(Style::default().bg(theme::bg_color())),
),
chunks[1],
);
} else {
let path_max = (chunks[1].width as usize)
.saturating_sub(4 + 20 + 10 + 10 + 6)
.max(8);
let rows: Vec<grid_table::GridRow> = app
.graveyard_entries
.iter()
.enumerate()
.map(|(i, e)| grid_table::GridRow {
cells: vec![
grid_table::GridCell {
text: format!("{}", i + 1),
style: theme::number(),
},
grid_table::GridCell {
text: e.name.chars().take(18).collect(),
style: theme::pkg_name(),
},
grid_table::GridCell {
text: e.modified_str(),
style: theme::version(),
},
grid_table::GridCell {
text: e.size_str(),
style: theme::source_tag(),
},
grid_table::GridCell {
text: e
.original_path
.to_string_lossy()
.chars()
.take(path_max)
.collect(),
style: theme::desc(),
},
],
})
.collect();
let columns = [
grid_table::Column {
header: "#",
width: Constraint::Length(4),
},
grid_table::Column {
header: "Package",
width: Constraint::Length(20),
},
grid_table::Column {
header: "Buried",
width: Constraint::Length(10),
},
grid_table::Column {
header: "Size",
width: Constraint::Length(8),
},
grid_table::Column {
header: "Original Path",
width: Constraint::Min(8),
},
];
f.render_widget(
grid_table::GridTable {
columns: &columns,
rows: &rows,
selected: Some(app.graveyard_selected),
header_style: theme::grid_header(),
separator_style: theme::grid_separator(),
selected_style: theme::highlight(),
},
chunks[1],
);
}
f.render_widget(
Paragraph::new(Span::styled(
" ↑↓ navigate • Enter recover • q / Esc quit",
theme::status_text(),
)),
chunks[2],
);
}
fn render_burial_progress(f: &mut ratatui::Frame, app: &App, area: Rect) {
f.render_widget(
Block::default().style(Style::default().bg(theme::bg_color())),
area,
);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(4),
])
.split(area);
let action_emoji = if app.burial_is_exhume { "🌱" } else { "🪦" };
let action_text = if app.burial_is_exhume {
"Recovering"
} else {
"Burying"
};
let frame = SPINNER_FRAMES[(app.tick as usize) % SPINNER_FRAMES.len()];
f.render_widget(
Paragraph::new(Text::raw(format!(
" {} {} {} packages...",
action_emoji, frame, action_text
)))
.block(Block::default().style(Style::default().bg(theme::bg_color()))),
chunks[0],
);
let items: Vec<ListItem> = app
.burial_files
.iter()
.enumerate()
.map(|(i, name)| {
let done = app
.burial_results
.get(i)
.map(|r| r.is_some())
.unwrap_or(false);
let result_text = app
.burial_results
.get(i)
.and_then(|r| r.as_deref())
.unwrap_or("");
let (icon, icon_style) = if done {
if app.burial_is_exhume {
(" 🌱 ", Style::default().fg(theme::neon_green()))
} else {
(" 🪦 ", Style::default().fg(theme::vivid_purple()))
}
} else {
let spin = SPINNER_FRAMES[(app.tick as usize + i * 3) % SPINNER_FRAMES.len()];
(spin, Style::default().fg(theme::hot_pink()))
};
let line = if done && !result_text.is_empty() {
Line::from(vec![
Span::styled(icon.to_string(), icon_style),
Span::styled(name.clone(), theme::dim()),
Span::styled(
format!(
" {} {}",
if app.burial_is_exhume {
"recovered from"
} else {
"buried in"
},
result_text
),
Style::default().fg(theme::neon_green()),
),
])
} else if done {
Line::from(vec![
Span::styled(icon.to_string(), icon_style),
Span::styled(name.clone(), theme::dim()),
])
} else {
Line::from(vec![
Span::styled(format!(" {} ", icon), icon_style),
Span::styled(
name.clone(),
Style::default()
.fg(theme::hot_pink())
.add_modifier(ratatui::style::Modifier::BOLD),
),
])
};
ListItem::new(line)
})
.collect();
f.render_widget(
List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(theme::border())
.style(Style::default().bg(theme::bg_color())),
),
chunks[1],
);
let done_count = app.burial_results.iter().filter(|r| r.is_some()).count();
let total = app.burial_files.len().max(1);
let ratio = done_count as f64 / total as f64;
let bar_width = (chunks[2].width as usize).saturating_sub(6).min(80);
let filled = if app.burial_is_exhume {
(bar_width as f64 * ratio).round() as usize
} else {
(bar_width as f64 * (1.0 - ratio)).round() as usize
};
let empty = bar_width.saturating_sub(filled);
let gradient_colors = theme::gradient_stops();
let color_count = gradient_colors.len();
let phase = (app.tick as usize) % (color_count * 4);
let mut spans: Vec<Span> = vec![Span::raw(" ")];
for i in 0..filled {
let color_idx = (phase + i) % color_count;
spans.push(Span::styled(
"█",
Style::default().fg(gradient_colors[color_idx]),
));
}
spans.push(Span::styled(
"░".repeat(empty),
Style::default().fg(theme::THEME.read().unwrap().surface),
));
spans.push(Span::raw(format!(" {}/{}", done_count, total)));
f.render_widget(
Paragraph::new(Line::from(spans)).block(
Block::default()
.borders(Borders::ALL)
.border_style(theme::border())
.title(Span::styled(
if app.burial_is_exhume {
" Recovery Progress "
} else {
" Burial Progress "
},
theme::search_label(),
))
.style(Style::default().bg(theme::bg_color())),
),
chunks[2],
);
}
pub async fn run_search_tui(
managers: &[Box<dyn PackageManager>],
initial_query: Option<&str>,
) -> io::Result<()> {
theme::reload();
terminal::enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
run_search_tui_inner(&mut terminal, managers, initial_query).await?;
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
Ok(())
}
pub async fn run_search_tui_inner(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
managers: &[Box<dyn PackageManager>],
initial_query: Option<&str>,
) -> io::Result<()> {
let mut app = App::new();
if let Some(q) = initial_query {
app.search_input = q.to_string();
app.cursor_pos = q.len();
app.status_message = format!("Searching for '{}'...", q);
let results = search_all(managers, q).await;
app.set_results(results);
app.mode = Mode::Browse;
if app.list_state.selected().is_none() && !app.all_results.is_empty() {
app.list_state.select(Some(0));
app.update_source_options();
}
app.status_message.clear();
}
loop {
app.tick += 1;
render(terminal, &mut app)?;
if app.should_quit {
break;
}
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key_event) = event::read()? {
if let Some(action) = handle_key(&mut app, key_event.code, key_event.modifiers) {
match action {
Action::Search(query) => {
app.status_message = format!("Searching for '{}'...", query);
render(terminal, &mut app)?;
let results = search_all(managers, &query).await;
app.set_results(results);
app.mode = Mode::Browse;
if app.list_state.selected().is_none() && !app.all_results.is_empty() {
app.list_state.select(Some(0));
app.update_source_options();
}
app.status_message.clear();
}
Action::Install(pkgs, source) => {
render(terminal, &mut app)?;
let pm = managers
.iter()
.find(|m| m.name() == source)
.or_else(|| managers.first());
if let Some(pm) = pm {
for (i, pkg) in pkgs.iter().enumerate() {
let result = pm.install(std::slice::from_ref(pkg)).await;
if let Some(item) = app.progress_items.get_mut(i) {
item.done = true;
item.success = matches!(result, PmResult::Success);
}
render(terminal, &mut app)?;
}
}
app.mode = Mode::Done;
}
Action::Remove(pkgs, source) => {
render(terminal, &mut app)?;
let pm = managers
.iter()
.find(|m| m.name() == source)
.or_else(|| managers.first());
if let Some(pm) = pm {
for (i, pkg) in pkgs.iter().enumerate() {
let result = pm.remove(std::slice::from_ref(pkg)).await;
if let Some(item) = app.progress_items.get_mut(i) {
item.done = true;
item.success = matches!(result, PmResult::Success);
}
render(terminal, &mut app)?;
}
}
app.mode = Mode::Done;
}
Action::FuzzySearch => {
if app.all_results.is_empty() {
continue;
}
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
use std::io::Write;
use std::process::{Command, Stdio};
let child_res = Command::new("sk")
.arg("--ansi")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn();
match child_res {
Ok(mut child) => {
let mut items_text = String::new();
for pkg in &app.all_results {
items_text
.push_str(&format!("{} [{}]\n", pkg.name, pkg.source));
}
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(items_text.as_bytes());
}
let output = child.wait_with_output().unwrap_or_else(|_| {
std::process::Output {
status: std::os::unix::process::ExitStatusExt::from_raw(
1,
),
stdout: Vec::new(),
stderr: Vec::new(),
}
});
terminal::enable_raw_mode()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
cursor::Hide
)?;
terminal.clear()?;
if output.status.success() {
let selected = String::from_utf8_lossy(&output.stdout);
let selected_line = selected.trim();
if !selected_line.is_empty() {
if let Some(idx) = selected_line.rfind(" [") {
let name = &selected_line[..idx];
let filtered: Vec<PackageInfo> = app
.all_results
.clone()
.into_iter()
.filter(|p| p.name == name)
.collect();
if !filtered.is_empty() {
let mut map = std::collections::HashMap::new();
for p in filtered {
map.entry(p.source.clone())
.or_insert_with(Vec::new)
.push(p);
}
let mut new_results: Vec<(
String,
Vec<PackageInfo>,
)> = map.into_iter().collect();
new_results.sort_by(|a, b| a.0.cmp(&b.0));
app.set_results(new_results);
}
}
}
}
}
Err(_) => {
terminal::enable_raw_mode()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
cursor::Hide
)?;
terminal.clear()?;
app.status_message =
"Error: Skim (sk) fuzzy finder is not installed."
.to_string();
}
}
}
Action::FuzzySearchInstalled => {
if app.installed_results.is_empty() {
continue;
}
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
use std::io::Write;
use std::process::{Command, Stdio};
let child_res = Command::new("sk")
.arg("--ansi")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn();
match child_res {
Ok(mut child) => {
let mut items_text = String::new();
for pkg in &app.installed_results {
items_text.push_str(&format!(
"{} [{}] {}\n",
pkg.name, pkg.source, pkg.version
));
}
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(items_text.as_bytes());
}
let output = child.wait_with_output().unwrap_or_else(|_| {
std::process::Output {
status: std::os::unix::process::ExitStatusExt::from_raw(
1,
),
stdout: Vec::new(),
stderr: Vec::new(),
}
});
terminal::enable_raw_mode()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
cursor::Hide
)?;
terminal.clear()?;
if output.status.success() {
let selected = String::from_utf8_lossy(&output.stdout);
let selected_line = selected.trim();
if !selected_line.is_empty() {
if let Some(idx) = selected_line.rfind(" [") {
let name = &selected_line[..idx];
let filtered: Vec<&PackageInfo> = app
.installed_results
.iter()
.filter(|p| p.name == name)
.collect();
if !filtered.is_empty() {
app.search_input = name.to_string();
app.cursor_pos = name.len();
}
}
}
}
}
Err(_) => {
terminal::enable_raw_mode()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
cursor::Hide
)?;
terminal.clear()?;
app.status_message =
"Error: Skim (sk) fuzzy finder is not installed."
.to_string();
}
}
}
Action::LoadInstalled => {
app.status_message = "Loading installed packages...".to_string();
render(terminal, &mut app)?;
let mut all_installed: Vec<PackageInfo> = Vec::new();
for pm in managers {
if !pm.is_available() {
continue;
}
if let Ok(pkgs) = pm.list_installed().await {
all_installed.extend(pkgs);
}
}
all_installed.sort_by_key(|a| a.name.to_lowercase());
app.installed_results = all_installed;
app.installed_loaded = true;
app.mark_installed();
app.mode = Mode::InstalledView;
app.page = 0;
app.list_state.select(if app.installed_results.is_empty() {
None
} else {
Some(0)
});
app.search_input.clear();
app.cursor_pos = 0;
app.status_message.clear();
}
}
}
}
}
}
Ok(())
}
pub async fn run_main_tui(
managers: Vec<Box<dyn PackageManager>>,
requested_manager: Option<&str>,
) -> io::Result<()> {
theme::reload();
terminal::enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut current_mode = AppMode::Menu;
let mut menu_state = menu::MenuState::new();
let mut fm_state = file_manager::FileManagerState::new();
let mut cp_state = color_picker::ColorPickerState::new();
let search_managers: Vec<Box<dyn PackageManager>> = if let Some(requested) = requested_manager {
managers
.into_iter()
.filter(|m| m.name() == requested)
.collect()
} else {
managers
};
loop {
match current_mode {
AppMode::Menu => {
terminal.draw(|f| {
let size = f.area();
f.render_widget(
ratatui::widgets::Block::default()
.style(ratatui::style::Style::default().bg(theme::bg_color())),
size,
);
menu::render_menu(f, &mut menu_state, size);
})?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if let Some(action) = menu_state.handle_key(key.code) {
match action {
menu::MenuAction::PackMan => current_mode = AppMode::PackManSearch,
menu::MenuAction::FileManager => {
current_mode = AppMode::FileManager
}
menu::MenuAction::ColorTheme => current_mode = AppMode::ColorTheme,
menu::MenuAction::Quit => break,
}
}
}
}
}
AppMode::FileManager => {
terminal.draw(|f| {
let size = f.area();
f.render_widget(
ratatui::widgets::Block::default()
.style(ratatui::style::Style::default().bg(theme::bg_color())),
size,
);
file_manager::render_file_manager(f, &mut fm_state, size);
})?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if fm_state.handle_key(key.code) {
current_mode = AppMode::Menu;
}
}
}
}
AppMode::ColorTheme => {
terminal.draw(|f| {
let size = f.area();
f.render_widget(
ratatui::widgets::Block::default()
.style(ratatui::style::Style::default().bg(theme::bg_color())),
size,
);
color_picker::render_color_picker(f, &mut cp_state, size);
})?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if cp_state.handle_key(key.code) {
current_mode = AppMode::Menu;
}
}
}
}
AppMode::PackManSearch => {
run_search_tui_inner(&mut terminal, &search_managers, None).await?;
current_mode = AppMode::Menu;
}
_ => {
current_mode = AppMode::Menu;
}
}
}
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
Ok(())
}
pub async fn run_progress_tui(
pm: &dyn PackageManager,
packages: Vec<String>,
action: &str, ) -> io::Result<()> {
terminal::enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let items: Vec<String> = if action == "update" && packages.is_empty() {
vec!["System Packages".to_string()]
} else {
packages.clone()
};
let mut app = App::new();
app.confirm_action = action.to_string();
app.mode = Mode::Progress;
app.progress_items = items
.iter()
.map(|p| ProgressItem {
name: p.clone(),
done: false,
success: false,
})
.collect();
render(&mut terminal, &mut app)?;
for (i, pkg) in items.iter().enumerate() {
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
let result = match action {
"install" => pm.install(std::slice::from_ref(pkg)).await,
"remove" => pm.remove(std::slice::from_ref(pkg)).await,
"update" => pm.update().await,
_ => PmResult::Error("Unknown action".into()),
};
terminal::enable_raw_mode()?;
execute!(terminal.backend_mut(), EnterAlternateScreen, cursor::Hide)?;
terminal.clear()?;
if let Some(item) = app.progress_items.get_mut(i) {
item.done = true;
item.success = matches!(result, PmResult::Success);
}
render(&mut terminal, &mut app)?;
}
app.mode = Mode::Done;
render(&mut terminal, &mut app)?;
loop {
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key_event) = event::read()? {
if matches!(
key_event.code,
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter
) {
break;
}
}
}
}
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
Ok(())
}
pub async fn run_installed_view_tui(managers: Vec<Box<dyn PackageManager>>) -> io::Result<()> {
theme::reload();
terminal::enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.status_message = "Loading installed packages...".to_string();
render(&mut terminal, &mut app)?;
let mut all_installed: Vec<PackageInfo> = Vec::new();
for pm in &managers {
if !pm.is_available() {
continue;
}
if let Ok(pkgs) = pm.list_installed().await {
all_installed.extend(pkgs);
}
}
all_installed.sort_by_key(|a| a.name.to_lowercase());
app.installed_results = all_installed;
app.installed_loaded = true;
app.mode = Mode::InstalledView;
app.page = 0;
app.list_state.select(if app.installed_results.is_empty() {
None
} else {
Some(0)
});
app.status_message.clear();
loop {
app.tick += 1;
render(&mut terminal, &mut app)?;
if app.should_quit {
break;
}
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key_event) = event::read()? {
if let Some(Action::Remove(pkgs, source)) =
handle_key(&mut app, key_event.code, key_event.modifiers)
{
let pm = managers
.iter()
.find(|m| m.name() == source)
.or_else(|| managers.first());
if let Some(pm) = pm {
app.mode = Mode::Progress;
app.progress_items = pkgs
.iter()
.map(|p| ProgressItem {
name: p.clone(),
done: false,
success: false,
})
.collect();
render(&mut terminal, &mut app)?;
for (i, pkg) in pkgs.iter().enumerate() {
let result = pm.remove(std::slice::from_ref(pkg)).await;
if let Some(item) = app.progress_items.get_mut(i) {
item.done = true;
item.success = matches!(result, PmResult::Success);
}
render(&mut terminal, &mut app)?;
}
app.mode = Mode::Done;
}
}
}
}
}
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
Ok(())
}
pub async fn run_burial_tui(
graveyard: &GraveyardConfig,
managers: &[Box<dyn PackageManager>],
packages: &[String],
requested_manager: Option<&str>,
) -> io::Result<()> {
theme::reload();
terminal::enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.mode = Mode::BurialProgress;
app.burial_is_exhume = false;
app.burial_files = packages
.iter()
.map(|p| {
if let Some(colon) = p.find(':') {
let pkg = &p[colon + 1..];
if !pkg.is_empty() {
pkg.to_string()
} else {
p.clone()
}
} else {
p.clone()
}
})
.collect();
app.burial_results = vec![None; packages.len()];
render(&mut terminal, &mut app)?;
for (i, raw_pkg) in packages.iter().enumerate() {
for _ in 0..6 {
app.tick += 1;
render(&mut terminal, &mut app)?;
tokio::time::sleep(Duration::from_millis(80)).await;
}
let (source, pkg_name) = if let Some(colon) = raw_pkg.find(':') {
let src = &raw_pkg[..colon];
let name = &raw_pkg[colon + 1..];
(
Some(src),
if name.is_empty() {
raw_pkg.as_str()
} else {
name
},
)
} else {
(requested_manager, raw_pkg.as_str())
};
let pm_opt = source
.and_then(|s| managers.iter().find(|m| m.name() == s))
.or_else(|| {
managers
.iter()
.find(|m| matches!(m.name(), "apt" | "pacstall" | "brew"))
})
.or_else(|| managers.first());
let result = if let Some(pm) = pm_opt {
pm.remove(&[pkg_name.to_string()]).await
} else {
PmResult::Error("No suitable package manager found".into())
};
let path_msg = match result {
PmResult::Success => {
let buried_path = if let Ok(bin) = which::which(pkg_name) {
graveyard
.bury(&[bin])
.await
.ok()
.and_then(|v| v.into_iter().next())
.map(|b| b.graveyard_path.to_string_lossy().to_string())
} else {
None
};
buried_path.unwrap_or_else(|| "removed via package manager".to_string())
}
PmResult::Error(e) => format!("ERROR: {}", e),
_ => "done".to_string(),
};
app.burial_results[i] = Some(path_msg);
render(&mut terminal, &mut app)?;
}
loop {
app.tick += 1;
render(&mut terminal, &mut app)?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter) {
break;
}
}
}
}
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
Ok(())
}
pub async fn run_exhume_tui(
graveyard: &GraveyardConfig,
managers: &[Box<dyn PackageManager>],
packages: &[String],
) -> io::Result<()> {
theme::reload();
terminal::enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.mode = Mode::BurialProgress;
app.burial_is_exhume = true;
app.burial_files = packages.to_vec();
app.burial_results = vec![None; packages.len()];
render(&mut terminal, &mut app)?;
for (i, pkg) in packages.iter().enumerate() {
for _ in 0..6 {
app.tick += 1;
render(&mut terminal, &mut app)?;
tokio::time::sleep(Duration::from_millis(80)).await;
}
let result = graveyard.exhume(Some(std::slice::from_ref(pkg))).await;
let msg = match result {
Ok(()) => {
let pm_opt = managers
.iter()
.find(|m| matches!(m.name(), "apt" | "pacstall" | "brew"))
.or_else(|| managers.first());
if let Some(pm) = pm_opt {
match pm.install(std::slice::from_ref(pkg)).await {
PmResult::Success => format!("reinstalled via {}", pm.name()),
_ => "files recovered from graveyard".to_string(),
}
} else {
"files recovered from graveyard".to_string()
}
}
Err(e) => format!("ERROR: {}", e),
};
app.burial_results[i] = Some(msg);
render(&mut terminal, &mut app)?;
}
loop {
app.tick += 1;
render(&mut terminal, &mut app)?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter) {
break;
}
}
}
}
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
Ok(())
}
pub async fn run_graveyard_tui(
graveyard: &GraveyardConfig,
managers: &[Box<dyn PackageManager>],
) -> io::Result<()> {
theme::reload();
terminal::enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.mode = Mode::GraveyardView;
app.graveyard_entries = graveyard.list_buried();
loop {
app.tick += 1;
render(&mut terminal, &mut app)?;
if app.should_quit {
break;
}
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Enter && !app.graveyard_entries.is_empty() {
if let Some(entry) = app.graveyard_entries.get(app.graveyard_selected) {
let pkg_name = entry.name.clone();
app.mode = Mode::BurialProgress;
app.burial_is_exhume = true;
app.burial_files = vec![pkg_name.clone()];
app.burial_results = vec![None];
for _ in 0..6 {
app.tick += 1;
render(&mut terminal, &mut app)?;
tokio::time::sleep(Duration::from_millis(80)).await;
}
let result = graveyard
.exhume(Some(std::slice::from_ref(&pkg_name)))
.await;
let msg = match result {
Ok(()) => {
let pm_opt = managers
.iter()
.find(|m| matches!(m.name(), "apt" | "pacstall" | "brew"))
.or_else(|| managers.first());
if let Some(pm) = pm_opt {
match pm.install(std::slice::from_ref(&pkg_name)).await {
PmResult::Success => {
format!("reinstalled via {}", pm.name())
}
_ => "recovered from graveyard".to_string(),
}
} else {
"recovered from graveyard".to_string()
}
}
Err(e) => format!("ERROR: {}", e),
};
app.burial_results[0] = Some(msg);
render(&mut terminal, &mut app)?;
loop {
if event::poll(Duration::from_millis(100))? {
if let Event::Key(k) = event::read()? {
if matches!(
k.code,
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter
) {
break;
}
}
}
}
app.graveyard_entries = graveyard.list_buried();
app.graveyard_selected = app
.graveyard_selected
.min(app.graveyard_entries.len().saturating_sub(1));
app.mode = Mode::GraveyardView;
}
} else {
handle_key(&mut app, key.code, key.modifiers);
}
}
}
}
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
Ok(())
}
fn relevance_score(pkg: &PackageInfo, query: &str) -> u32 {
let q = query.to_lowercase();
let name = pkg.name.to_lowercase();
let desc = pkg.description.to_lowercase();
if name == q {
return 1000;
}
if name.starts_with(&q) {
return 800;
}
let as_word = |haystack: &str, needle: &str| -> bool {
if let Some(pos) = haystack.find(needle) {
let before_ok = pos == 0
|| haystack
.as_bytes()
.get(pos - 1)
.is_none_or(|&b| matches!(b, b'-' | b'_' | b'/' | b'@'));
let after_pos = pos + needle.len();
let after_ok = after_pos >= haystack.len()
|| haystack
.as_bytes()
.get(after_pos)
.is_none_or(|&b| matches!(b, b'-' | b'_' | b'/' | b'@'));
before_ok && after_ok
} else {
false
}
};
if as_word(&name, &q) {
return 700;
}
if name.contains(&q) {
let len_bonus = 50u32.saturating_sub(name.len() as u32);
return 600 + len_bonus;
}
if desc.contains(&q) {
return 300;
}
let tokens: Vec<&str> = q
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.collect();
if tokens.iter().all(|t| name.contains(t)) && !tokens.is_empty() {
return 200;
}
let matched = tokens.iter().filter(|t| name.contains(*t)).count();
if matched > 0 {
return (100 + matched * 30) as u32;
}
0
}
async fn search_all(
managers: &[Box<dyn PackageManager>],
query: &str,
) -> Vec<(String, Vec<PackageInfo>)> {
let mut results: std::collections::HashMap<String, Vec<PackageInfo>> =
std::collections::HashMap::new();
let terms: Vec<&str> = query
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for pm in managers {
if !pm.is_available() || matches!(pm.name(), "topgrade" | "bun" | "cargo-binstall") {
continue;
}
for term in &terms {
if let Ok(pkgs) = pm.search_captured(term).await {
if !pkgs.is_empty() {
results
.entry(pm.name().to_string())
.or_default()
.extend(pkgs);
}
}
}
}
let mut installed_names: std::collections::HashMap<String, std::collections::HashSet<String>> =
std::collections::HashMap::new();
for pm in managers {
if pm.is_available() {
if let Ok(inst) = pm.list_installed().await {
let names: std::collections::HashSet<String> =
inst.into_iter().map(|p| p.name.to_lowercase()).collect();
installed_names.insert(pm.name().to_string(), names);
}
}
}
let all_installed: std::collections::HashSet<&str> = installed_names
.values()
.flat_map(|s| s.iter().map(|n| n.as_str()))
.collect();
let primary_term = terms.first().copied().unwrap_or(query);
let mut final_results: Vec<(String, Vec<PackageInfo>)> = results
.into_iter()
.map(|(pm_name, mut pkgs)| {
let pm_installed = installed_names.get(&pm_name);
for pkg in &mut pkgs {
let lower = pkg.name.to_lowercase();
pkg.installed = pm_installed
.map(|s| s.contains(lower.as_str()))
.unwrap_or(false)
|| all_installed.contains(lower.as_str());
}
pkgs.dedup_by(|a, b| a.name == b.name);
pkgs.sort_by(|a, b| {
let sa = relevance_score(a, primary_term);
let sb = relevance_score(b, primary_term);
sb.cmp(&sa).then_with(|| a.name.cmp(&b.name))
});
(pm_name, pkgs)
})
.collect();
final_results.sort_by(|a, b| a.0.cmp(&b.0));
final_results
}