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::collections::HashMap;
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, PackageHistoryItem, record_operation};
use crate::kernel::detector::KernelEntry;
use crate::kernel::vanilla::VanillaRelease;
use crate::remote::config::{load_remote_config, save_remote_config};
use crate::remote::server::{AgentStatus, RemoteCommand, RemoteEvent, RemoteServer};
use crate::remote::tls::{cert_fingerprint, ensure_server_cert, make_server_tls};
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,
remote_panel::RemotePanel,
search_panel::SearchPanel,
sources_panel::SourcesPanel,
update_panel::UpdatePanel,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Updates = 0,
Search = 1,
Packages = 2,
Kernels = 3,
Desktops = 4,
Sources = 5,
Remote = 6,
History = 7,
}
impl Tab {
pub fn from_index(i: usize) -> Option<Self> {
match i {
0 => Some(Tab::Updates),
1 => Some(Tab::Search),
2 => Some(Tab::Packages),
3 => Some(Tab::Kernels),
4 => Some(Tab::Desktops),
5 => Some(Tab::Sources),
6 => Some(Tab::Remote),
7 => Some(Tab::History),
_ => None,
}
}
pub fn title(&self) -> &'static str {
match self {
Tab::Updates => "Updates",
Tab::Search => "Search",
Tab::Packages => "Packages",
Tab::Kernels => "Kernels",
Tab::Desktops => "Desktops",
Tab::Sources => "Sources",
Tab::Remote => "Remote",
Tab::History => "History",
}
}
pub fn all() -> &'static [Tab] {
&[
Tab::Updates, Tab::Search, Tab::Packages, Tab::Kernels,
Tab::Desktops, Tab::Sources, Tab::Remote, Tab::History,
]
}
}
fn apt_op_to_history(op: &AptOperation) -> (&'static str, Vec<PackageHistoryItem>) {
let items = |pkgs: &[String]| pkgs.iter().map(|n| PackageHistoryItem { name: n.clone(), old_version: None, new_version: None }).collect();
match op {
AptOperation::Install(pkgs) => ("install", items(pkgs)),
AptOperation::Remove(pkgs) => ("remove", items(pkgs)),
AptOperation::Purge(pkgs) => ("purge", items(pkgs)),
AptOperation::Upgrade { .. } => ("upgrade", vec![]),
AptOperation::Update => ("update", vec![]),
}
}
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>>,
refresh_tx: Option<mpsc::Sender<Vec<PackageChange>>>,
remote_event_tx: Option<mpsc::Sender<RemoteEvent>>,
remote_server: Option<RemoteServer>,
history_max_entries: usize,
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 remote_panel: RemotePanel,
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>,
history_max_entries: usize,
) -> Self {
Self {
active_tab: Tab::Updates,
status_messages: Vec::new(),
should_quit: false,
show_help: false,
apt_tx: None,
refresh_tx: None,
remote_event_tx: None,
remote_server: None,
history_max_entries,
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),
remote_panel: RemotePanel::new(),
history_panel: HistoryPanel::new(history),
}
}
pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()>
where
B::Error: Send + Sync + 'static,
{
let (apt_tx, apt_rx) = mpsc::channel::<String>();
let (refresh_tx, refresh_rx) = mpsc::channel::<Vec<PackageChange>>();
let (remote_event_tx, remote_event_rx) = mpsc::channel::<RemoteEvent>();
self.apt_tx = Some(apt_tx);
self.refresh_tx = Some(refresh_tx);
self.remote_event_tx = Some(remote_event_tx);
let handler = EventHandler::new(Some(apt_rx), Some(refresh_rx), Some(remote_event_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::Updates => self.update_panel.handle_key(key.code),
Tab::Search => self.search_panel.handle_key(key.code),
Tab::Packages => self.package_table.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::Remote => self.remote_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::UpdatesRefreshed(changes) => {
let count = changes.len();
self.update_panel = UpdatePanel::new(changes);
self.status_messages.push(format!("Updates refreshed: {} pending.", count));
}
AppEvent::Remote(ev) => self.handle_remote_event(ev),
AppEvent::Tick => {}
}
if self.should_quit {
break;
}
}
Ok(())
}
fn handle_remote_event(&mut self, ev: RemoteEvent) {
match ev {
RemoteEvent::AgentConnected(id, hostname) => {
self.remote_panel.sync_ids();
self.remote_panel.last_event = format!("Agent {} connected: {}", id, hostname);
self.status_messages.push(format!("Remote: {} connected (id={})", hostname, id));
}
RemoteEvent::AgentDisconnected(id) => {
self.remote_panel.sync_ids();
self.remote_panel.last_event = format!("Agent {} disconnected", id);
self.status_messages.push(format!("Remote: agent {} disconnected", id));
}
RemoteEvent::AgentUpdates(id, pkgs) => {
let msg = if pkgs.is_empty() {
format!("Remote agent {}: up to date", id)
} else {
format!("Remote agent {}: {} updates available", id, pkgs.len())
};
self.remote_panel.last_event = msg.clone();
self.status_messages.push(msg);
}
RemoteEvent::AgentDone(id, success, _output) => {
let msg = format!(
"Remote agent {}: command {}",
id,
if success { "succeeded" } else { "failed" }
);
self.remote_panel.last_event = msg.clone();
self.status_messages.push(msg);
self.remote_panel.sync_ids();
}
}
}
fn handle_tab_action(&mut self, code: KeyCode) -> bool {
match self.active_tab {
Tab::Updates => match code {
KeyCode::Char('u') => { self.spawn_upgrade(); true }
KeyCode::Char('r') => { self.spawn_refresh_updates(); true }
_ => false,
},
Tab::Search => self.handle_search_action(code),
Tab::Packages => self.handle_packages_action(code),
Tab::Kernels => self.handle_kernels_action(code),
Tab::Desktops => self.handle_desktops_action(code),
Tab::Sources => self.handle_sources_action(code),
Tab::Remote => self.handle_remote_action(code),
Tab::History => {
if code == KeyCode::Char('u') {
self.spawn_history_undo();
true
} else {
false
}
}
}
}
fn handle_remote_action(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Char('s') => {
if self.remote_panel.server_running {
self.stop_remote_server();
} else {
self.start_remote_server();
}
true
}
KeyCode::Char('r') => {
if let Some(id) = self.remote_panel.selected_agent_id() {
if let Some(srv) = &self.remote_server {
srv.send_cmd(id, RemoteCommand::CheckUpdates);
self.status_messages.push(format!("Requested update check on agent {}", id));
if let Ok(mut map) = self.remote_panel.agents.lock() {
if let Some(a) = map.get_mut(&id) {
a.status = AgentStatus::Busy("checking updates".to_string());
}
}
}
}
true
}
KeyCode::Char('u') => {
if let Some(id) = self.remote_panel.selected_agent_id() {
if let Some(srv) = &self.remote_server {
srv.send_cmd(id, RemoteCommand::RunUpgrade);
self.status_messages.push(format!("Sent upgrade command to agent {}", id));
if let Ok(mut map) = self.remote_panel.agents.lock() {
if let Some(a) = map.get_mut(&id) {
a.status = AgentStatus::Busy("running upgrade".to_string());
}
}
}
}
true
}
_ => false,
}
}
fn start_remote_server(&mut self) {
match load_remote_config() {
Err(e) => {
self.status_messages.push(format!("Remote config error: {}", e));
return;
}
Ok(cfg) => {
if let Err(e) = save_remote_config(&cfg) {
self.status_messages.push(format!("Could not save remote config: {}", e));
return;
}
let (crt_path, key_path) = match ensure_server_cert() {
Ok(p) => p,
Err(e) => {
self.status_messages.push(format!("TLS cert error: {}", e));
return;
}
};
let fingerprint = match cert_fingerprint(&crt_path) {
Ok(fp) => fp,
Err(e) => {
self.status_messages.push(format!("Fingerprint error: {}", e));
return;
}
};
let tls_config = match make_server_tls(&crt_path, &key_path) {
Ok(c) => c,
Err(e) => {
self.status_messages.push(format!("TLS config error: {}", e));
return;
}
};
let event_tx = match &self.remote_event_tx {
Some(tx) => tx.clone(),
None => return,
};
let srv = RemoteServer::new(cfg.token.clone(), cfg.port, event_tx);
self.remote_panel.agents = std::sync::Arc::clone(&srv.agents);
if let Err(e) = srv.start(tls_config) {
self.status_messages.push(format!("Server start error: {}", e));
return;
}
let ip = local_ip().unwrap_or_else(|| "0.0.0.0".to_string());
self.remote_panel.server_addr = format!("{}:{}", ip, cfg.port);
self.remote_panel.server_token = cfg.token.clone();
self.remote_panel.server_fingerprint = fingerprint;
self.remote_panel.server_running = true;
self.remote_server = Some(srv);
self.status_messages.push(format!("Remote server started on port {}", cfg.port));
}
}
}
fn stop_remote_server(&mut self) {
self.remote_server = None;
self.remote_panel.server_running = false;
self.remote_panel.agents = std::sync::Arc::new(std::sync::Mutex::new(HashMap::new()));
self.remote_panel.agent_ids.clear();
self.remote_panel.state.select(None);
self.status_messages.push("Remote server stopped.".to_string());
}
fn handle_search_action(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Enter if self.search_panel.typing => {
self.search_panel.typing = false;
let query = self.search_panel.query.clone();
if !query.is_empty() {
let results = crate::apt::query::search_packages(&query, false).unwrap_or_default();
self.search_panel.set_results(results);
}
true
}
KeyCode::Enter => {
if let Some(idx) = self.search_panel.state.selected() {
if let Some(pkg) = self.search_panel.results.get(idx).cloned() {
let install = pkg.installed_version.is_none();
let op = if install {
AptOperation::Install(vec![pkg.name])
} else {
AptOperation::Remove(vec![pkg.name])
};
self.spawn_apt_op(op);
}
}
true
}
_ => 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() {
let max = self.history_max_entries;
thread::spawn(move || {
match execute(&op, Some(tx.clone())) {
Ok(s) if s.success() => {
let _ = tx.send("Done.".into());
let (verb, pkgs) = apt_op_to_history(&op);
let _ = record_operation(verb, pkgs, max);
}
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());
let max = self.history_max_entries;
let pkg_names: Vec<String> = self.update_panel.changes.iter().map(|c| c.name.clone()).collect();
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());
let items = pkg_names.into_iter().map(|n| PackageHistoryItem { name: n, old_version: None, new_version: None }).collect();
let _ = record_operation("upgrade", items, max);
}
Ok(_) => { let _ = tx.send("Upgrade failed.".into()); }
Err(e) => { let _ = tx.send(format!("Upgrade error: {}", e)); }
}
});
}
}
fn spawn_refresh_updates(&mut self) {
if let (Some(apt_tx), Some(refresh_tx)) = (self.apt_tx.clone(), self.refresh_tx.clone()) {
let _ = apt_tx.send("Refreshing available updates...".into());
thread::spawn(move || {
let op = AptOperation::Upgrade { full: false };
match crate::apt::executor::dry_run(&op) {
Ok(changes) => { let _ = refresh_tx.send(changes); }
Err(e) => { let _ = apt_tx.send(format!("Refresh error: {}", e)); }
}
});
}
}
fn spawn_kernel_install(&mut self, version: String) {
if let Some(tx) = self.apt_tx.clone() {
let max = self.history_max_entries;
thread::spawn(move || {
match crate::kernel::manager::install_kernel(&version, &tx) {
Ok(()) => {
let items = vec![PackageHistoryItem { name: version, old_version: None, new_version: None }];
let _ = record_operation("kernel-install", items, max);
}
Err(e) => { let _ = tx.send(format!("Kernel install error: {}", e)); }
}
});
}
}
fn spawn_kernel_remove(&mut self, version: String) {
if let Some(tx) = self.apt_tx.clone() {
let max = self.history_max_entries;
thread::spawn(move || {
match crate::kernel::manager::remove_kernel(&version, &tx) {
Ok(()) => {
let items = vec![PackageHistoryItem { name: version, old_version: None, new_version: None }];
let _ = record_operation("kernel-remove", items, max);
}
Err(e) => { let _ = tx.send(format!("Kernel remove error: {}", e)); }
}
});
}
}
fn spawn_vanilla_install(&mut self, version: String) {
if let Some(tx) = self.apt_tx.clone() {
let max = self.history_max_entries;
thread::spawn(move || {
match crate::kernel::vanilla::install_vanilla(&version, &tx) {
Ok(()) => {
let items = vec![PackageHistoryItem { name: version, old_version: None, new_version: None }];
let _ = record_operation("vanilla-install", items, max);
}
Err(e) => { let _ = tx.send(format!("Vanilla install error: {}", e)); }
}
});
}
}
fn spawn_vanilla_remove(&mut self, version: String) {
if let Some(tx) = self.apt_tx.clone() {
let max = self.history_max_entries;
thread::spawn(move || {
match crate::kernel::vanilla::remove_vanilla(&version, &tx) {
Ok(()) => {
let items = vec![PackageHistoryItem { name: version, old_version: None, new_version: None }];
let _ = record_operation("vanilla-remove", items, max);
}
Err(e) => { 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));
let max = self.history_max_entries;
let op_verb = if install { "install" } else { "remove" };
thread::spawn(move || {
let result = if install {
crate::desktop::manager::install_desktop(id, Some(tx.clone()))
} else {
crate::desktop::manager::remove_desktop(id, Some(tx.clone()))
};
match result {
Ok(()) => {
let _ = tx.send("Done.".into());
let items = vec![PackageHistoryItem { name: id.to_string(), old_version: None, new_version: None }];
let _ = record_operation(op_verb, items, max);
}
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 {
if self.active_tab == Tab::Search && self.search_panel.typing {
if let KeyCode::Char('c') = code {
if modifiers.contains(KeyModifiers::CONTROL) {
self.should_quit = true;
return true;
}
}
return false;
}
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 next = (self.active_tab as usize + 1) % Tab::all().len();
self.active_tab = Tab::from_index(next).unwrap_or(Tab::Updates);
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::Updates);
true
}
KeyCode::Char('1') => { self.active_tab = Tab::Updates; true }
KeyCode::Char('2') => { self.active_tab = Tab::Search; true }
KeyCode::Char('3') => { self.active_tab = Tab::Packages; true }
KeyCode::Char('4') => { self.active_tab = Tab::Kernels; true }
KeyCode::Char('5') => { self.active_tab = Tab::Desktops; true }
KeyCode::Char('6') => { self.active_tab = Tab::Sources; true }
KeyCode::Char('7') => { self.active_tab = Tab::Remote; true }
KeyCode::Char('8') => { self.active_tab = Tab::History; true }
_ => false,
}
}
fn draw(&mut 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(&mut self, f: &mut Frame, area: Rect) {
match self.active_tab {
Tab::Updates => self.update_panel.render(f, area),
Tab::Search => self.search_panel.render(f, area),
Tab::Packages => self.package_table.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::Remote => self.remote_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-8: 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)),
]);
f.render_widget(
Paragraph::new(text).block(Block::default().borders(Borders::ALL)),
area,
);
}
fn draw_help_overlay(&self, f: &mut Frame, area: Rect) {
let popup_area = centered_rect(65, 18, 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-8 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"),
Line::from(""),
Line::from(Span::styled("Search tab", hdr)),
Line::from("/ start typing query"),
Line::from("Enter run search / install"),
Line::from("Esc cancel typing"),
];
let right = vec![
Line::from(Span::styled("Actions", hdr)),
Line::from("[i] install selected"),
Line::from("[r] remove / check updates"),
Line::from("[u] upgrade (Updates/Remote)"),
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 DM / start/stop server"),
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" => AptOperation::Remove(entry.packages.iter().map(|p| p.name.clone()).collect()),
"remove" => AptOperation::Install(entry.packages.iter().map(|p| p.name.clone()).collect()),
"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(())
}
fn local_ip() -> Option<String> {
let out = std::process::Command::new("ip")
.args(["route", "get", "1"])
.output()
.ok()?;
let s = String::from_utf8_lossy(&out.stdout);
let tokens: Vec<&str> = s.split_whitespace().collect();
let src_pos = tokens.iter().position(|&t| t == "src")?;
tokens.get(src_pos + 1).map(|s| s.to_string())
}