use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent};
use crate::state::{AppState, PackageItem, Source};
#[must_use]
pub(super) fn startup_setup_steps_in_priority(
selected: &std::collections::HashSet<crate::state::modal::StartupSetupTask>,
) -> VecDeque<crate::state::modal::StartupSetupTask> {
let mut steps = VecDeque::new();
let ordered = [
crate::state::modal::StartupSetupTask::OptionalDepsMissing,
crate::state::modal::StartupSetupTask::SudoTimestampSetup,
crate::state::modal::StartupSetupTask::DoasPersistSetup,
crate::state::modal::StartupSetupTask::SshAurSetup,
crate::state::modal::StartupSetupTask::AurSleuthSetup,
crate::state::modal::StartupSetupTask::VirusTotalSetup,
crate::state::modal::StartupSetupTask::ArchNews,
];
for step in ordered {
if selected.contains(&step) {
steps.push_back(step);
}
}
steps
}
pub(super) fn show_next_startup_setup_step(app: &mut AppState) {
while matches!(app.modal, crate::state::Modal::None) {
let Some(next_step) = app.pending_startup_setup_steps.pop_front() else {
show_next_pending_announcement(app);
return;
};
match next_step {
crate::state::modal::StartupSetupTask::ArchNews => {
let prefs = crate::theme::settings();
app.modal = crate::state::Modal::NewsSetup {
show_arch_news: prefs.startup_news_show_arch_news,
show_advisories: prefs.startup_news_show_advisories,
show_aur_updates: prefs.startup_news_show_aur_updates,
show_aur_comments: prefs.startup_news_show_aur_comments,
show_pkg_updates: prefs.startup_news_show_pkg_updates,
max_age_days: prefs.startup_news_max_age_days,
cursor: 0,
};
}
crate::state::modal::StartupSetupTask::OptionalDepsMissing => {
let rows: Vec<crate::state::types::OptionalDepRow> =
crate::events::mouse::menu_options::build_optional_deps_rows(app)
.into_iter()
.filter(|row| {
!row.installed
&& row.selectable
&& !matches!(
row.package.as_str(),
"aur-ssh-setup"
| "virustotal-setup"
| "aur-sleuth-setup"
| "sudo-timestamp-setup"
| "doas-persist-setup"
)
})
.collect();
if rows.is_empty() {
continue;
}
app.modal = crate::state::Modal::OptionalDeps {
rows,
selected: 0,
selected_pkg_names: std::collections::HashSet::new(),
};
}
crate::state::modal::StartupSetupTask::SshAurSetup => {
if app.aur_ssh_help_ready.unwrap_or(false) {
continue;
}
super::optional_deps::open_setup_package(app, "aur-ssh-setup");
}
crate::state::modal::StartupSetupTask::AurSleuthSetup => {
super::optional_deps::open_setup_package(app, "aur-sleuth-setup");
}
crate::state::modal::StartupSetupTask::VirusTotalSetup => {
super::optional_deps::open_setup_package(app, "virustotal-setup");
}
crate::state::modal::StartupSetupTask::SudoTimestampSetup => {
super::optional_deps::open_setup_package(app, "sudo-timestamp-setup");
}
crate::state::modal::StartupSetupTask::DoasPersistSetup => {
super::optional_deps::open_setup_package(app, "doas-persist-setup");
}
}
}
}
pub(super) fn show_next_pending_announcement(app: &mut AppState) {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
if !matches!(app.modal, crate::state::Modal::None) {
tracing::debug!("skipping pending announcement check (modal still open)");
return;
}
tracing::debug!(
queue_size = app.pending_announcements.len(),
"checking for pending announcements"
);
while let Some(announcement) = app.pending_announcements.first() {
if !crate::announcements::version_matches(
CURRENT_VERSION,
announcement.min_version.as_deref(),
announcement.max_version.as_deref(),
) {
tracing::debug!(
id = %announcement.id,
"pending announcement version range mismatch, removing from queue"
);
app.pending_announcements.remove(0);
continue;
}
if crate::announcements::is_expired(announcement.expires.as_deref()) {
tracing::debug!(
id = %announcement.id,
"pending announcement expired, removing from queue"
);
app.pending_announcements.remove(0);
continue;
}
if app.announcements_read_ids.contains(&announcement.id) {
tracing::debug!(
id = %announcement.id,
"pending announcement already read, removing from queue"
);
app.pending_announcements.remove(0);
continue;
}
let announcement = app.pending_announcements.remove(0);
let announcement_id = announcement.id.clone();
app.modal = crate::state::Modal::Announcement {
title: announcement.title,
content: announcement.content,
id: announcement_id.clone(),
scroll: 0,
};
tracing::info!(id = %announcement_id, "showing pending announcement");
return;
}
tracing::debug!(
queue_empty = app.pending_announcements.is_empty(),
"no more pending announcements"
);
tracing::debug!(
pending_news_exists = app.pending_news.is_some(),
news_loading = app.news_loading,
"startup pending news auto-popup disabled"
);
}
pub(super) fn handle_alert(ke: KeyEvent, app: &mut AppState, message: &str) -> bool {
let is_help = message.contains("Help") || message.contains("Tab Help");
let is_lockout = message.contains("locked") || message.contains("lockout");
match ke.code {
KeyCode::Esc => {
if is_help {
app.help_scroll = 0; }
if is_lockout {
app.pending_executor_password = None;
app.pending_exec_header_chips = None;
app.pending_executor_request = None;
}
if let Some(prev_modal) = app.previous_modal.take() {
app.modal = prev_modal;
} else {
app.modal = crate::state::Modal::None;
super::repositories::reopen_repositories_modal_if_pending(app);
}
true }
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
if is_help {
app.help_scroll = 0; }
if is_lockout {
app.pending_executor_password = None;
app.pending_exec_header_chips = None;
app.pending_executor_request = None;
}
if let Some(prev_modal) = app.previous_modal.take() {
app.modal = prev_modal;
} else {
app.modal = crate::state::Modal::None;
super::repositories::reopen_repositories_modal_if_pending(app);
}
is_lockout
}
KeyCode::Up if is_help => {
app.help_scroll = app.help_scroll.saturating_sub(1);
false
}
KeyCode::Down if is_help => {
app.help_scroll = app.help_scroll.saturating_add(1);
false
}
_ => false,
}
}
fn consume_repo_apply_overlap_on_preflight_exec_enter(
app: &mut AppState,
success: Option<bool>,
items: &[PackageItem],
) -> bool {
if success != Some(true) || !items.is_empty() {
return false;
}
if !crate::logic::repos::repositories_linux_actions_supported() {
return false;
}
let Some(pending) = app.pending_repo_apply_overlap_check.take() else {
return false;
};
match crate::logic::repos::analyze_foreign_repo_overlap_with_qm_snapshot(
&pending.repo_section,
pending.pre_apply_foreign_snapshot.as_deref(),
) {
Err(msg) => {
app.modal = crate::state::Modal::Alert { message: msg };
true
}
Ok(analysis) if analysis.entries.is_empty() => {
if analysis.foreign_pkg_count > 0 {
if analysis.sync_pkg_name_count == 0 {
app.toast_message = Some(crate::i18n::t_fmt1(
app,
"app.toasts.repo_overlap_no_sync_list",
&pending.repo_section,
));
} else {
app.toast_message = Some(crate::i18n::t_fmt1(
app,
"app.toasts.repo_overlap_no_matching_names",
&pending.repo_section,
));
}
app.toast_expires_at = Some(Instant::now() + Duration::from_secs(10));
}
false
}
Ok(analysis) => {
let repo_name = pending.repo_section;
let entries: Vec<_> = analysis
.entries
.into_iter()
.map(|e| (e.name, e.version))
.collect();
app.modal = crate::state::Modal::ForeignRepoOverlap {
repo_name,
entries,
phase: crate::state::modal::ForeignRepoOverlapPhase::WarnAck {
step: 0,
list_scroll: 0,
},
};
true
}
}
}
pub(super) fn handle_preflight_exec(
ke: KeyEvent,
app: &mut AppState,
verbose: &mut bool,
abortable: bool,
items: &[crate::state::PackageItem],
success: Option<bool>,
) -> bool {
match ke.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.pending_repo_apply_overlap_check = None;
let reopen_ok = success == Some(true);
if !reopen_ok {
app.pending_repositories_modal_resume = None;
}
app.modal = crate::state::Modal::None;
if success == Some(false) && !app.pending_startup_setup_steps.is_empty() {
show_next_startup_setup_step(app);
return true;
}
if reopen_ok {
super::repositories::reopen_repositories_modal_if_pending(app);
}
true }
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
if consume_repo_apply_overlap_on_preflight_exec_enter(app, success, items) {
return true;
}
let is_scan = items.iter().any(|item| item.name.starts_with("scan:"));
if is_scan {
if let Some(prev_modal) = app.previous_modal.take() {
if matches!(prev_modal, crate::state::Modal::Preflight { .. }) {
app.modal = prev_modal;
return true; }
app.previous_modal = Some(prev_modal);
}
app.modal = crate::state::Modal::None;
return true; }
app.pending_post_summary_items = Some((items.to_vec(), success));
app.modal = crate::state::Modal::Loading {
message: "Computing summary...".to_string(),
};
true }
KeyCode::Char('l') => {
*verbose = !*verbose;
let verbose_status = if *verbose { "ON" } else { "OFF" };
app.toast_message = Some(format!("Verbose: {verbose_status}"));
false
}
KeyCode::Char('x') => {
if abortable {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.abort_requested"));
}
false
}
_ => false,
}
}
pub(super) fn handle_post_summary(
ke: KeyEvent,
app: &mut AppState,
services_pending: &[String],
) -> bool {
match ke.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q' | '\n' | '\r') => {
app.modal = crate::state::Modal::None;
if !app.pending_startup_setup_steps.is_empty() {
show_next_startup_setup_step(app);
return true;
}
super::repositories::reopen_repositories_modal_if_pending(app);
true }
KeyCode::Char('r') => {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.rollback"));
false
}
KeyCode::Char('s') => {
if services_pending.is_empty() {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.no_services_to_restart"));
} else {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.restart_services"));
}
false
}
_ => false,
}
}
pub(super) fn handle_help(ke: KeyEvent, app: &mut AppState) -> bool {
match ke.code {
KeyCode::Esc => {
app.modal = crate::state::Modal::None;
true }
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
app.modal = crate::state::Modal::None;
false
}
_ => false,
}
}
#[cfg_attr(test, allow(dead_code))]
fn calculate_news_scroll_for_selection(
selected: usize,
total_items: usize,
visible_height: u16,
) -> u16 {
if total_items == 0 || visible_height == 0 {
return 0;
}
let selected_line = u16::try_from(selected).unwrap_or(u16::MAX);
let total_lines = u16::try_from(total_items).unwrap_or(u16::MAX);
let selected_line = selected_line.min(total_lines);
let middle_offset = visible_height / 2;
let desired_scroll = selected_line.saturating_sub(middle_offset);
let max_scroll = total_lines.saturating_sub(visible_height);
desired_scroll.min(max_scroll)
}
#[cfg(test)]
mod news_tests {
use super::*;
use crate::state::{AppState, types::NewsFeedItem, types::NewsFeedSource};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn test_calculate_news_scroll_for_selection() {
let scroll = calculate_news_scroll_for_selection(5, 10, 5);
assert!(scroll <= 5, "Scroll should not exceed max");
let scroll = calculate_news_scroll_for_selection(0, 10, 5);
assert_eq!(scroll, 0, "First item should have scroll 0");
let scroll = calculate_news_scroll_for_selection(0, 0, 5);
assert_eq!(scroll, 0, "Empty list should return 0");
let scroll = calculate_news_scroll_for_selection(5, 10, 0);
assert_eq!(scroll, 0, "Zero height should return 0");
}
#[test]
#[allow(clippy::field_reassign_with_default)] fn test_handle_news_mark_read() {
let mut app = AppState::default();
app.search_normal_mode = true; app.keymap.news_mark_read = [crate::theme::KeyChord {
code: KeyCode::Char('r'),
mods: KeyModifiers::empty(),
}]
.into();
let items = vec![NewsFeedItem {
id: "test-id-1".to_string(),
date: "2025-01-01".to_string(),
title: "Test News".to_string(),
summary: None,
url: Some("https://example.com/news/1".to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
}];
let mut selected = 0;
let mut scroll = 0;
let mut ke = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::empty());
ke.kind = crossterm::event::KeyEventKind::Press;
let _ = handle_news(ke, &mut app, &items, &mut selected, &mut scroll);
assert!(app.news_read_ids.contains("test-id-1"));
assert!(app.news_read_urls.contains("https://example.com/news/1"));
assert!(app.news_read_ids_dirty);
assert!(app.news_read_dirty);
}
#[test]
#[allow(clippy::field_reassign_with_default)] fn test_handle_news_mark_all_read() {
let mut app = AppState::default();
app.search_normal_mode = true; app.keymap.news_mark_all_read = [crate::theme::KeyChord {
code: KeyCode::Char('r'),
mods: KeyModifiers::CONTROL,
}]
.into();
let items = vec![
NewsFeedItem {
id: "test-id-1".to_string(),
date: "2025-01-01".to_string(),
title: "Test News 1".to_string(),
summary: None,
url: Some("https://example.com/news/1".to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
},
NewsFeedItem {
id: "test-id-2".to_string(),
date: "2025-01-02".to_string(),
title: "Test News 2".to_string(),
summary: None,
url: Some("https://example.com/news/2".to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
},
];
let mut selected = 0;
let mut scroll = 0;
let mut ke = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
ke.kind = crossterm::event::KeyEventKind::Press;
let _ = handle_news(ke, &mut app, &items, &mut selected, &mut scroll);
assert!(app.news_read_ids.contains("test-id-1"));
assert!(app.news_read_ids.contains("test-id-2"));
assert!(app.news_read_urls.contains("https://example.com/news/1"));
assert!(app.news_read_urls.contains("https://example.com/news/2"));
assert!(app.news_read_ids_dirty);
assert!(app.news_read_dirty);
}
#[test]
#[allow(clippy::field_reassign_with_default)] fn test_handle_news_navigation() {
let mut app = AppState::default();
app.news_list_rect = Some((0, 0, 50, 10));
let items = vec![
NewsFeedItem {
id: "test-id-1".to_string(),
date: "2025-01-01".to_string(),
title: "Test News 1".to_string(),
summary: None,
url: None,
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
},
NewsFeedItem {
id: "test-id-2".to_string(),
date: "2025-01-02".to_string(),
title: "Test News 2".to_string(),
summary: None,
url: None,
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
},
];
let mut selected = 0;
let mut scroll = 0;
let mut ke = KeyEvent::new(KeyCode::Down, KeyModifiers::empty());
ke.kind = crossterm::event::KeyEventKind::Press;
let _ = handle_news(ke, &mut app, &items, &mut selected, &mut scroll);
assert_eq!(selected, 1, "Down should increment selection");
let mut ke = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
ke.kind = crossterm::event::KeyEventKind::Press;
let _ = handle_news(ke, &mut app, &items, &mut selected, &mut scroll);
assert_eq!(selected, 0, "Up should decrement selection");
}
#[test]
#[allow(clippy::field_reassign_with_default)] fn test_handle_news_mark_read_insert_mode() {
let mut app = AppState::default();
app.search_normal_mode = false; app.keymap.news_mark_read = [crate::theme::KeyChord {
code: KeyCode::Char('r'),
mods: KeyModifiers::empty(),
}]
.into();
let items = vec![NewsFeedItem {
id: "test-id-1".to_string(),
date: "2025-01-01".to_string(),
title: "Test News".to_string(),
summary: None,
url: Some("https://example.com/news/1".to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
}];
let mut selected = 0;
let mut scroll = 0;
let mut ke = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::empty());
ke.kind = crossterm::event::KeyEventKind::Press;
let _ = handle_news(ke, &mut app, &items, &mut selected, &mut scroll);
assert!(!app.news_read_ids.contains("test-id-1"));
assert!(!app.news_read_urls.contains("https://example.com/news/1"));
assert!(!app.news_read_ids_dirty);
assert!(!app.news_read_dirty);
}
#[test]
#[allow(clippy::field_reassign_with_default)] fn test_handle_news_mark_all_read_insert_mode() {
let mut app = AppState::default();
app.search_normal_mode = false; app.keymap.news_mark_all_read = [crate::theme::KeyChord {
code: KeyCode::Char('r'),
mods: KeyModifiers::CONTROL,
}]
.into();
let items = vec![
NewsFeedItem {
id: "test-id-1".to_string(),
date: "2025-01-01".to_string(),
title: "Test News 1".to_string(),
summary: None,
url: Some("https://example.com/news/1".to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
},
NewsFeedItem {
id: "test-id-2".to_string(),
date: "2025-01-02".to_string(),
title: "Test News 2".to_string(),
summary: None,
url: Some("https://example.com/news/2".to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
},
];
let mut selected = 0;
let mut scroll = 0;
let mut ke = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
ke.kind = crossterm::event::KeyEventKind::Press;
let _ = handle_news(ke, &mut app, &items, &mut selected, &mut scroll);
assert!(!app.news_read_ids.contains("test-id-1"));
assert!(!app.news_read_ids.contains("test-id-2"));
assert!(!app.news_read_urls.contains("https://example.com/news/1"));
assert!(!app.news_read_urls.contains("https://example.com/news/2"));
assert!(!app.news_read_ids_dirty);
assert!(!app.news_read_dirty);
}
}
pub(super) fn handle_news(
ke: KeyEvent,
app: &mut AppState,
items: &[crate::state::types::NewsFeedItem],
selected: &mut usize,
scroll: &mut u16,
) -> bool {
let km = &app.keymap;
if crate::events::utils::matches_any(&ke, &km.news_mark_read) {
if !app.search_normal_mode {
return false;
}
if let Some(it) = items.get(*selected) {
app.news_read_ids.insert(it.id.clone());
app.news_read_ids_dirty = true;
if let Some(url) = &it.url {
app.news_read_urls.insert(url.clone());
app.news_read_dirty = true;
}
}
return false;
}
if crate::events::utils::matches_any(&ke, &km.news_mark_all_read) {
if !app.search_normal_mode {
return false;
}
for it in items {
app.news_read_ids.insert(it.id.clone());
if let Some(url) = &it.url {
app.news_read_urls.insert(url.clone());
}
}
app.news_read_ids_dirty = true;
app.news_read_dirty = true;
return false;
}
match ke.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.modal = crate::state::Modal::None;
return true; }
KeyCode::Up | KeyCode::Char('k') => {
if *selected > 0 {
*selected -= 1;
if let Some((_, _, _, visible_h)) = app.news_list_rect {
*scroll =
calculate_news_scroll_for_selection(*selected, items.len(), visible_h);
}
}
}
KeyCode::Down | KeyCode::Char('j') => {
if *selected + 1 < items.len() {
*selected += 1;
if let Some((_, _, _, visible_h)) = app.news_list_rect {
*scroll =
calculate_news_scroll_for_selection(*selected, items.len(), visible_h);
}
}
}
KeyCode::PageUp => {
if *selected >= 10 {
*selected -= 10;
} else {
*selected = 0;
}
if let Some((_, _, _, visible_h)) = app.news_list_rect {
*scroll = calculate_news_scroll_for_selection(*selected, items.len(), visible_h);
}
}
KeyCode::PageDown => {
let max_idx = items.len().saturating_sub(1);
*selected = (*selected + 10).min(max_idx);
if let Some((_, _, _, visible_h)) = app.news_list_rect {
*scroll = calculate_news_scroll_for_selection(*selected, items.len(), visible_h);
}
}
KeyCode::Char('d')
if ke
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
let max_idx = items.len().saturating_sub(1);
*selected = (*selected + 25).min(max_idx);
if let Some((_, _, _, visible_h)) = app.news_list_rect {
*scroll = calculate_news_scroll_for_selection(*selected, items.len(), visible_h);
}
}
KeyCode::Char('u')
if ke
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
if *selected >= 20 {
*selected -= 20;
} else {
*selected = 0;
}
if let Some((_, _, _, visible_h)) = app.news_list_rect {
*scroll = calculate_news_scroll_for_selection(*selected, items.len(), visible_h);
}
}
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
if let Some(it) = items.get(*selected)
&& let Some(url) = &it.url
{
crate::util::open_url(url);
}
}
_ => {}
}
false
}
pub(super) fn handle_announcement(
ke: crossterm::event::KeyEvent,
app: &mut AppState,
id: &str,
scroll: &mut u16,
) -> bool {
match ke.code {
crossterm::event::KeyCode::Char('r') => {
app.announcements_read_ids.insert(id.to_string());
app.announcement_dirty = true;
tracing::debug!(id = %id, "marked announcement as read, closing modal");
app.modal = crate::state::Modal::None;
show_next_pending_announcement(app);
return true; }
crossterm::event::KeyCode::Enter
| crossterm::event::KeyCode::Char('\n' | '\r')
| crossterm::event::KeyCode::Esc => {
tracing::debug!(id = %id, "dismissed announcement temporarily, closing modal");
app.modal = crate::state::Modal::None;
show_next_pending_announcement(app);
return true; }
crossterm::event::KeyCode::Char('q') => {
tracing::debug!(id = %id, "dismissed announcement temporarily, closing modal");
app.modal = crate::state::Modal::None;
show_next_pending_announcement(app);
return true; }
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
if *scroll > 0 {
*scroll -= 1;
}
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
*scroll = scroll.saturating_add(1);
}
crossterm::event::KeyCode::PageUp => {
*scroll = scroll.saturating_sub(10);
}
crossterm::event::KeyCode::PageDown => {
*scroll = scroll.saturating_add(10);
}
_ => {}
}
false
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)] #[allow(clippy::cognitive_complexity)] pub(super) fn handle_updates(
ke: KeyEvent,
app: &mut AppState,
entries: &[(String, String, String)],
scroll: &mut u16,
selected: &mut usize,
filter_active: &mut bool,
filter_query: &mut String,
filter_caret: &mut usize,
last_selected_pkg_name: &mut Option<String>,
filtered_indices: &mut Vec<usize>,
selected_pkg_names: &mut std::collections::HashSet<String>,
) -> bool {
let sync_scroll =
|app: &AppState, scroll: &mut u16, selected_original: usize, filtered: &[usize]| {
let visible_selected =
updates_visible_index_for_selected(selected_original, filtered).unwrap_or(0);
let visible_count = filtered.len();
if visible_count == 0 {
*scroll = 0;
return;
}
*scroll = crate::events::utils::compute_updates_modal_scroll_for_selection(
&app.updates_modal_entry_line_starts,
app.updates_modal_total_lines,
app.updates_modal_content_rect,
visible_selected,
visible_count,
*scroll,
);
};
if filtered_indices.is_empty() && (!*filter_active || filter_query.trim().is_empty()) {
*filtered_indices = (0..entries.len()).collect();
}
if *selected >= entries.len() {
*selected = entries.len().saturating_sub(1);
}
if let Some((name, _, _)) = entries.get(*selected) {
*last_selected_pkg_name = Some(name.clone());
}
if *filter_active {
if handle_updates_filter_editing(
ke,
entries,
scroll,
selected,
filter_active,
filter_query,
filter_caret,
last_selected_pkg_name,
filtered_indices,
selected_pkg_names,
&sync_scroll,
app,
) {
return false;
}
} else if matches!(ke.code, KeyCode::Char('/')) {
*filter_active = true;
*filter_caret = crate::events::utils::char_count(filter_query);
return false;
}
let plain_lower_g = matches!(ke.code, KeyCode::Char('g')) && ke.modifiers.is_empty();
if !plain_lower_g {
clear_updates_pending_g(app);
}
match ke.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.modal = crate::state::Modal::None;
return true; }
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
let selected_items = collect_selected_update_items(entries, selected_pkg_names);
if !selected_items.is_empty() {
app.modal = crate::state::Modal::None;
crate::events::search::open_preflight_modal(app, selected_items, true);
return true; }
if let Some((pkg_name, _, new_version)) = entries.get(*selected) {
let pkg_item = package_item_for_update_entry(pkg_name, new_version);
app.modal = crate::state::Modal::None;
crate::events::search::open_preflight_modal(app, vec![pkg_item], true);
}
return true; }
KeyCode::Char(' ') => {
if let Some((name, _, _)) = entries.get(*selected)
&& !selected_pkg_names.remove(name)
{
selected_pkg_names.insert(name.clone());
}
}
KeyCode::Char('a') if ke.modifiers.is_empty() => {
for &idx in filtered_indices.iter() {
if let Some((name, _, _)) = entries.get(idx) {
selected_pkg_names.insert(name.clone());
}
}
}
KeyCode::Home => {
*selected = filtered_indices.first().copied().unwrap_or(0);
sync_scroll(app, scroll, *selected, filtered_indices);
}
KeyCode::End | KeyCode::Char('G') => {
*selected = filtered_indices.last().copied().unwrap_or(0);
sync_scroll(app, scroll, *selected, filtered_indices);
}
KeyCode::Char('g') if ke.modifiers.is_empty() => {
let now = Instant::now();
if is_updates_pending_g_active(app, now) {
clear_updates_pending_g(app);
*selected = filtered_indices.first().copied().unwrap_or(0);
sync_scroll(app, scroll, *selected, filtered_indices);
} else {
app.updates_modal_pending_g_at = Some(now);
}
}
KeyCode::Up | KeyCode::Char('k') => {
if let Some(visible) = updates_visible_index_for_selected(*selected, filtered_indices)
&& visible > 0
{
*selected = filtered_indices[visible - 1];
sync_scroll(app, scroll, *selected, filtered_indices);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(visible) = updates_visible_index_for_selected(*selected, filtered_indices)
&& visible + 1 < filtered_indices.len()
{
*selected = filtered_indices[visible + 1];
sync_scroll(app, scroll, *selected, filtered_indices);
}
}
KeyCode::PageUp => {
if let Some(visible) = updates_visible_index_for_selected(*selected, filtered_indices) {
let next_visible = visible.saturating_sub(10);
*selected = filtered_indices[next_visible];
sync_scroll(app, scroll, *selected, filtered_indices);
}
}
KeyCode::PageDown => {
if let Some(visible) = updates_visible_index_for_selected(*selected, filtered_indices) {
let max_visible = filtered_indices.len().saturating_sub(1);
let next_visible = (visible + 10).min(max_visible);
*selected = filtered_indices[next_visible];
sync_scroll(app, scroll, *selected, filtered_indices);
}
}
KeyCode::Char('d')
if ke
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
if let Some(visible) = updates_visible_index_for_selected(*selected, filtered_indices) {
let max_visible = filtered_indices.len().saturating_sub(1);
let next_visible = (visible + 25).min(max_visible);
*selected = filtered_indices[next_visible];
sync_scroll(app, scroll, *selected, filtered_indices);
}
}
KeyCode::Char('u')
if ke
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
if let Some(visible) = updates_visible_index_for_selected(*selected, filtered_indices) {
let next_visible = visible.saturating_sub(20);
*selected = filtered_indices[next_visible];
sync_scroll(app, scroll, *selected, filtered_indices);
}
}
_ => {}
}
if let Some((name, _, _)) = entries.get(*selected) {
*last_selected_pkg_name = Some(name.clone());
}
false
}
fn package_item_for_update_entry(pkg_name: &str, new_version: &str) -> PackageItem {
if let Some(mut pkg_item) = crate::index::find_package_by_name(pkg_name) {
pkg_item.version = new_version.to_string();
pkg_item
} else {
PackageItem {
name: pkg_name.to_string(),
version: new_version.to_string(),
description: String::new(),
source: Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
}
}
}
fn collect_selected_update_items(
entries: &[(String, String, String)],
selected_pkg_names: &std::collections::HashSet<String>,
) -> Vec<PackageItem> {
entries
.iter()
.filter(|(name, _, _)| selected_pkg_names.contains(name))
.map(|(name, _, new_version)| package_item_for_update_entry(name, new_version))
.collect()
}
fn updates_visible_index_for_selected(
selected: usize,
filtered_indices: &[usize],
) -> Option<usize> {
filtered_indices.iter().position(|&idx| idx == selected)
}
#[allow(clippy::too_many_arguments)]
fn recompute_updates_filter_state(
entries: &[(String, String, String)],
scroll: &mut u16,
selected: &mut usize,
filter_query: &str,
last_selected_pkg_name: &mut Option<String>,
filtered_indices: &mut Vec<usize>,
sync_scroll: &impl Fn(&AppState, &mut u16, usize, &[usize]),
app: &AppState,
) {
let previous_visible =
updates_visible_index_for_selected(*selected, filtered_indices).unwrap_or(0);
*filtered_indices =
crate::events::utils::compute_updates_filtered_indices(entries, filter_query);
if filtered_indices.is_empty() {
*scroll = 0;
return;
}
let restored_by_name = last_selected_pkg_name.as_ref().and_then(|name| {
filtered_indices.iter().copied().find(|&original_idx| {
entries
.get(original_idx)
.is_some_and(|(entry_name, _, _)| entry_name == name)
})
});
let fallback_visible = previous_visible.min(filtered_indices.len().saturating_sub(1));
*selected = restored_by_name.unwrap_or(filtered_indices[fallback_visible]);
if let Some((name, _, _)) = entries.get(*selected) {
*last_selected_pkg_name = Some(name.clone());
}
sync_scroll(app, scroll, *selected, filtered_indices);
}
#[allow(clippy::too_many_arguments)]
fn handle_updates_filter_editing(
ke: KeyEvent,
entries: &[(String, String, String)],
scroll: &mut u16,
selected: &mut usize,
filter_active: &mut bool,
filter_query: &mut String,
filter_caret: &mut usize,
last_selected_pkg_name: &mut Option<String>,
filtered_indices: &mut Vec<usize>,
selected_pkg_names: &mut std::collections::HashSet<String>,
sync_scroll: &impl Fn(&AppState, &mut u16, usize, &[usize]),
app: &AppState,
) -> bool {
match ke.code {
KeyCode::Esc => {
filter_query.clear();
*filter_caret = 0;
*filter_active = false;
*filtered_indices = (0..entries.len()).collect();
recompute_updates_filter_state(
entries,
scroll,
selected,
filter_query,
last_selected_pkg_name,
filtered_indices,
sync_scroll,
app,
);
true
}
KeyCode::Left => {
*filter_caret = filter_caret.saturating_sub(1);
true
}
KeyCode::Right => {
let chars = crate::events::utils::char_count(filter_query);
*filter_caret = (*filter_caret + 1).min(chars);
true
}
KeyCode::Home => {
*filter_caret = 0;
true
}
KeyCode::End => {
*filter_caret = crate::events::utils::char_count(filter_query);
true
}
KeyCode::Backspace => {
if *filter_caret > 0 {
let start_ci = filter_caret.saturating_sub(1);
let start_b = crate::events::utils::byte_index_for_char(filter_query, start_ci);
let end_b = crate::events::utils::byte_index_for_char(filter_query, *filter_caret);
filter_query.replace_range(start_b..end_b, "");
*filter_caret = start_ci;
recompute_updates_filter_state(
entries,
scroll,
selected,
filter_query,
last_selected_pkg_name,
filtered_indices,
sync_scroll,
app,
);
}
true
}
KeyCode::Delete => {
let chars = crate::events::utils::char_count(filter_query);
if *filter_caret < chars {
let start_b =
crate::events::utils::byte_index_for_char(filter_query, *filter_caret);
let end_b =
crate::events::utils::byte_index_for_char(filter_query, *filter_caret + 1);
filter_query.replace_range(start_b..end_b, "");
recompute_updates_filter_state(
entries,
scroll,
selected,
filter_query,
last_selected_pkg_name,
filtered_indices,
sync_scroll,
app,
);
}
true
}
KeyCode::Char(' ') => {
if let Some((name, _, _)) = entries.get(*selected)
&& !selected_pkg_names.remove(name)
{
selected_pkg_names.insert(name.clone());
}
true
}
KeyCode::Char(ch)
if ke.modifiers.is_empty() || ke.modifiers == crossterm::event::KeyModifiers::SHIFT =>
{
let insert_at = crate::events::utils::byte_index_for_char(filter_query, *filter_caret);
filter_query.insert(insert_at, ch);
*filter_caret += 1;
recompute_updates_filter_state(
entries,
scroll,
selected,
filter_query,
last_selected_pkg_name,
filtered_indices,
sync_scroll,
app,
);
true
}
KeyCode::Enter | KeyCode::Tab => true,
KeyCode::Char('k' | 'j' | 'g' | 'G')
if ke
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
true
}
_ => false,
}
}
const UPDATES_GG_TIMEOUT: Duration = Duration::from_millis(500);
const fn clear_updates_pending_g(app: &mut AppState) {
app.updates_modal_pending_g_at = None;
}
fn is_updates_pending_g_active(app: &AppState, now: Instant) -> bool {
app.updates_modal_pending_g_at
.is_some_and(|pending| now.saturating_duration_since(pending) <= UPDATES_GG_TIMEOUT)
}
pub(super) fn handle_gnome_terminal_prompt(ke: KeyEvent, app: &mut AppState) -> bool {
match ke.code {
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
let bin = match crate::logic::privilege::active_tool() {
Ok(tool) => tool.binary_name(),
Err(msg) => {
app.toast_message = Some(msg);
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(8));
app.modal = crate::state::Modal::None;
return false;
}
};
let cmd = format!(
"({bin} pacman -S --needed --noconfirm gnome-terminal) || ({bin} pacman -S --needed --noconfirm gnome-console) || ({bin} pacman -S --needed --noconfirm kgx)"
);
if app.dry_run {
use crate::install::shell_single_quote;
let quoted = shell_single_quote(&cmd);
crate::install::spawn_shell_commands_in_terminal(&[format!(
"echo DRY RUN: {quoted}"
)]);
} else {
crate::install::spawn_shell_commands_in_terminal(&[cmd]);
}
app.modal = crate::state::Modal::None;
}
KeyCode::Esc => {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.gnome_terminal_warning"));
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(6));
app.modal = crate::state::Modal::None;
}
_ => {}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::announcements::RemoteAnnouncement;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
fn test_key_event(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::empty(),
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::empty(),
}
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_show_next_pending_announcement_shows_valid() {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::None;
let announcement = RemoteAnnouncement {
id: "test-1".to_string(),
title: "Test Title".to_string(),
content: "Test content".to_string(),
min_version: None,
max_version: None,
expires: None,
};
app.pending_announcements.push(announcement);
show_next_pending_announcement(&mut app);
match &app.modal {
crate::state::Modal::Announcement { id, title, .. } => {
assert_eq!(id, "test-1");
assert_eq!(title, "Test Title");
}
_ => panic!("Expected Announcement modal"),
}
assert!(app.pending_announcements.is_empty());
}
#[test]
fn test_show_next_startup_setup_step_skips_ssh_step_when_ready() {
let mut app = crate::state::AppState {
aur_ssh_help_ready: Some(true),
pending_startup_setup_steps: std::collections::VecDeque::from([
crate::state::modal::StartupSetupTask::SshAurSetup,
crate::state::modal::StartupSetupTask::ArchNews,
]),
..Default::default()
};
show_next_startup_setup_step(&mut app);
assert!(matches!(app.modal, crate::state::Modal::NewsSetup { .. }));
assert!(app.pending_startup_setup_steps.is_empty());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_show_next_pending_announcement_skips_expired() {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::None;
let expired = RemoteAnnouncement {
id: "expired-1".to_string(),
title: "Expired".to_string(),
content: "Content".to_string(),
min_version: None,
max_version: None,
expires: Some("2020-01-01".to_string()), };
app.pending_announcements.push(expired);
let valid = RemoteAnnouncement {
id: "valid-1".to_string(),
title: "Valid".to_string(),
content: "Content".to_string(),
min_version: None,
max_version: None,
expires: None,
};
app.pending_announcements.push(valid);
show_next_pending_announcement(&mut app);
match &app.modal {
crate::state::Modal::Announcement { id, .. } => {
assert_eq!(id, "valid-1");
}
_ => panic!("Expected Announcement modal"),
}
assert!(app.pending_announcements.is_empty());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_show_next_pending_announcement_skips_read() {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::None;
let read = RemoteAnnouncement {
id: "read-1".to_string(),
title: "Read".to_string(),
content: "Content".to_string(),
min_version: None,
max_version: None,
expires: None,
};
app.announcements_read_ids.insert("read-1".to_string());
app.pending_announcements.push(read);
let unread = RemoteAnnouncement {
id: "unread-1".to_string(),
title: "Unread".to_string(),
content: "Content".to_string(),
min_version: None,
max_version: None,
expires: None,
};
app.pending_announcements.push(unread);
show_next_pending_announcement(&mut app);
match &app.modal {
crate::state::Modal::Announcement { id, .. } => {
assert_eq!(id, "unread-1");
}
_ => panic!("Expected Announcement modal"),
}
assert!(app.pending_announcements.is_empty());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_show_next_pending_announcement_skips_version_mismatch() {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::None;
let mismatched = RemoteAnnouncement {
id: "mismatch-1".to_string(),
title: "Mismatch".to_string(),
content: "Content".to_string(),
min_version: Some("999.0.0".to_string()), max_version: None,
expires: None,
};
app.pending_announcements.push(mismatched);
let valid = RemoteAnnouncement {
id: "valid-1".to_string(),
title: "Valid".to_string(),
content: "Content".to_string(),
min_version: None,
max_version: None,
expires: None,
};
app.pending_announcements.push(valid);
show_next_pending_announcement(&mut app);
match &app.modal {
crate::state::Modal::Announcement { id, .. } => {
assert_eq!(id, "valid-1");
}
_ => panic!("Expected Announcement modal"),
}
assert!(app.pending_announcements.is_empty());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_show_next_pending_announcement_does_not_auto_show_news() {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::None;
let news_items = vec![crate::state::NewsItem {
date: "2025-01-01".to_string(),
title: "Test News".to_string(),
url: "https://example.com/news".to_string(),
}];
app.pending_news = Some(news_items);
show_next_pending_announcement(&mut app);
assert!(
matches!(app.modal, crate::state::Modal::None),
"startup flow must not auto-open News modal"
);
assert!(app.pending_news.is_some());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_handle_announcement_mark_read() {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::Announcement {
title: "Test".to_string(),
content: "Content".to_string(),
id: "test-1".to_string(),
scroll: 0,
};
let mut scroll = 0u16;
let ke = test_key_event(KeyCode::Char('r'));
let result = handle_announcement(ke, &mut app, "test-1", &mut scroll);
assert!(result); assert!(app.announcements_read_ids.contains("test-1"));
assert!(app.announcement_dirty);
assert!(matches!(app.modal, crate::state::Modal::None));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_handle_announcement_dismiss_temporary() {
for key_code in [
KeyCode::Enter,
KeyCode::Char('\n'),
KeyCode::Char('\r'),
KeyCode::Esc,
KeyCode::Char('q'),
] {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::Announcement {
title: "Test".to_string(),
content: "Content".to_string(),
id: "test-1".to_string(),
scroll: 0,
};
let mut scroll = 0u16;
let ke = test_key_event(key_code);
let result = handle_announcement(ke, &mut app, "test-1", &mut scroll);
assert!(result); assert!(!app.announcements_read_ids.contains("test-1")); assert!(matches!(app.modal, crate::state::Modal::None));
}
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_handle_announcement_scroll() {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::Announcement {
title: "Test".to_string(),
content: "Content".to_string(),
id: "test-1".to_string(),
scroll: 5,
};
let mut scroll = 5u16;
let ke_up = test_key_event(KeyCode::Up);
handle_announcement(ke_up, &mut app, "test-1", &mut scroll);
assert_eq!(scroll, 4);
let ke_k = test_key_event(KeyCode::Char('k'));
handle_announcement(ke_k, &mut app, "test-1", &mut scroll);
assert_eq!(scroll, 3);
scroll = 0;
let ke_up_zero = test_key_event(KeyCode::Up);
handle_announcement(ke_up_zero, &mut app, "test-1", &mut scroll);
assert_eq!(scroll, 0);
scroll = 0;
let ke_down = test_key_event(KeyCode::Down);
handle_announcement(ke_down, &mut app, "test-1", &mut scroll);
assert_eq!(scroll, 1);
let ke_j = test_key_event(KeyCode::Char('j'));
handle_announcement(ke_j, &mut app, "test-1", &mut scroll);
assert_eq!(scroll, 2);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_handle_announcement_page_scroll() {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::Announcement {
title: "Test".to_string(),
content: "Content".to_string(),
id: "test-1".to_string(),
scroll: 20,
};
let mut scroll = 20u16;
let ke_page_up = test_key_event(KeyCode::PageUp);
handle_announcement(ke_page_up, &mut app, "test-1", &mut scroll);
assert_eq!(scroll, 10);
let ke_page_down = test_key_event(KeyCode::PageDown);
handle_announcement(ke_page_down, &mut app, "test-1", &mut scroll);
assert_eq!(scroll, 20);
scroll = 5;
let ke_page_up_saturate = test_key_event(KeyCode::PageUp);
handle_announcement(ke_page_up_saturate, &mut app, "test-1", &mut scroll);
assert_eq!(scroll, 0); }
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_handle_announcement_shows_next_after_read() {
let mut app = crate::state::AppState::default();
app.modal = crate::state::Modal::Announcement {
title: "Test 1".to_string(),
content: "Content".to_string(),
id: "test-1".to_string(),
scroll: 0,
};
let next = RemoteAnnouncement {
id: "test-2".to_string(),
title: "Test 2".to_string(),
content: "Content".to_string(),
min_version: None,
max_version: None,
expires: None,
};
app.pending_announcements.push(next);
let mut scroll = 0u16;
let ke = test_key_event(KeyCode::Char('r'));
handle_announcement(ke, &mut app, "test-1", &mut scroll);
match &app.modal {
crate::state::Modal::Announcement { id, .. } => {
assert_eq!(id, "test-2");
}
_ => panic!("Expected Announcement modal with next announcement"),
}
}
#[test]
fn test_update_scroll_for_selection_uses_rendered_line_starts() {
let app = crate::state::AppState {
updates_modal_entry_line_starts: vec![0, 3, 5],
updates_modal_total_lines: 7,
updates_modal_content_rect: Some((0, 0, 40, 3)),
..Default::default()
};
let mut scroll = crate::events::utils::compute_updates_modal_scroll_for_selection(
&app.updates_modal_entry_line_starts,
app.updates_modal_total_lines,
app.updates_modal_content_rect,
1,
3,
0,
);
assert_eq!(
scroll, 1,
"entry 1 starts at rendered line 3 with height 3 viewport"
);
scroll = crate::events::utils::compute_updates_modal_scroll_for_selection(
&app.updates_modal_entry_line_starts,
app.updates_modal_total_lines,
app.updates_modal_content_rect,
2,
3,
scroll,
);
assert_eq!(
scroll, 3,
"entry 2 should move viewport to include rendered line 5"
);
}
#[test]
fn test_update_scroll_for_selection_clamps_to_max_scroll() {
let app = crate::state::AppState {
updates_modal_entry_line_starts: vec![0, 2],
updates_modal_total_lines: 4,
updates_modal_content_rect: Some((0, 0, 40, 3)),
..Default::default()
};
let scroll = crate::events::utils::compute_updates_modal_scroll_for_selection(
&app.updates_modal_entry_line_starts,
app.updates_modal_total_lines,
app.updates_modal_content_rect,
0,
2,
10,
);
assert_eq!(scroll, 0, "first entry should clamp scroll back to start");
}
#[test]
fn test_handle_updates_home_and_end_jump_bounds() {
let mut app = crate::state::AppState::default();
let entries = vec![
("a".to_string(), "1".to_string(), "2".to_string()),
("b".to_string(), "1".to_string(), "2".to_string()),
("c".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 1usize;
let mut scroll = 0u16;
let mut filter_active = false;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = None;
let mut filtered_indices = (0..entries.len()).collect();
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Home),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(selected, 0);
let _ = handle_updates(
test_key_event(KeyCode::End),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(selected, 2);
}
#[test]
fn test_handle_updates_uppercase_g_jumps_last() {
let mut app = crate::state::AppState::default();
let entries = vec![
("a".to_string(), "1".to_string(), "2".to_string()),
("b".to_string(), "1".to_string(), "2".to_string()),
("c".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = false;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = None;
let mut filtered_indices = (0..entries.len()).collect();
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Char('G')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(selected, 2);
}
#[test]
fn test_handle_updates_gg_within_timeout_jumps_first() {
let mut app = crate::state::AppState::default();
let entries = vec![
("a".to_string(), "1".to_string(), "2".to_string()),
("b".to_string(), "1".to_string(), "2".to_string()),
("c".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 2usize;
let mut scroll = 0u16;
let mut filter_active = false;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = None;
let mut filtered_indices = (0..entries.len()).collect();
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Char('g')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert!(app.updates_modal_pending_g_at.is_some());
assert_eq!(selected, 2);
let _ = handle_updates(
test_key_event(KeyCode::Char('g')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(selected, 0);
assert!(app.updates_modal_pending_g_at.is_none());
}
#[test]
fn test_handle_updates_expired_pending_g_does_not_jump() {
let mut app = crate::state::AppState::default();
let entries = vec![
("a".to_string(), "1".to_string(), "2".to_string()),
("b".to_string(), "1".to_string(), "2".to_string()),
("c".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 2usize;
let mut scroll = 0u16;
let mut filter_active = false;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = None;
let mut filtered_indices = (0..entries.len()).collect();
let mut selected_pkg_names = std::collections::HashSet::new();
let now = Instant::now();
let expired = now
.checked_sub(UPDATES_GG_TIMEOUT + Duration::from_millis(50))
.map_or(now, |instant| instant);
app.updates_modal_pending_g_at = Some(expired);
let _ = handle_updates(
test_key_event(KeyCode::Char('g')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(selected, 2);
assert!(app.updates_modal_pending_g_at.is_some());
}
#[test]
fn test_handle_updates_non_g_clears_pending_chord() {
let mut app = crate::state::AppState::default();
let entries = vec![
("a".to_string(), "1".to_string(), "2".to_string()),
("b".to_string(), "1".to_string(), "2".to_string()),
("c".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = false;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = None;
let mut filtered_indices = (0..entries.len()).collect();
let mut selected_pkg_names = std::collections::HashSet::new();
app.updates_modal_pending_g_at = Some(Instant::now());
let _ = handle_updates(
test_key_event(KeyCode::Down),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(selected, 1);
assert!(app.updates_modal_pending_g_at.is_none());
}
#[test]
fn test_handle_updates_slash_enters_filter_mode() {
let mut app = crate::state::AppState::default();
let entries = vec![("pkg-a".to_string(), "1".to_string(), "2".to_string())];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = false;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = None;
let mut filtered_indices = vec![0];
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Char('/')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert!(filter_active);
assert_eq!(selected, 0);
}
#[test]
fn test_handle_updates_space_in_filter_mode_toggles_mark() {
let mut app = crate::state::AppState::default();
let entries = vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("beta".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = true;
let mut filter_query = "al".to_string();
let mut filter_caret = 2usize;
let mut last_selected_pkg_name = Some("alpha".to_string());
let mut filtered_indices =
crate::events::utils::compute_updates_filtered_indices(&entries, &filter_query);
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Char(' ')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(filter_query, "al");
assert!(selected_pkg_names.contains("alpha"));
let _ = handle_updates(
test_key_event(KeyCode::Char(' ')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert!(!selected_pkg_names.contains("alpha"));
}
#[test]
fn test_handle_updates_filter_mode_arrow_navigates_list() {
let mut app = crate::state::AppState::default();
let entries = vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("beta".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = true;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = Some("alpha".to_string());
let mut filtered_indices = vec![0, 1];
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Down),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(selected, 1);
assert!(filter_active);
let _ = handle_updates(
test_key_event(KeyCode::Up),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(selected, 0);
assert!(filter_active);
}
#[test]
fn test_handle_updates_filter_mode_j_inserts_into_query() {
let mut app = crate::state::AppState::default();
let entries = vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("beta".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = true;
let mut filter_query = "a".to_string();
let mut filter_caret = 1usize;
let mut last_selected_pkg_name = Some("alpha".to_string());
let mut filtered_indices =
crate::events::utils::compute_updates_filtered_indices(&entries, &filter_query);
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Char('j')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(filter_query, "aj");
assert_eq!(filter_caret, 2);
assert_eq!(selected, 0);
assert!(filter_active);
}
#[test]
fn test_handle_updates_filter_mode_g_and_shift_g_insert_into_query() {
let mut app = crate::state::AppState::default();
let entries = vec![
("gamma".to_string(), "1".to_string(), "2".to_string()),
("other".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = true;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = Some("gamma".to_string());
let mut filtered_indices =
crate::events::utils::compute_updates_filtered_indices(&entries, &filter_query);
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Char('g')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(filter_query, "g");
assert_eq!(filter_caret, 1);
let shift_g = KeyEvent {
code: KeyCode::Char('G'),
modifiers: KeyModifiers::SHIFT,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::empty(),
};
let _ = handle_updates(
shift_g,
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(filter_query, "gG");
assert_eq!(filter_caret, 2);
assert_eq!(selected, 0);
assert!(filter_active);
}
#[test]
fn test_handle_updates_filter_esc_clears_and_exits() {
let mut app = crate::state::AppState::default();
let entries = vec![("pkg-a".to_string(), "1".to_string(), "2".to_string())];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = true;
let mut filter_query = "pkg".to_string();
let mut filter_caret = 3usize;
let mut last_selected_pkg_name = Some("pkg-a".to_string());
let mut filtered_indices = vec![0];
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Esc),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert!(!filter_active);
assert!(filter_query.is_empty());
assert_eq!(filter_caret, 0);
assert_eq!(filtered_indices, vec![0]);
}
#[test]
fn test_handle_updates_filter_restores_selection_by_identity() {
let mut app = crate::state::AppState::default();
let entries = vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("beta".to_string(), "1".to_string(), "2".to_string()),
("gamma".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 1usize;
let mut scroll = 0u16;
let mut filter_active = true;
let mut filter_query = "be".to_string();
let mut filter_caret = 2usize;
let mut last_selected_pkg_name = Some("beta".to_string());
let mut filtered_indices =
crate::events::utils::compute_updates_filtered_indices(&entries, "be");
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Backspace),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert_eq!(selected, 1);
assert_eq!(last_selected_pkg_name, Some("beta".to_string()));
assert!(!filtered_indices.is_empty());
}
#[test]
fn test_handle_updates_active_filter_does_not_refill_empty_results() {
let mut app = crate::state::AppState::default();
let entries = vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("beta".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = true;
let mut filter_query = "zzz".to_string();
let mut filter_caret = 3usize;
let mut last_selected_pkg_name = Some("alpha".to_string());
let mut filtered_indices =
crate::events::utils::compute_updates_filtered_indices(&entries, &filter_query);
let mut selected_pkg_names = std::collections::HashSet::new();
assert!(filtered_indices.is_empty());
let _ = handle_updates(
test_key_event(KeyCode::Down),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert!(filter_active);
assert_eq!(filter_query, "zzz");
assert!(filtered_indices.is_empty());
}
#[test]
fn test_handle_updates_space_toggles_selected_package() {
let mut app = crate::state::AppState::default();
let entries = vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("beta".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 1usize;
let mut scroll = 0u16;
let mut filter_active = false;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = None;
let mut filtered_indices = vec![0, 1];
let mut selected_pkg_names = std::collections::HashSet::new();
let _ = handle_updates(
test_key_event(KeyCode::Char(' ')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert!(selected_pkg_names.contains("beta"));
let _ = handle_updates(
test_key_event(KeyCode::Char(' ')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert!(!selected_pkg_names.contains("beta"));
}
#[test]
fn test_handle_updates_a_selects_all_visible_rows() {
let mut app = crate::state::AppState::default();
let entries = vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("beta".to_string(), "1".to_string(), "2".to_string()),
("gamma".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 0usize;
let mut scroll = 0u16;
let mut filter_active = false;
let mut filter_query = String::new();
let mut filter_caret = 0usize;
let mut last_selected_pkg_name = None;
let mut filtered_indices = vec![1, 2];
let mut selected_pkg_names = std::collections::HashSet::from(["alpha".to_string()]);
let _ = handle_updates(
test_key_event(KeyCode::Char('a')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert!(selected_pkg_names.contains("alpha"));
assert!(selected_pkg_names.contains("beta"));
assert!(selected_pkg_names.contains("gamma"));
assert_eq!(selected_pkg_names.len(), 3);
}
#[test]
fn test_handle_updates_filter_changes_preserve_hidden_selection() {
let mut app = crate::state::AppState::default();
let entries = vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("beta".to_string(), "1".to_string(), "2".to_string()),
("gamma".to_string(), "1".to_string(), "2".to_string()),
];
let mut selected = 1usize;
let mut scroll = 0u16;
let mut filter_active = true;
let mut filter_query = "b".to_string();
let mut filter_caret = 1usize;
let mut last_selected_pkg_name = Some("beta".to_string());
let mut filtered_indices = vec![1];
let mut selected_pkg_names = std::collections::HashSet::from(["gamma".to_string()]);
let _ = handle_updates(
test_key_event(KeyCode::Char('e')),
&mut app,
&entries,
&mut scroll,
&mut selected,
&mut filter_active,
&mut filter_query,
&mut filter_caret,
&mut last_selected_pkg_name,
&mut filtered_indices,
&mut selected_pkg_names,
);
assert!(selected_pkg_names.contains("gamma"));
}
#[test]
fn test_collect_selected_update_items_preserves_entry_order() {
let entries = vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("beta".to_string(), "1".to_string(), "2".to_string()),
("gamma".to_string(), "1".to_string(), "2".to_string()),
];
let selected_pkg_names =
std::collections::HashSet::from(["gamma".to_string(), "alpha".to_string()]);
let items = collect_selected_update_items(&entries, &selected_pkg_names);
let names: Vec<String> = items.into_iter().map(|item| item.name).collect();
assert_eq!(names, vec!["alpha".to_string(), "gamma".to_string()]);
}
}