From 7b285dc5dd05dfb2f29f8d7e18f691be5c14b793 Mon Sep 17 00:00:00 2001 From: Paddo <653385+paddo@users.noreply.github.com> Date: Mon, 18 May 2026 09:51:12 +1000 Subject: [PATCH 1/2] feat: add package manifest history view to dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packages tab: 'h' opens a manager's manifest commit history, Enter on a history entry shows the diff — mirroring the Files tab. Read-only; rollback action lands separately. --- src/dashboard/mod.rs | 134 +++++++++++++++++++++++------- src/dashboard/widgets/packages.rs | 119 +++++++++++++++++++++++--- src/sync/packages.rs | 12 +++ 3 files changed, 226 insertions(+), 39 deletions(-) diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index f3c1ed8..891d434 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -112,6 +112,31 @@ impl FilesTabState { } } +pub struct PackagesTabState { + pub cursor: usize, + /// Manager whose installed-package list is expanded. + pub expanded: Option, + /// Manager whose manifest history is open. + pub history_manager: Option, + pub history: Vec, + /// History entry whose diff is expanded. + pub history_commit: Option, + pub history_diff: Vec, +} + +impl PackagesTabState { + fn new() -> Self { + Self { + cursor: 0, + expanded: None, + history_manager: None, + history: Vec::new(), + history_commit: None, + history_diff: Vec::new(), + } + } +} + pub struct App { state: DashboardState, active_tab: Tab, @@ -127,8 +152,7 @@ pub struct App { flash_error: Option<(Instant, String)>, flash_message: Option<(Instant, String)>, list_edit: Option, - pkg_expanded: Option, - pkg_cursor: usize, + packages: PackagesTabState, uninstall_confirm: Option<(String, String)>, uninstalling: Option<(String, String)>, uninstall_rx: Option>>, @@ -189,9 +213,7 @@ impl App { fn item_count(&self) -> usize { match self.active_tab { Tab::Files => widgets::files::build_rows(&self.state, &self.files).len(), - Tab::Packages => { - widgets::packages::build_rows(&self.state, self.pkg_expanded.as_deref()).len() - } + Tab::Packages => widgets::packages::build_rows(&self.state, &self.packages).len(), Tab::Machines => { widgets::machines::build_rows(&self.state, self.machine_expanded.as_deref()).len() } @@ -234,8 +256,7 @@ pub fn run() -> Result<()> { flash_error: None, flash_message: None, list_edit: None, - pkg_expanded: None, - pkg_cursor: 0, + packages: PackagesTabState::new(), uninstall_confirm: None, uninstalling: None, uninstall_rx: None, @@ -1007,22 +1028,21 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { return; } - // Packages tab Enter: expand/collapse or uninstall + // Packages tab Enter: expand package list, uninstall, or toggle a history diff if app.active_tab == Tab::Packages && key.code == KeyCode::Enter { - let rows = widgets::packages::build_rows(&app.state, app.pkg_expanded.as_deref()); - if app.pkg_cursor < rows.len() { - match &rows[app.pkg_cursor] { + let rows = widgets::packages::build_rows(&app.state, &app.packages); + if app.packages.cursor < rows.len() { + match &rows[app.packages.cursor] { widgets::packages::PkgRow::Header { manager_key, .. } => { - if app.pkg_expanded.as_deref() == Some(manager_key.as_str()) { - app.pkg_expanded = None; + if app.packages.expanded.as_deref() == Some(manager_key.as_str()) { + app.packages.expanded = None; } else { - app.pkg_expanded = Some(manager_key.clone()); + app.packages.expanded = Some(manager_key.clone()); } // Clamp cursor to new row count - let new_rows = - widgets::packages::build_rows(&app.state, app.pkg_expanded.as_deref()); - if app.pkg_cursor >= new_rows.len() { - app.pkg_cursor = new_rows.len().saturating_sub(1); + let new_rows = widgets::packages::build_rows(&app.state, &app.packages); + if app.packages.cursor >= new_rows.len() { + app.packages.cursor = new_rows.len().saturating_sub(1); } } widgets::packages::PkgRow::Package { @@ -1032,6 +1052,18 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { app.uninstall_confirm = Some((manager_key.clone(), name.clone())); } } + widgets::packages::PkgRow::HistoryEntry { commit_hash, .. } => { + let commit_hash = commit_hash.clone(); + if app.packages.history_commit.as_deref() == Some(commit_hash.as_str()) { + app.packages.history_commit = None; + app.packages.history_diff.clear(); + } else { + let manager = app.packages.history_manager.clone().unwrap_or_default(); + app.packages.history_diff = load_pkg_diff(&manager, &commit_hash); + app.packages.history_commit = Some(commit_hash); + } + } + widgets::packages::PkgRow::DiffRow { .. } => {} } } return; @@ -1284,8 +1316,8 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { } } else if app.active_tab == Tab::Packages { let max = app.item_count().saturating_sub(1); - if app.pkg_cursor < max { - app.pkg_cursor += 1; + if app.packages.cursor < max { + app.packages.cursor += 1; } } else if app.active_tab == Tab::Machines { let max = app.item_count().saturating_sub(1); @@ -1303,7 +1335,7 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { if app.active_tab == Tab::Files { app.files.cursor = app.files.cursor.saturating_sub(1); } else if app.active_tab == Tab::Packages { - app.pkg_cursor = app.pkg_cursor.saturating_sub(1); + app.packages.cursor = app.packages.cursor.saturating_sub(1); } else if app.active_tab == Tab::Machines { app.machine_cursor = app.machine_cursor.saturating_sub(1); } else { @@ -1311,6 +1343,31 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { *offset = offset.saturating_sub(1); } } + KeyCode::Char('h') => { + if app.active_tab == Tab::Packages { + let rows = widgets::packages::build_rows(&app.state, &app.packages); + if let Some(widgets::packages::PkgRow::Header { manager_key, .. }) = + rows.get(app.packages.cursor) + { + let manager_key = manager_key.clone(); + let was_open = + app.packages.history_manager.as_deref() == Some(manager_key.as_str()); + app.packages.history_commit = None; + app.packages.history_diff.clear(); + if was_open { + app.packages.history_manager = None; + app.packages.history.clear(); + } else { + app.packages.history = load_pkg_history(&manager_key); + app.packages.history_manager = Some(manager_key); + } + let new_rows = widgets::packages::build_rows(&app.state, &app.packages); + if app.packages.cursor >= new_rows.len() { + app.packages.cursor = new_rows.len().saturating_sub(1); + } + } + } + } KeyCode::Char('?') => { app.show_help = !app.show_help; } @@ -1318,6 +1375,33 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { } } +/// Load manifest commit history for a package manager. +fn load_pkg_history(manager_key: &str) -> Vec { + let Some(manifest) = crate::sync::packages::manifest_filename(manager_key) else { + return Vec::new(); + }; + let repo_path = format!("manifests/{}", manifest); + crate::sync::SyncEngine::sync_path() + .ok() + .and_then(|p| crate::sync::GitBackend::open(&p).ok()) + .and_then(|git| git.file_log_changed(&repo_path, 10, false).ok()) + .unwrap_or_default() +} + +/// Load the manifest diff for a manager at a commit. +fn load_pkg_diff(manager_key: &str, commit: &str) -> Vec { + let Some(manifest) = crate::sync::packages::manifest_filename(manager_key) else { + return Vec::new(); + }; + let repo_path = format!("manifests/{}", manifest); + crate::sync::SyncEngine::sync_path() + .ok() + .and_then(|p| crate::sync::GitBackend::open(&p).ok()) + .and_then(|git| git.file_diff(commit, &repo_path, &repo_path, false).ok()) + .map(|d| d.lines().map(str::to_string).collect()) + .unwrap_or_default() +} + /// Refresh expanded file history/diff after state reload fn refresh_files_expanded(app: &mut App) { if let Some(ref repo_path) = app.files.expanded_file { @@ -1798,13 +1882,7 @@ fn draw(f: &mut Frame, app: &App) { Tab::Overview => draw_overview(f, content_chunks[1], app), Tab::Files => widgets::files::render(f, content_chunks[1], &app.state, &app.files), Tab::Packages => { - widgets::packages::render( - f, - content_chunks[1], - &app.state, - app.pkg_expanded.as_deref(), - app.pkg_cursor, - ); + widgets::packages::render(f, content_chunks[1], &app.state, &app.packages); } Tab::Machines => widgets::machines::render( f, diff --git a/src/dashboard/widgets/packages.rs b/src/dashboard/widgets/packages.rs index 503642c..281f19f 100644 --- a/src/dashboard/widgets/packages.rs +++ b/src/dashboard/widgets/packages.rs @@ -1,5 +1,7 @@ use super::manager_label; +use crate::cli::output::relative_time; use crate::dashboard::state::DashboardState; +use crate::dashboard::PackagesTabState; use ratatui::{prelude::*, widgets::*}; /// Row in the flat package list @@ -13,10 +15,20 @@ pub enum PkgRow { manager_key: String, name: String, }, + HistoryEntry { + commit_hash: String, + short_hash: String, + date: String, + machine_id: String, + message: String, + }, + DiffRow { + line: String, + }, } /// Build the flat list of rows from machine state -pub fn build_rows(state: &DashboardState, expanded: Option<&str>) -> Vec { +pub fn build_rows(state: &DashboardState, pt: &PackagesTabState) -> Vec { let current_machine_id = state .sync_state .as_ref() @@ -42,7 +54,25 @@ pub fn build_rows(state: &DashboardState, expanded: Option<&str>) -> Vec label: manager_label(key).to_string(), count: packages.len(), }); - if expanded == Some(key.as_str()) { + + if pt.history_manager.as_deref() == Some(key.as_str()) { + for entry in &pt.history { + rows.push(PkgRow::HistoryEntry { + commit_hash: entry.commit_hash.clone(), + short_hash: entry.short_hash.clone(), + date: relative_time(entry.date), + machine_id: entry.machine_id.clone(), + message: entry.message.clone(), + }); + if pt.history_commit.as_deref() == Some(entry.commit_hash.as_str()) { + for line in &pt.history_diff { + rows.push(PkgRow::DiffRow { line: line.clone() }); + } + } + } + } + + if pt.expanded.as_deref() == Some(key.as_str()) { let mut sorted_pkgs: Vec<_> = (*packages).clone(); sorted_pkgs.sort(); for pkg in &sorted_pkgs { @@ -56,14 +86,9 @@ pub fn build_rows(state: &DashboardState, expanded: Option<&str>) -> Vec rows } -pub fn render( - f: &mut Frame, - area: Rect, - state: &DashboardState, - expanded: Option<&str>, - cursor: usize, -) { - let rows = build_rows(state, expanded); +pub fn render(f: &mut Frame, area: Rect, state: &DashboardState, pt: &PackagesTabState) { + let rows = build_rows(state, pt); + let cursor = pt.cursor; let block = Block::default() .title(" Packages ") @@ -104,7 +129,7 @@ pub fn render( count, .. } => { - let arrow = if expanded == Some(manager_key.as_str()) { + let arrow = if pt.expanded.as_deref() == Some(manager_key.as_str()) { "v" } else { ">" @@ -150,6 +175,78 @@ pub fn render( ]); f.render_widget(Paragraph::new(line), row_area); } + PkgRow::HistoryEntry { + commit_hash, + short_hash, + date, + machine_id, + message, + } => { + let bg = if is_selected { + Color::Indexed(240) + } else { + Color::Reset + }; + let arrow = if pt.history_commit.as_deref() == Some(commit_hash.as_str()) { + "v" + } else { + ">" + }; + let line = Line::from(vec![ + Span::styled( + format!(" {} ", arrow), + Style::default().fg(Color::Gray).bg(bg), + ), + Span::styled( + short_hash.clone(), + Style::default().fg(Color::Yellow).bg(bg).bold(), + ), + Span::styled( + format!(" {:>12}", date), + Style::default().fg(Color::Gray).bg(bg), + ), + Span::styled( + format!(" {:15}", machine_id), + Style::default().fg(Color::Gray).bg(bg), + ), + Span::styled( + format!(" {}", message), + Style::default().fg(Color::White).bg(bg), + ), + Span::styled( + " ".repeat(inner_area.width as usize), + Style::default().bg(bg), + ), + ]); + f.render_widget(Paragraph::new(line), row_area); + } + PkgRow::DiffRow { line: diff_line } => { + let bg = if is_selected { + Color::Indexed(240) + } else { + Color::Reset + }; + let fg = if diff_line.starts_with("@@") { + Color::Cyan + } else if diff_line.starts_with("+++") || diff_line.starts_with("---") { + Color::Gray + } else if diff_line.starts_with('+') { + Color::Green + } else if diff_line.starts_with('-') { + Color::Red + } else { + Color::Gray + }; + let line = Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled(diff_line.clone(), Style::default().fg(fg).bg(bg)), + Span::styled( + " ".repeat(inner_area.width as usize), + Style::default().bg(bg), + ), + ]); + f.render_widget(Paragraph::new(line), row_area); + } } y += 1; diff --git a/src/sync/packages.rs b/src/sync/packages.rs index b1c79db..575d9ae 100644 --- a/src/sync/packages.rs +++ b/src/sync/packages.rs @@ -20,6 +20,18 @@ struct PackageManagerDef { manifest_file: &'static str, } +/// Map a machine-state package key to its manifest filename in `manifests/`. +/// All three brew keys (`brew_formulae`, `brew_casks`, `brew_taps`) share the Brewfile. +pub fn manifest_filename(state_key: &str) -> Option<&'static str> { + if state_key.starts_with("brew_") { + return Some("Brewfile"); + } + SIMPLE_MANAGERS + .iter() + .find(|d| d.state_key == state_key) + .map(|d| d.manifest_file) +} + const SIMPLE_MANAGERS: &[PackageManagerDef] = &[ PackageManagerDef { state_key: "npm", From a48e24c3fbaf5642407c96e5015e07701e4e9f00 Mon Sep 17 00:00:00 2001 From: Paddo <653385+paddo@users.noreply.github.com> Date: Mon, 18 May 2026 10:10:30 +1000 Subject: [PATCH 2/2] feat: add package rollback from the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'R' on a manifest history entry rolls a package manager back to that snapshot: a new `tether rollback packages` command computes the reverse delta (install missing, uninstall extras) and re-syncs. The dashboard spawns it as a subprocess after a confirmation modal. Brew is not yet covered — its Brewfile format and cask/tap handling warrant a separate pass; history viewing still works for it. --- src/cli/commands/mod.rs | 23 +++++++ src/cli/commands/rollback.rs | 92 ++++++++++++++++++++++++++ src/dashboard/mod.rs | 123 +++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 src/cli/commands/rollback.rs diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index d8f0db3..bd22a0a 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -10,6 +10,7 @@ mod machines; mod packages; mod resolve; mod restore; +mod rollback; mod status; pub mod sync; mod team; @@ -155,6 +156,23 @@ pub enum Commands { #[arg(short, long, default_value = "20")] limit: usize, }, + + /// Roll back synced state to an earlier point + Rollback { + #[command(subcommand)] + action: RollbackAction, + }, +} + +#[derive(Subcommand)] +pub enum RollbackAction { + /// Roll back a package manager's installed set to a manifest commit + Packages { + /// Manager key (npm, pnpm, bun, gem, uv) + manager: String, + /// Manifest commit hash to roll back to + commit: String, + }, } #[derive(Subcommand)] @@ -676,6 +694,11 @@ impl Cli { IdentityAction::Reset => identity::reset().await, }, Commands::History { file, limit } => history::run(file, *limit).await, + Commands::Rollback { action } => match action { + RollbackAction::Packages { manager, commit } => { + rollback::packages(manager, commit).await + } + }, Commands::Collab { action } => match action { CollabAction::Init { project } => collab::init(project.as_deref()).await, CollabAction::Join { url } => collab::join(url).await, diff --git a/src/cli/commands/rollback.rs b/src/cli/commands/rollback.rs new file mode 100644 index 0000000..27c8601 --- /dev/null +++ b/src/cli/commands/rollback.rs @@ -0,0 +1,92 @@ +use crate::cli::Output; +use crate::packages::{BunManager, GemManager, NpmManager, PackageManager, PnpmManager, UvManager}; +use crate::sync::{GitBackend, SyncEngine}; +use anyhow::Result; +use std::collections::HashSet; + +/// Roll back a package manager's installed set to the manifest as of `commit`. +/// +/// Reverse-delta: install packages the snapshot had but this machine lacks, and +/// uninstall packages installed since. A follow-up sync records the removals +/// (via `detect_removed_packages`) and rewrites the union manifest. +pub async fn packages(manager: &str, commit: &str) -> Result<()> { + if commit.is_empty() || !commit.chars().all(|c| c.is_ascii_hexdigit()) { + anyhow::bail!("Invalid commit hash: {}", commit); + } + + let pkg_manager: Box = match manager { + "npm" => Box::new(NpmManager::new()), + "pnpm" => Box::new(PnpmManager::new()), + "bun" => Box::new(BunManager::new()), + "gem" => Box::new(GemManager::new()), + "uv" => Box::new(UvManager::new()), + "brew_formulae" | "brew_casks" | "brew_taps" => { + anyhow::bail!("Rollback for brew is not yet supported"); + } + other => anyhow::bail!("Unknown package manager: {}", other), + }; + + if !pkg_manager.is_available().await { + anyhow::bail!("{} is not available on this machine", manager); + } + + let manifest = crate::sync::packages::manifest_filename(manager) + .ok_or_else(|| anyhow::anyhow!("No manifest for {}", manager))?; + let repo_path = format!("manifests/{}", manifest); + + let sync_path = SyncEngine::sync_path()?; + let git = GitBackend::open(&sync_path)?; + let snapshot = git.show_at_commit(commit, &repo_path)?; + let snapshot = String::from_utf8_lossy(&snapshot); + let target: HashSet = snapshot + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(); + + let installed: HashSet = pkg_manager + .list_installed() + .await? + .into_iter() + .map(|p| p.name) + .collect(); + + let mut to_install: Vec<&String> = target.difference(&installed).collect(); + let mut to_uninstall: Vec<&String> = installed.difference(&target).collect(); + to_install.sort(); + to_uninstall.sort(); + + if to_install.is_empty() && to_uninstall.is_empty() { + Output::info(&format!("{} already matches that snapshot", manager)); + return Ok(()); + } + + Output::header(&format!("Rolling back {}", manager)); + for pkg in &to_uninstall { + Output::list_item(&format!("uninstall {}", pkg)); + } + for pkg in &to_install { + Output::list_item(&format!("install {}", pkg)); + } + + for pkg in &to_uninstall { + if let Err(e) = pkg_manager.uninstall(pkg).await { + Output::warning(&format!("Failed to uninstall {}: {}", pkg, e)); + } + } + if !to_install.is_empty() { + let manifest_text = to_install + .iter() + .map(|s| s.as_str()) + .collect::>() + .join("\n") + + "\n"; + if let Err(e) = pkg_manager.import_manifest(&manifest_text).await { + Output::warning(&format!("Some packages failed to install: {}", e)); + } + } + + Output::success("Rollback applied; syncing..."); + super::sync::run(false, false, false).await +} diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index 891d434..b2235a5 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -112,6 +112,14 @@ impl FilesTabState { } } +pub struct RollbackConfirm { + pub manager: String, + pub commit: String, + pub short_hash: String, + pub install: usize, + pub uninstall: usize, +} + pub struct PackagesTabState { pub cursor: usize, /// Manager whose installed-package list is expanded. @@ -122,6 +130,7 @@ pub struct PackagesTabState { /// History entry whose diff is expanded. pub history_commit: Option, pub history_diff: Vec, + pub rollback_confirm: Option, } impl PackagesTabState { @@ -133,6 +142,7 @@ impl PackagesTabState { history: Vec::new(), history_commit: None, history_diff: Vec::new(), + rollback_confirm: None, } } } @@ -203,6 +213,21 @@ impl App { } } + fn spawn_rollback(&mut self, manager: &str, commit: &str) { + if self.sync_child.is_some() { + return; + } + let exe = std::env::current_exe().unwrap_or_else(|_| "tether".into()); + if let Ok(child) = std::process::Command::new(exe) + .args(["rollback", "packages", manager, commit]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + self.sync_child = Some(child); + } + } + fn reload_state(&mut self) { self.state = DashboardState::load(); self.files.deleted = load_deleted_files(&self.state); @@ -517,6 +542,26 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { return; } + // Package rollback confirmation popup + if app.packages.rollback_confirm.is_some() { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + if let Some(rb) = app.packages.rollback_confirm.take() { + app.spawn_rollback(&rb.manager, &rb.commit); + app.flash_message = Some(( + Instant::now(), + format!("Rolling back {} to {}", rb.manager, rb.short_hash), + )); + } + } + KeyCode::Char('n') | KeyCode::Esc => { + app.packages.rollback_confirm = None; + } + _ => {} + } + return; + } + // File delete confirmation popup if app.file_delete_confirm.is_some() { match key.code { @@ -1167,6 +1212,29 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { } } } + } else if app.active_tab == Tab::Packages { + let rows = widgets::packages::build_rows(&app.state, &app.packages); + if let Some(widgets::packages::PkgRow::HistoryEntry { + commit_hash, + short_hash, + .. + }) = rows.get(app.packages.cursor) + { + let commit_hash = commit_hash.clone(); + let short_hash = short_hash.clone(); + if let Some(manager) = app.packages.history_manager.clone() { + if manager.starts_with("brew_") { + app.flash_error = Some(( + Instant::now(), + "Rollback for brew is not yet supported".to_string(), + )); + } else if let Some(confirm) = + build_rollback_confirm(&app.state, &manager, &commit_hash, &short_hash) + { + app.packages.rollback_confirm = Some(confirm); + } + } + } } } KeyCode::Char('x') => { @@ -1388,6 +1456,47 @@ fn load_pkg_history(manager_key: &str) -> Vec { .unwrap_or_default() } +/// Compute the install/uninstall counts for rolling a manager back to a commit. +fn build_rollback_confirm( + state: &DashboardState, + manager: &str, + commit: &str, + short_hash: &str, +) -> Option { + let manifest = crate::sync::packages::manifest_filename(manager)?; + let repo_path = format!("manifests/{}", manifest); + let sync_path = crate::sync::SyncEngine::sync_path().ok()?; + let git = crate::sync::GitBackend::open(&sync_path).ok()?; + let snapshot = git.show_at_commit(commit, &repo_path).ok()?; + let snapshot = String::from_utf8_lossy(&snapshot); + let target: HashSet<&str> = snapshot + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); + + let current_machine_id = state + .sync_state + .as_ref() + .map(|s| s.machine_id.as_str()) + .unwrap_or(""); + let installed: HashSet<&str> = state + .machines + .iter() + .find(|m| m.machine_id == current_machine_id) + .and_then(|m| m.packages.get(manager)) + .map(|v| v.iter().map(String::as_str).collect()) + .unwrap_or_default(); + + Some(RollbackConfirm { + manager: manager.to_string(), + commit: commit.to_string(), + short_hash: short_hash.to_string(), + install: target.difference(&installed).count(), + uninstall: installed.difference(&target).count(), + }) +} + /// Load the manifest diff for a manager at a commit. fn load_pkg_diff(manager_key: &str, commit: &str) -> Vec { let Some(manifest) = crate::sync::packages::manifest_filename(manager_key) else { @@ -1934,6 +2043,20 @@ fn draw(f: &mut Frame, app: &App) { ); } + // Package rollback confirmation popup + if let Some(ref rb) = app.packages.rollback_confirm { + let label = widgets::manager_label(&rb.manager); + render_confirm_popup( + f, + "Roll back packages", + &format!( + "Roll back {} to {} (+{} install, -{} uninstall)?", + label, rb.short_hash, rb.install, rb.uninstall + ), + Color::Yellow, + ); + } + // File delete confirmation popup if let Some(ref path) = app.file_delete_confirm { render_confirm_popup(