use anyhow::Result;
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
backend::Backend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Tabs},
Frame, Terminal,
};
use std::sync::mpsc;
use std::thread;
use crate::apt::executor::{AptOperation, execute};
use crate::apt::query::PackageInfo;
use crate::apt::parser::PackageChange;
use crate::desktop::manager::InstalledStatus;
use crate::history::HistoryEntry;
use crate::kernel::detector::KernelEntry;
use crate::kernel::vanilla::VanillaRelease;
use crate::sources::parser::AptSource;
use super::events::{AppEvent, EventHandler};
use super::widgets::{
desktop_panel::DesktopPanel,
history_panel::HistoryPanel,
kernel_panel::{KernelPanel, KernelTab},
package_table::PackageTable,
search_panel::SearchPanel,
sources_panel::SourcesPanel,
update_panel::UpdatePanel,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Packages = 0,
Updates = 1,
Kernels = 2,
Desktops = 3,
Sources = 4,
History = 5,
}
impl Tab {
pub fn from_index(i: usize) -> Option<Self> {
match i {
0 => Some(Tab::Packages),
1 => Some(Tab::Updates),
2 => Some(Tab::Kernels),
3 => Some(Tab::Desktops),
4 => Some(Tab::Sources),
5 => Some(Tab::History),
_ => None,
}
}
pub fn title(&self) -> &'static str {
match self {
Tab::Packages => "Packages",
Tab::Updates => "Updates",
Tab::Kernels => "Kernels",
Tab::Desktops => "Desktops",
Tab::Sources => "Sources",
Tab::History => "History",
}
}
pub fn all() -> &'static [Tab] {
&[Tab::Packages, Tab::Updates, Tab::Kernels, Tab::Desktops, Tab::Sources, Tab::History]
}
}
pub struct App {
pub active_tab: Tab,
pub status_messages: Vec<String>,
pub should_quit: bool,
pub show_help: bool,
apt_tx: Option<mpsc::Sender<String>>,
pub package_table: PackageTable,
pub update_panel: UpdatePanel,
pub search_panel: SearchPanel,
pub kernel_panel: KernelPanel,
pub desktop_panel: DesktopPanel,
pub sources_panel: SourcesPanel,
pub history_panel: HistoryPanel,
}
impl App {
pub fn new(
packages: Vec<PackageInfo>,
changes: Vec<PackageChange>,
upgrade_changes: Vec<PackageChange>,
kernels: Vec<KernelEntry>,
vanilla: Vec<VanillaRelease>,
desktops: Vec<InstalledStatus>,
sources: Vec<AptSource>,
history: Vec<HistoryEntry>,
) -> Self {
Self {
active_tab: Tab::Packages,
status_messages: Vec::new(),
should_quit: false,
show_help: false,
apt_tx: None,
package_table: PackageTable::new(packages, changes),
update_panel: UpdatePanel::new(upgrade_changes),
search_panel: SearchPanel::new(),
kernel_panel: KernelPanel::new(kernels, vanilla),
desktop_panel: DesktopPanel::new(desktops),
sources_panel: SourcesPanel::new(sources),
history_panel: HistoryPanel::new(history),
}
}
pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
let (apt_tx, apt_rx) = mpsc::channel::<String>();
self.apt_tx = Some(apt_tx);
let handler = EventHandler::new(Some(apt_rx));
loop {
terminal.draw(|f| self.draw(f))?;
match handler.next()? {
AppEvent::Key(key) => {
if self.handle_global_key(key.code, key.modifiers) {
continue;
}
if self.handle_tab_action(key.code) {
continue;
}
match self.active_tab {
Tab::Packages => self.package_table.handle_key(key.code),
Tab::Updates => self.update_panel.handle_key(key.code),
Tab::Kernels => self.kernel_panel.handle_key(key.code),
Tab::Desktops => self.desktop_panel.handle_key(key.code),
Tab::Sources => self.sources_panel.handle_key(key.code),
Tab::History => self.history_panel.handle_key(key.code),
}
}
AppEvent::AptOutput(line) => {
self.status_messages.push(line);
if self.status_messages.len() > 100 {
self.status_messages.drain(0..1);
}
}
AppEvent::Tick => {}
}
if self.should_quit {
break;
}
}
Ok(())
}
fn handle_tab_action(&mut self, code: KeyCode) -> bool {
match self.active_tab {
Tab::Packages => self.handle_packages_action(code),
Tab::Updates => {
if code == KeyCode::Char('u') {
self.spawn_upgrade();
true
} else {
false
}
}
Tab::Kernels => self.handle_kernels_action(code),
Tab::Desktops => self.handle_desktops_action(code),
Tab::Sources => self.handle_sources_action(code),
Tab::History => {
if code == KeyCode::Char('u') {
self.spawn_history_undo();
true
} else {
false
}
}
}
}
fn handle_packages_action(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Enter => {
if self.package_table.detail_pkg.is_some() {
self.package_table.detail_pkg = None;
} else {
let name = self.package_table.state.selected()
.and_then(|idx| self.package_table.packages.get(idx))
.map(|p| p.name.clone());
if let Some(n) = name {
match crate::apt::query::show_package(&n) {
Ok(info) => self.package_table.detail_pkg = Some(info),
Err(e) => self.status_messages.push(format!("Error fetching {}: {}", n, e)),
}
}
}
true
}
KeyCode::Char('i') => {
let name = self.package_table.state.selected()
.and_then(|idx| self.package_table.packages.get(idx))
.map(|p| p.name.clone());
if let Some(n) = name {
self.spawn_apt_op(AptOperation::Install(vec![n]));
}
true
}
KeyCode::Char('r') => {
let name = self.package_table.state.selected()
.and_then(|idx| self.package_table.packages.get(idx))
.map(|p| p.name.clone());
if let Some(n) = name {
self.spawn_apt_op(AptOperation::Remove(vec![n]));
}
true
}
_ => false,
}
}
fn handle_kernels_action(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Char('i') => {
let is_debian = self.kernel_panel.active_tab == KernelTab::Debian;
let ver = if is_debian {
self.kernel_panel.debian_state.selected()
.and_then(|i| self.kernel_panel.kernels.get(i))
.map(|e| e.version.clone())
} else {
self.kernel_panel.vanilla_state.selected()
.and_then(|i| self.kernel_panel.vanilla.get(i))
.map(|r| r.version.clone())
};
if let Some(v) = ver {
if is_debian { self.spawn_kernel_install(v); } else { self.spawn_vanilla_install(v); }
}
true
}
KeyCode::Char('r') => {
let is_debian = self.kernel_panel.active_tab == KernelTab::Debian;
let ver = if is_debian {
self.kernel_panel.debian_state.selected()
.and_then(|i| self.kernel_panel.kernels.get(i))
.map(|e| e.version.clone())
} else {
self.kernel_panel.vanilla_state.selected()
.and_then(|i| self.kernel_panel.vanilla.get(i))
.map(|r| r.version.clone())
};
if let Some(v) = ver {
if is_debian { self.spawn_kernel_remove(v); } else { self.spawn_vanilla_remove(v); }
}
true
}
KeyCode::Char('p') => {
if self.kernel_panel.active_tab == KernelTab::Debian {
let data = self.kernel_panel.debian_state.selected()
.and_then(|i| self.kernel_panel.kernels.get(i).map(|e| (i, e.version.clone(), e.is_held)));
if let Some((idx, ver, is_held)) = data {
let result = if is_held {
crate::kernel::manager::unpin_kernel(&ver)
} else {
crate::kernel::manager::pin_kernel(&ver)
};
match result {
Ok(()) => {
if let Some(e) = self.kernel_panel.kernels.get_mut(idx) {
e.is_held = !is_held;
}
let verb = if is_held { "Unpinned" } else { "Pinned" };
self.status_messages.push(format!("{} kernel {}", verb, ver));
}
Err(e) => {
self.status_messages.push(format!("Pin error: {}", e));
}
}
}
}
true
}
_ => false,
}
}
fn handle_desktops_action(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Enter => {
let action = self.desktop_panel.state.selected()
.and_then(|i| self.desktop_panel.desktops.get(i).map(|d| (d.profile.id, !d.installed)));
if let Some((id, install)) = action {
self.spawn_desktop_action(id, install);
}
true
}
KeyCode::Char('s') => {
let id = self.desktop_panel.state.selected()
.and_then(|i| self.desktop_panel.desktops.get(i).map(|d| d.profile.id));
if let Some(id) = id {
self.spawn_desktop_switch(id);
}
true
}
_ => false,
}
}
fn handle_sources_action(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Char(' ') => { self.do_source_toggle(); true }
KeyCode::Char('d') => { self.do_source_delete(); true }
KeyCode::Char('a') => {
self.status_messages.push(
"To add: exit TUI and run: rustpm sources add <uri> <suite> <component>".into(),
);
true
}
KeyCode::Char('e') => {
self.status_messages.push(
"To edit: exit TUI and run: rustpm sources edit".into(),
);
true
}
_ => false,
}
}
fn spawn_apt_op(&mut self, op: AptOperation) {
if let Some(tx) = self.apt_tx.clone() {
thread::spawn(move || {
match execute(&op, Some(tx.clone())) {
Ok(s) if s.success() => { let _ = tx.send("Done.".into()); }
Ok(_) => { let _ = tx.send("Operation failed.".into()); }
Err(e) => { let _ = tx.send(format!("Error: {}", e)); }
}
});
}
}
fn spawn_upgrade(&mut self) {
if let Some(tx) = self.apt_tx.clone() {
let _ = tx.send("Running apt-get upgrade...".into());
thread::spawn(move || {
let op = AptOperation::Upgrade { full: false };
match execute(&op, Some(tx.clone())) {
Ok(s) if s.success() => { let _ = tx.send("Upgrade complete.".into()); }
Ok(_) => { let _ = tx.send("Upgrade failed.".into()); }
Err(e) => { let _ = tx.send(format!("Upgrade error: {}", e)); }
}
});
}
}
fn spawn_kernel_install(&mut self, version: String) {
if let Some(tx) = self.apt_tx.clone() {
thread::spawn(move || {
if let Err(e) = crate::kernel::manager::install_kernel(&version, &tx) {
let _ = tx.send(format!("Kernel install error: {}", e));
}
});
}
}
fn spawn_kernel_remove(&mut self, version: String) {
if let Some(tx) = self.apt_tx.clone() {
thread::spawn(move || {
if let Err(e) = crate::kernel::manager::remove_kernel(&version, &tx) {
let _ = tx.send(format!("Kernel remove error: {}", e));
}
});
}
}
fn spawn_vanilla_install(&mut self, version: String) {
if let Some(tx) = self.apt_tx.clone() {
thread::spawn(move || {
if let Err(e) = crate::kernel::vanilla::install_vanilla(&version, &tx) {
let _ = tx.send(format!("Vanilla install error: {}", e));
}
});
}
}
fn spawn_vanilla_remove(&mut self, version: String) {
if let Some(tx) = self.apt_tx.clone() {
thread::spawn(move || {
if let Err(e) = crate::kernel::vanilla::remove_vanilla(&version, &tx) {
let _ = tx.send(format!("Vanilla remove error: {}", e));
}
});
}
}
fn spawn_desktop_action(&mut self, id: &'static str, install: bool) {
if let Some(tx) = self.apt_tx.clone() {
let verb = if install { "Installing" } else { "Removing" };
let _ = tx.send(format!("{} {}...", verb, id));
thread::spawn(move || {
let result = if install {
crate::desktop::manager::install_desktop(id)
} else {
crate::desktop::manager::remove_desktop(id)
};
match result {
Ok(()) => { let _ = tx.send("Done.".into()); }
Err(e) => { let _ = tx.send(format!("Desktop error: {}", e)); }
}
});
}
}
fn spawn_desktop_switch(&mut self, id: &'static str) {
if let Some(tx) = self.apt_tx.clone() {
let _ = tx.send(format!("Switching display manager to {}...", id));
thread::spawn(move || {
match crate::desktop::manager::switch_desktop(id) {
Ok(()) => { let _ = tx.send(format!("Switched to {}. Restart DM to apply.", id)); }
Err(e) => { let _ = tx.send(format!("Switch error: {}", e)); }
}
});
}
}
fn do_source_toggle(&mut self) {
let data = self.sources_panel.state.selected()
.and_then(|i| self.sources_panel.sources.get(i).map(|s| (i, s.uri.clone(), s.enabled)));
if let Some((idx, uri, enabled)) = data {
let result = if enabled {
crate::sources::manager::disable_source(&uri)
} else {
crate::sources::manager::enable_source(&uri)
};
match result {
Ok(()) => {
if let Some(s) = self.sources_panel.sources.get_mut(idx) {
s.enabled = !enabled;
}
let verb = if enabled { "Disabled" } else { "Enabled" };
self.status_messages.push(format!("{} source: {}", verb, uri));
}
Err(e) => {
self.status_messages.push(format!("Source toggle error: {}", e));
}
}
}
}
fn do_source_delete(&mut self) {
let data = self.sources_panel.state.selected()
.and_then(|i| self.sources_panel.sources.get(i).map(|s| (i, s.uri.clone())));
if let Some((idx, uri)) = data {
match crate::sources::manager::remove_source(&uri) {
Ok(()) => {
self.sources_panel.sources.remove(idx);
let new_len = self.sources_panel.sources.len();
if new_len == 0 {
self.sources_panel.state.select(None);
} else {
self.sources_panel.state.select(Some(idx.min(new_len - 1)));
}
self.status_messages.push(format!("Removed source: {}", uri));
}
Err(e) => {
self.status_messages.push(format!("Source delete error: {}", e));
}
}
}
}
fn spawn_history_undo(&mut self) {
let entry = self.history_panel.state.selected()
.and_then(|i| self.history_panel.entries.get(i).cloned());
if let Some(entry) = entry {
if let Some(tx) = self.apt_tx.clone() {
let _ = tx.send(format!("Undoing #{}: {}...", entry.id, entry.operation));
thread::spawn(move || {
match undo_entry(&entry, &tx) {
Ok(()) => { let _ = tx.send("Undo complete.".into()); }
Err(e) => { let _ = tx.send(format!("Undo error: {}", e)); }
}
});
}
}
}
fn handle_global_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> bool {
match code {
KeyCode::Char('q') => {
if self.show_help {
self.show_help = false;
} else if self.package_table.detail_pkg.is_some() {
self.package_table.detail_pkg = None;
} else {
self.should_quit = true;
}
true
}
KeyCode::Esc => {
if self.show_help {
self.show_help = false;
true
} else if self.package_table.detail_pkg.is_some() {
self.package_table.detail_pkg = None;
true
} else {
false
}
}
KeyCode::Char('?') => {
self.show_help = !self.show_help;
true
}
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
true
}
KeyCode::Tab => {
let idx = self.active_tab as usize;
let next = (idx + 1) % Tab::all().len();
self.active_tab = Tab::from_index(next).unwrap_or(Tab::Packages);
true
}
KeyCode::BackTab => {
let idx = self.active_tab as usize;
let prev = if idx == 0 { Tab::all().len() - 1 } else { idx - 1 };
self.active_tab = Tab::from_index(prev).unwrap_or(Tab::Packages);
true
}
KeyCode::Char('1') => { self.active_tab = Tab::Packages; true }
KeyCode::Char('2') => { self.active_tab = Tab::Updates; true }
KeyCode::Char('3') => { self.active_tab = Tab::Kernels; true }
KeyCode::Char('4') => { self.active_tab = Tab::Desktops; true }
KeyCode::Char('5') => { self.active_tab = Tab::Sources; true }
KeyCode::Char('6') => { self.active_tab = Tab::History; true }
_ => false,
}
}
fn draw(&self, f: &mut Frame) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(area);
self.draw_tab_bar(f, chunks[0]);
self.draw_content(f, chunks[1]);
self.draw_status_bar(f, chunks[2]);
if self.show_help {
self.draw_help_overlay(f, area);
}
}
fn draw_tab_bar(&self, f: &mut Frame, area: Rect) {
let titles: Vec<Line> = Tab::all()
.iter()
.map(|t| Line::from(t.title()))
.collect();
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" rustpm "))
.select(self.active_tab as usize)
.style(Style::default().fg(Color::White))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
f.render_widget(tabs, area);
}
fn draw_content(&self, f: &mut Frame, area: Rect) {
match self.active_tab {
Tab::Packages => self.package_table.render(f, area),
Tab::Updates => self.update_panel.render(f, area),
Tab::Kernels => self.kernel_panel.render(f, area),
Tab::Desktops => self.desktop_panel.render(f, area),
Tab::Sources => self.sources_panel.render(f, area),
Tab::History => self.history_panel.render(f, area),
}
}
fn draw_status_bar(&self, f: &mut Frame, area: Rect) {
let hint = " Tab: switch 1-6: jump PgUp/Dn: scroll ?: help q: quit";
let last_msg = self.status_messages.last().map(|s| s.as_str()).unwrap_or("");
let text = Line::from(vec![
Span::styled(hint, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(last_msg, Style::default().fg(Color::Cyan)),
]);
let bar = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL));
f.render_widget(bar, area);
}
fn draw_help_overlay(&self, f: &mut Frame, area: Rect) {
let popup_area = centered_rect(65, 16, area);
f.render_widget(Clear, popup_area);
let block = Block::default()
.borders(Borders::ALL)
.title(" rustpm keybindings ")
.style(Style::default().bg(Color::Black));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(inner);
let hdr = Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow);
let left = vec![
Line::from(Span::styled("Global", hdr)),
Line::from("Tab / 1-6 switch tab"),
Line::from("q / Esc quit / back"),
Line::from("? this help"),
Line::from("Ctrl-C force quit"),
Line::from(""),
Line::from(Span::styled("Navigation", hdr)),
Line::from("j/k / ↑↓ move cursor"),
Line::from("PgUp/PgDn scroll 10 rows"),
Line::from("Enter confirm action"),
];
let right = vec![
Line::from(Span::styled("Actions", hdr)),
Line::from("[i] install selected"),
Line::from("[r] remove selected"),
Line::from("[u] upgrade (Updates tab)"),
Line::from("[u] undo (History tab)"),
Line::from("[p] pin/unpin kernel"),
Line::from("[v/d] vanilla/debian tab"),
Line::from("[Space] enable/disable source"),
Line::from("[d] delete source"),
Line::from("[Enter] install/remove desktop"),
Line::from("[s] switch display manager"),
Line::from(""),
Line::from(Span::styled("Press ? or q to close", Style::default().fg(Color::DarkGray))),
];
f.render_widget(Paragraph::new(left), cols[0]);
f.render_widget(Paragraph::new(right), cols[1]);
}
}
fn centered_rect(percent_x: u16, height: u16, r: Rect) -> Rect {
let popup_width = r.width * percent_x / 100;
let x = r.x + (r.width - popup_width) / 2;
let y = r.y + (r.height.saturating_sub(height)) / 2;
Rect {
x,
y,
width: popup_width,
height: height.min(r.height),
}
}
fn undo_entry(entry: &HistoryEntry, tx: &mpsc::Sender<String>) -> anyhow::Result<()> {
let op = match entry.operation.as_str() {
"install" => {
let names: Vec<String> = entry.packages.iter().map(|p| p.name.clone()).collect();
AptOperation::Remove(names)
}
"remove" => {
let names: Vec<String> = entry.packages.iter().map(|p| p.name.clone()).collect();
AptOperation::Install(names)
}
"upgrade" => {
let pkgs: Vec<String> = entry.packages.iter()
.filter_map(|p| p.old_version.as_ref().map(|v| format!("{}={}", p.name, v)))
.collect();
if pkgs.is_empty() {
anyhow::bail!("No version history recorded for this upgrade");
}
AptOperation::Install(pkgs)
}
other => anyhow::bail!("Cannot undo operation type: {}", other),
};
let status = execute(&op, Some(tx.clone()))?;
if !status.success() {
anyhow::bail!("Undo operation failed");
}
Ok(())
}