use crossterm::event::{KeyCode, KeyEvent};
use tokio::sync::mpsc;
use super::restore;
use crate::install::ExecutorRequest;
use crate::state::{AppState, Modal, PackageItem};
const STARTUP_SETUP_SELECTOR_ITEMS: usize = 7;
#[must_use]
fn startup_selector_task_selectable(
task: crate::state::modal::StartupSetupTask,
app: &AppState,
active_tool: Option<crate::logic::privilege::PrivilegeTool>,
) -> bool {
match task {
crate::state::modal::StartupSetupTask::SshAurSetup => {
!app.aur_ssh_help_ready.unwrap_or(false)
}
crate::state::modal::StartupSetupTask::SudoTimestampSetup => {
matches!(
active_tool,
Some(crate::logic::privilege::PrivilegeTool::Sudo)
)
}
crate::state::modal::StartupSetupTask::DoasPersistSetup => {
matches!(
active_tool,
Some(crate::logic::privilege::PrivilegeTool::Doas)
)
}
_ => true,
}
}
pub(super) fn handle_alert_modal(ke: KeyEvent, app: &mut AppState, modal: &Modal) -> bool {
if let Modal::Alert { message } = modal {
super::common::handle_alert(ke, app, message)
} else {
false
}
}
pub(super) fn handle_preflight_exec_modal(
ke: KeyEvent,
app: &mut AppState,
mut modal: Modal,
) -> bool {
if let Modal::PreflightExec {
ref mut verbose,
ref log_lines,
ref abortable,
ref items,
ref action,
ref tab,
ref header_chips,
ref success,
} = modal
{
if matches!(ke.code, KeyCode::Enter | KeyCode::Char('\n' | '\r'))
&& app.pending_repo_apply_overlap_check.is_some()
&& items.is_empty()
&& success.is_none()
{
app.toast_message = Some(crate::i18n::t(
app,
"app.toasts.repo_apply_wait_exec_finish",
));
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(4));
app.modal = modal;
return true;
}
let should_stop =
super::common::handle_preflight_exec(ke, app, verbose, *abortable, items, *success);
if should_stop {
return true; }
restore::restore_if_not_closed_with_excluded_keys(
app,
&ke,
&[KeyCode::Esc, KeyCode::Char('q')],
Modal::PreflightExec {
verbose: *verbose,
log_lines: log_lines.clone(),
abortable: *abortable,
items: items.clone(),
action: *action,
tab: *tab,
success: *success,
header_chips: header_chips.clone(),
},
);
}
false
}
pub(super) fn handle_post_summary_modal(ke: KeyEvent, app: &mut AppState, modal: &Modal) -> bool {
if let Modal::PostSummary {
success,
changed_files,
pacnew_count,
pacsave_count,
services_pending,
snapshot_label,
} = modal
{
let should_stop = super::common::handle_post_summary(ke, app, services_pending);
if should_stop {
return true; }
restore::restore_if_not_closed_with_excluded_keys(
app,
&ke,
&[
KeyCode::Esc,
KeyCode::Enter,
KeyCode::Char('q'),
KeyCode::Char('\n'),
KeyCode::Char('\r'),
],
Modal::PostSummary {
success: *success,
changed_files: *changed_files,
pacnew_count: *pacnew_count,
pacsave_count: *pacsave_count,
services_pending: services_pending.clone(),
snapshot_label: snapshot_label.clone(),
},
);
}
false
}
pub(super) fn handle_system_update_modal(
ke: KeyEvent,
app: &mut AppState,
mut modal: Modal,
) -> bool {
if let Modal::SystemUpdate {
ref mut do_mirrors,
ref mut do_pacman,
ref mut force_sync,
ref mut do_aur,
ref mut do_cache,
ref mut country_idx,
ref countries,
ref mut mirror_count,
ref mut cursor,
} = modal
{
let should_stop = super::system_update::handle_system_update(
ke,
app,
do_mirrors,
do_pacman,
force_sync,
do_aur,
do_cache,
country_idx,
countries,
mirror_count,
cursor,
);
return restore::restore_if_not_closed_with_option_result(
app,
&ke,
should_stop,
Modal::SystemUpdate {
do_mirrors: *do_mirrors,
do_pacman: *do_pacman,
force_sync: *force_sync,
do_aur: *do_aur,
do_cache: *do_cache,
country_idx: *country_idx,
countries: countries.clone(),
mirror_count: *mirror_count,
cursor: *cursor,
},
);
}
false
}
pub(super) fn handle_confirm_install_modal(
ke: KeyEvent,
app: &mut AppState,
modal: &Modal,
) -> bool {
if let Modal::ConfirmInstall { items } = modal {
super::install::handle_confirm_install(ke, app, items);
}
false
}
pub(super) fn handle_confirm_remove_modal(ke: KeyEvent, app: &mut AppState, modal: &Modal) -> bool {
if let Modal::ConfirmRemove { items } = modal {
super::install::handle_confirm_remove(ke, app, items);
}
false
}
pub(super) fn handle_confirm_batch_update_modal(
ke: KeyEvent,
app: &mut AppState,
modal: &Modal,
) -> bool {
if let Modal::ConfirmBatchUpdate { items, dry_run } = modal {
match ke.code {
KeyCode::Esc | KeyCode::Char('q' | 'Q') => {
app.modal = crate::state::Modal::None;
return true;
}
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
let items_clone = items.clone();
let dry_run_clone = *dry_run;
app.dry_run = dry_run_clone;
let header_chips = app.pending_exec_header_chips.take().unwrap_or_default();
if crate::events::install::try_open_warn_aur_repo_duplicate_modal(
app,
&items_clone,
header_chips.clone(),
) {
return true;
}
let settings = crate::theme::settings();
if crate::logic::password::should_use_interactive_auth_handoff(&settings) {
match crate::events::try_interactive_auth_handoff() {
Ok(true) => crate::events::preflight::start_execution(
app,
&items_clone,
crate::state::PreflightAction::Install,
header_chips,
None,
),
Ok(false) => {
app.modal = crate::state::Modal::Alert {
message: crate::i18n::t(app, "app.errors.authentication_failed"),
};
}
Err(e) => {
app.modal = crate::state::Modal::Alert { message: e };
}
}
} else if crate::logic::password::resolve_auth_mode(&settings)
== crate::logic::privilege::AuthMode::PasswordlessOnly
&& crate::logic::password::should_use_passwordless_sudo(&settings)
{
crate::events::preflight::start_execution(
app,
&items_clone,
crate::state::PreflightAction::Install,
header_chips,
None,
);
} else {
app.modal = crate::state::Modal::PasswordPrompt {
purpose: crate::state::modal::PasswordPurpose::Install,
items: items_clone,
input: crate::state::SecureString::default(),
cursor: 0,
error: None,
};
app.pending_exec_header_chips = Some(header_chips);
}
return true;
}
_ => {}
}
}
false
}
pub(super) fn handle_confirm_aur_update_modal(
ke: KeyEvent,
app: &mut AppState,
modal: &Modal,
) -> bool {
if let Modal::ConfirmAurUpdate { .. } = modal {
match ke.code {
KeyCode::Esc | KeyCode::Char('q' | 'Q' | 'n' | 'N') => {
app.pending_aur_update_command = None;
app.modal = crate::state::Modal::None;
return true;
}
KeyCode::Enter | KeyCode::Char('\n' | '\r' | 'y' | 'Y') => {
if let Some(aur_command) = app.pending_aur_update_command.take() {
let password = app.pending_executor_password.clone();
let dry_run = app.dry_run;
app.modal = Modal::PreflightExec {
items: Vec::new(),
action: crate::state::PreflightAction::Install,
tab: crate::state::PreflightTab::Summary,
verbose: false,
log_lines: Vec::new(),
abortable: true,
header_chips: app.pending_exec_header_chips.take().unwrap_or_default(),
success: None,
};
app.pending_executor_request = Some(ExecutorRequest::Update {
commands: vec![aur_command],
password,
dry_run,
});
} else {
app.modal = crate::state::Modal::None;
}
return true;
}
_ => {}
}
}
false
}
pub(super) fn handle_warn_aur_repo_duplicate_modal(
ke: KeyEvent,
app: &mut AppState,
modal: &Modal,
) -> bool {
if let Modal::WarnAurRepoDuplicate {
dup_names,
packages,
header_chips,
} = modal
{
let consumed = super::foreign_overlap::handle_warn_aur_repo_duplicate_modal(
ke,
app,
dup_names,
packages,
header_chips,
);
if !consumed {
app.modal = modal.clone();
}
return consumed;
}
false
}
pub(super) fn handle_foreign_repo_overlap_modal(
ke: KeyEvent,
app: &mut AppState,
modal: &Modal,
) -> bool {
if let Modal::ForeignRepoOverlap {
repo_name,
entries,
phase,
} = modal
{
let consumed = super::foreign_overlap::handle_foreign_repo_overlap_modal(
ke,
app,
repo_name,
entries,
phase.clone(),
);
if !consumed {
app.modal = modal.clone();
}
return consumed;
}
false
}
pub(super) fn handle_confirm_aur_vote_modal(
ke: KeyEvent,
app: &mut AppState,
modal: &Modal,
) -> bool {
if let Modal::ConfirmAurVote {
pkgbase, action, ..
} = modal
{
match ke.code {
KeyCode::Esc | KeyCode::Char('q' | 'Q' | 'n' | 'N') => {
app.pending_aur_vote_intent = None;
app.modal = crate::state::Modal::None;
app.toast_message =
Some(format!("Cancelled AUR {action} request for '{pkgbase}'."));
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
return true;
}
KeyCode::Enter | KeyCode::Char('\n' | '\r' | 'y' | 'Y') => {
app.pending_aur_vote_intent = None;
app.pending_aur_vote_request = Some((pkgbase.clone(), *action));
let action_label = match action {
crate::sources::VoteAction::Vote => "vote",
crate::sources::VoteAction::Unvote => "unvote",
};
app.toast_message = Some(format!(
"Queued AUR {action_label} request for '{pkgbase}'."
));
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
app.modal = crate::state::Modal::None;
return true;
}
_ => {}
}
}
false
}
pub(super) fn handle_confirm_reinstall_modal(
ke: KeyEvent,
app: &mut AppState,
modal: &Modal,
) -> bool {
if let Modal::ConfirmReinstall {
items: _installed_items,
all_items,
header_chips,
} = modal
{
match ke.code {
KeyCode::Esc | KeyCode::Char('q' | 'Q') => {
app.modal = crate::state::Modal::None;
return true;
}
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
let items_clone = all_items.clone();
let header_chips_clone = header_chips.clone();
if crate::events::install::try_open_warn_aur_repo_duplicate_modal(
app,
&items_clone,
header_chips_clone.clone(),
) {
return true;
}
let password = app.pending_executor_password.take();
if password.is_none() {
let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
if let Some(lockout_msg) =
crate::logic::faillock::get_lockout_message_if_locked(&username, app)
{
app.modal = crate::state::Modal::Alert {
message: lockout_msg,
};
return true;
}
let settings = crate::theme::settings();
if crate::logic::password::should_use_interactive_auth_handoff(&settings) {
match crate::events::try_interactive_auth_handoff() {
Ok(true) => crate::events::preflight::start_execution(
app,
&items_clone,
crate::state::PreflightAction::Install,
header_chips_clone,
None,
),
Ok(false) => {
app.modal = crate::state::Modal::Alert {
message: crate::i18n::t(
app,
"app.errors.authentication_failed",
),
};
}
Err(e) => {
app.modal = crate::state::Modal::Alert { message: e };
}
}
} else if crate::logic::password::resolve_auth_mode(&settings)
== crate::logic::privilege::AuthMode::PasswordlessOnly
&& crate::logic::password::should_use_passwordless_sudo(&settings)
{
crate::events::preflight::start_execution(
app,
&items_clone,
crate::state::PreflightAction::Install,
header_chips_clone,
None,
);
} else {
app.modal = crate::state::Modal::PasswordPrompt {
purpose: crate::state::modal::PasswordPurpose::Install,
items: items_clone,
input: crate::state::SecureString::default(),
cursor: 0,
error: None,
};
app.pending_exec_header_chips = Some(header_chips_clone);
}
}
return true;
}
_ => {}
}
}
false
}
pub(super) fn handle_help_modal(ke: KeyEvent, app: &mut AppState, _modal: Modal) -> bool {
super::common::handle_help(ke, app)
}
pub(super) fn handle_news_modal(ke: KeyEvent, app: &mut AppState, mut modal: Modal) -> bool {
if let Modal::News {
ref items,
ref mut selected,
ref mut scroll,
} = modal
{
let result = super::common::handle_news(ke, app, items, selected, scroll);
return restore::restore_if_not_closed_with_bool_result(
app,
result,
Modal::News {
items: items.clone(),
selected: *selected,
scroll: *scroll,
},
);
}
false
}
pub(super) fn handle_announcement_modal(
ke: KeyEvent,
app: &mut AppState,
mut modal: Modal,
) -> bool {
if let Modal::Announcement {
ref title,
ref content,
ref id,
ref mut scroll,
} = modal
{
let old_id = id.clone();
let result = super::common::handle_announcement(ke, app, id, scroll);
match &app.modal {
Modal::Announcement { id: new_id, .. } if *new_id == old_id => {
app.modal = Modal::Announcement {
title: title.clone(),
content: content.clone(),
id: old_id,
scroll: *scroll,
};
}
_ => {
}
}
return result;
}
false
}
pub(super) fn handle_updates_modal(ke: KeyEvent, app: &mut AppState, mut modal: Modal) -> bool {
if let Modal::Updates {
ref entries,
ref mut scroll,
ref mut selected,
ref mut filter_active,
ref mut filter_query,
ref mut filter_caret,
ref mut last_selected_pkg_name,
ref mut filtered_indices,
ref mut selected_pkg_names,
} = modal
{
let result = super::common::handle_updates(
ke,
app,
entries,
scroll,
selected,
filter_active,
filter_query,
filter_caret,
last_selected_pkg_name,
filtered_indices,
selected_pkg_names,
);
return restore::restore_if_not_closed_with_bool_result(
app,
result,
Modal::Updates {
entries: entries.clone(),
scroll: *scroll,
selected: *selected,
filter_active: *filter_active,
filter_query: filter_query.clone(),
filter_caret: *filter_caret,
last_selected_pkg_name: last_selected_pkg_name.clone(),
filtered_indices: filtered_indices.clone(),
selected_pkg_names: selected_pkg_names.clone(),
},
);
}
false
}
pub(super) fn handle_optional_deps_modal(
ke: KeyEvent,
app: &mut AppState,
mut modal: Modal,
) -> bool {
if let Modal::OptionalDeps {
ref rows,
ref mut selected,
ref mut selected_pkg_names,
} = modal
{
let should_stop =
super::optional_deps::handle_optional_deps(ke, app, rows, selected, selected_pkg_names);
return restore::restore_if_not_closed_with_option_result(
app,
&ke,
should_stop,
Modal::OptionalDeps {
rows: rows.clone(),
selected: *selected,
selected_pkg_names: selected_pkg_names.clone(),
},
);
}
false
}
pub(super) fn handle_repositories_modal(
ke: KeyEvent,
app: &mut AppState,
mut modal: Modal,
) -> bool {
if let Modal::Repositories {
ref rows,
ref mut selected,
ref mut scroll,
ref repos_conf_error,
ref pacman_warnings,
} = modal
{
if matches!(ke.code, KeyCode::Char(' ')) {
match super::repositories::toggle_selected_repo_enabled_and_apply(
app,
rows,
*selected,
*scroll,
repos_conf_error.as_deref(),
) {
Ok(()) => return true,
Err(msg) => {
app.toast_message = Some(msg);
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(8));
return restore::restore_if_not_closed_with_option_result(
app,
&ke,
Some(false),
Modal::Repositories {
rows: rows.clone(),
selected: *selected,
scroll: *scroll,
repos_conf_error: repos_conf_error.clone(),
pacman_warnings: pacman_warnings.clone(),
},
);
}
}
}
if matches!(ke.code, KeyCode::Enter | KeyCode::Char('\n' | '\r')) {
match super::repositories::enter_repo_apply(
app,
rows,
*selected,
*scroll,
repos_conf_error.as_deref(),
) {
Ok(()) => return true,
Err(msg) => {
app.modal = Modal::Alert { message: msg };
return true;
}
}
}
if matches!(ke.code, KeyCode::Char('r' | 'R')) {
match super::repositories::enter_repo_key_refresh(
app,
rows,
*selected,
repos_conf_error.as_deref(),
) {
Ok(()) => return true,
Err(msg) => {
app.modal = Modal::Alert { message: msg };
return true;
}
}
}
if matches!(ke.code, KeyCode::Char('s' | 'S')) {
super::repositories::open_repos_conf_example_in_editor(app);
return restore::restore_if_not_closed_with_option_result(
app,
&ke,
Some(false),
Modal::Repositories {
rows: rows.clone(),
selected: *selected,
scroll: *scroll,
repos_conf_error: repos_conf_error.clone(),
pacman_warnings: pacman_warnings.clone(),
},
);
}
let should_stop = super::repositories::handle_repositories_modal_keys(
ke,
app,
rows.len(),
selected,
scroll,
);
return restore::restore_if_not_closed_with_option_result(
app,
&ke,
should_stop,
Modal::Repositories {
rows: rows.clone(),
selected: *selected,
scroll: *scroll,
repos_conf_error: repos_conf_error.clone(),
pacman_warnings: pacman_warnings.clone(),
},
);
}
false
}
pub(super) fn handle_ssh_setup_modal(ke: KeyEvent, app: &mut AppState, mut modal: Modal) -> bool {
if let Modal::SshAurSetup {
ref mut step,
ref mut status_lines,
ref mut existing_host_block,
} = modal
{
let result = super::optional_deps::handle_ssh_setup_modal(
ke,
app,
step,
status_lines,
existing_host_block,
);
return restore::restore_if_not_closed_with_option_result(
app,
&ke,
result,
Modal::SshAurSetup {
step: *step,
status_lines: status_lines.clone(),
existing_host_block: existing_host_block.clone(),
},
);
}
false
}
pub(super) fn handle_scan_config_modal(ke: KeyEvent, app: &mut AppState, mut modal: Modal) -> bool {
if let Modal::ScanConfig {
ref mut do_clamav,
ref mut do_trivy,
ref mut do_semgrep,
ref mut do_shellcheck,
ref mut do_virustotal,
ref mut do_custom,
ref mut do_sleuth,
ref mut cursor,
} = modal
{
super::scan::handle_scan_config(
ke,
app,
do_clamav,
do_trivy,
do_semgrep,
do_shellcheck,
do_virustotal,
do_custom,
do_sleuth,
cursor,
);
restore::restore_if_not_closed_with_esc(
app,
&ke,
Modal::ScanConfig {
do_clamav: *do_clamav,
do_trivy: *do_trivy,
do_semgrep: *do_semgrep,
do_shellcheck: *do_shellcheck,
do_virustotal: *do_virustotal,
do_custom: *do_custom,
do_sleuth: *do_sleuth,
cursor: *cursor,
},
);
}
false
}
pub(super) fn handle_news_setup_modal(ke: KeyEvent, app: &mut AppState, mut modal: Modal) -> bool {
if let Modal::NewsSetup {
ref mut show_arch_news,
ref mut show_advisories,
ref mut show_aur_updates,
ref mut show_aur_comments,
ref mut show_pkg_updates,
ref mut max_age_days,
ref mut cursor,
} = modal
{
match ke.code {
KeyCode::Esc => {
app.previous_modal = None;
app.modal = crate::state::Modal::None;
if app.pending_startup_setup_steps.is_empty() {
super::common::show_next_pending_announcement(app);
} else {
super::common::show_next_startup_setup_step(app);
}
return true;
}
KeyCode::Up => {
if *cursor > 0 {
*cursor -= 1;
}
}
KeyCode::Down => {
if *cursor < 7 {
*cursor += 1;
}
}
KeyCode::Left => {
if *cursor >= 5 && *cursor <= 7 && *cursor > 5 {
*cursor -= 1;
}
}
KeyCode::Right => {
if *cursor >= 5 && *cursor <= 7 && *cursor < 7 {
*cursor += 1;
}
}
KeyCode::Char(' ') => match *cursor {
0 => *show_arch_news = !*show_arch_news,
1 => *show_advisories = !*show_advisories,
2 => *show_aur_updates = !*show_aur_updates,
3 => *show_aur_comments = !*show_aur_comments,
4 => *show_pkg_updates = !*show_pkg_updates,
5 => *max_age_days = Some(7),
6 => *max_age_days = Some(30),
7 => *max_age_days = Some(90),
_ => {}
},
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
crate::theme::save_startup_news_show_arch_news(*show_arch_news);
crate::theme::save_startup_news_show_advisories(*show_advisories);
crate::theme::save_startup_news_show_aur_updates(*show_aur_updates);
crate::theme::save_startup_news_show_aur_comments(*show_aur_comments);
crate::theme::save_startup_news_show_pkg_updates(*show_pkg_updates);
crate::theme::save_startup_news_max_age_days(*max_age_days);
crate::theme::save_startup_news_configured(true);
app.trigger_startup_news_fetch = true;
app.modal = crate::state::Modal::None;
if app.pending_startup_setup_steps.is_empty() {
super::common::show_next_pending_announcement(app);
} else {
super::common::show_next_startup_setup_step(app);
}
return true;
}
_ => {}
}
restore::restore_if_not_closed_with_esc(
app,
&ke,
Modal::NewsSetup {
show_arch_news: *show_arch_news,
show_advisories: *show_advisories,
show_aur_updates: *show_aur_updates,
show_aur_comments: *show_aur_comments,
show_pkg_updates: *show_pkg_updates,
max_age_days: *max_age_days,
cursor: *cursor,
},
);
}
false
}
pub(super) fn handle_virustotal_setup_modal(
ke: KeyEvent,
app: &mut AppState,
mut modal: Modal,
) -> bool {
if let Modal::VirusTotalSetup {
ref mut input,
ref mut cursor,
} = modal
{
let should_advance = matches!(ke.code, KeyCode::Esc)
|| (matches!(ke.code, KeyCode::Enter | KeyCode::Char('\n' | '\r'))
&& !input.trim().is_empty());
super::scan::handle_virustotal_setup(ke, app, input, cursor);
if should_advance
&& matches!(app.modal, Modal::None)
&& !app.pending_startup_setup_steps.is_empty()
{
super::common::show_next_startup_setup_step(app);
}
if !(should_advance && matches!(app.modal, Modal::None)) {
restore::restore_if_not_closed_with_esc(
app,
&ke,
Modal::VirusTotalSetup {
input: input.clone(),
cursor: *cursor,
},
);
}
return true;
}
false
}
#[allow(clippy::needless_pass_by_value)] pub(super) fn handle_sudo_timestamp_setup_modal(
ke: KeyEvent,
app: &mut AppState,
modal: Modal,
) -> bool {
let Modal::SudoTimestampSetup { mut setup } = modal else {
return false;
};
let finished =
super::sudo_timestamp_setup::handle_sudo_timestamp_setup_key(ke, app, &mut setup);
if finished {
app.modal = Modal::None;
if !app.pending_startup_setup_steps.is_empty() {
super::common::show_next_startup_setup_step(app);
}
} else {
app.modal = Modal::SudoTimestampSetup { setup };
}
true
}
#[allow(clippy::needless_pass_by_value)]
pub(super) fn handle_doas_persist_setup_modal(
ke: KeyEvent,
app: &mut AppState,
modal: Modal,
) -> bool {
let Modal::DoasPersistSetup { mut setup } = modal else {
return false;
};
let finished = super::doas_persist_setup::handle_doas_persist_setup_key(ke, app, &mut setup);
if finished {
app.modal = Modal::None;
if !app.pending_startup_setup_steps.is_empty() {
super::common::show_next_startup_setup_step(app);
}
} else {
app.modal = Modal::DoasPersistSetup { setup };
}
true
}
pub(super) fn handle_startup_setup_selector_modal(
ke: KeyEvent,
app: &mut AppState,
mut modal: Modal,
) -> bool {
if let Modal::StartupSetupSelector {
ref mut cursor,
ref mut selected,
active_privilege_tool,
} = modal
{
match ke.code {
KeyCode::Esc => {
app.pending_startup_setup_steps.clear();
app.modal = Modal::None;
super::common::show_next_pending_announcement(app);
return true;
}
KeyCode::Char('r' | 'R') => {
crate::theme::save_startup_news_configured(true);
app.pending_startup_setup_steps.clear();
app.modal = Modal::None;
super::common::show_next_pending_announcement(app);
return true;
}
KeyCode::Up | KeyCode::Char('k') => {
if *cursor > 0 {
*cursor -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if *cursor + 1 < STARTUP_SETUP_SELECTOR_ITEMS {
*cursor += 1;
}
}
KeyCode::Char(' ') => {
let max_cursor = STARTUP_SETUP_SELECTOR_ITEMS.saturating_sub(1);
*cursor = (*cursor).min(max_cursor);
let task = match *cursor {
0 => crate::state::modal::StartupSetupTask::ArchNews,
1 => crate::state::modal::StartupSetupTask::SshAurSetup,
2 => crate::state::modal::StartupSetupTask::OptionalDepsMissing,
3 => crate::state::modal::StartupSetupTask::SudoTimestampSetup,
4 => crate::state::modal::StartupSetupTask::DoasPersistSetup,
5 => crate::state::modal::StartupSetupTask::AurSleuthSetup,
_ => crate::state::modal::StartupSetupTask::VirusTotalSetup,
};
if !startup_selector_task_selectable(task, app, active_privilege_tool) {
app.modal = Modal::StartupSetupSelector {
cursor: *cursor,
selected: selected.clone(),
active_privilege_tool,
};
return false;
}
if selected.contains(&task) {
selected.remove(&task);
} else {
selected.insert(task);
}
}
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
if app.aur_ssh_help_ready.unwrap_or(false) {
selected.remove(&crate::state::modal::StartupSetupTask::SshAurSetup);
}
app.pending_startup_setup_steps =
super::common::startup_setup_steps_in_priority(selected);
app.modal = Modal::None;
super::common::show_next_startup_setup_step(app);
return true;
}
_ => {}
}
app.modal = Modal::StartupSetupSelector {
cursor: *cursor,
selected: selected.clone(),
active_privilege_tool,
};
}
false
}
pub(super) fn handle_gnome_terminal_prompt_modal(
ke: KeyEvent,
app: &mut AppState,
_modal: Modal,
) -> bool {
super::common::handle_gnome_terminal_prompt(ke, app);
false
}
#[allow(clippy::too_many_lines)] pub(super) fn handle_password_prompt_modal(
ke: KeyEvent,
app: &mut AppState,
mut modal: Modal,
) -> bool {
if let Modal::PasswordPrompt {
ref mut input,
ref mut cursor,
ref purpose,
ref items,
ref mut error,
} = modal
{
let submitted = super::password::handle_password_prompt(ke, app, input, cursor);
if !submitted && matches!(ke.code, KeyCode::Esc) {
match purpose {
crate::state::modal::PasswordPurpose::RepoApply => {
app.pending_repo_apply_commands = None;
app.pending_repo_apply_summary = None;
app.pending_repo_apply_overlap_check = None;
app.pending_repositories_modal_resume = None;
}
crate::state::modal::PasswordPurpose::RepoForeignMigrate => {
app.pending_foreign_migrate_commands = None;
app.pending_foreign_migrate_summary = None;
}
crate::state::modal::PasswordPurpose::Update => {
app.pending_update_commands = None;
}
crate::state::modal::PasswordPurpose::Install
| crate::state::modal::PasswordPurpose::Remove
| crate::state::modal::PasswordPurpose::Downgrade
| crate::state::modal::PasswordPurpose::FileSync => {}
}
}
if submitted {
let password = if input.trim().is_empty() {
None
} else {
Some(input.clone())
};
if let Some(ref pass) = password {
match crate::logic::password::validate_sudo_password(pass.as_str()) {
Ok(true) => {
}
Ok(false) => {
let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
let (is_locked, lockout_until, remaining_minutes) =
crate::logic::faillock::get_lockout_info(&username);
app.faillock_locked = is_locked;
app.faillock_lockout_until = lockout_until;
app.faillock_remaining_minutes = remaining_minutes;
if is_locked {
let lockout_msg = remaining_minutes.map_or_else(
|| {
crate::i18n::t_fmt1(
app,
"app.modals.alert.account_locked",
&username,
)
},
|remaining| {
if remaining > 0 {
crate::i18n::t_fmt(
app,
"app.modals.alert.account_locked_with_time",
&[&username as &dyn std::fmt::Display, &remaining],
)
} else {
crate::i18n::t_fmt1(
app,
"app.modals.alert.account_locked",
&username,
)
}
},
);
app.pending_executor_password = None;
app.pending_exec_header_chips = None;
app.pending_executor_request = None;
app.pending_repo_apply_commands = None;
app.pending_repo_apply_summary = None;
app.pending_repo_apply_overlap_check = None;
app.pending_repositories_modal_resume = None;
app.pending_foreign_migrate_commands = None;
app.pending_foreign_migrate_summary = None;
app.modal = crate::state::Modal::Alert {
message: lockout_msg,
};
return true;
}
let error_msg = crate::logic::faillock::check_faillock_status(&username)
.map_or_else(
|_| {
crate::i18n::t(
app,
"app.modals.password_prompt.incorrect_password",
)
},
|status| {
let remaining =
status.max_attempts.saturating_sub(status.attempts_used);
crate::i18n::t_fmt1(
app,
"app.modals.password_prompt.incorrect_password_attempts",
remaining,
)
},
);
app.modal = crate::state::Modal::PasswordPrompt {
purpose: *purpose,
items: items.clone(),
input: crate::state::SecureString::default(), cursor: 0, error: Some(error_msg),
};
return true;
}
Err(e) => {
app.modal = crate::state::Modal::PasswordPrompt {
purpose: *purpose,
items: items.clone(),
input: input.clone(),
cursor: *cursor,
error: Some(crate::i18n::t_fmt1(
app,
"app.modals.password_prompt.validation_failed",
&e,
)),
};
return true;
}
}
}
if matches!(purpose, crate::state::modal::PasswordPurpose::Downgrade) {
app.modal = crate::state::Modal::None;
let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
let joined = names.join(" ");
let tool = match crate::logic::privilege::active_tool() {
Ok(t) => t,
Err(msg) => {
app.modal = crate::state::Modal::Alert { message: msg };
return true;
}
};
let cmd = if app.dry_run {
let downgrade_cmd = crate::logic::privilege::build_privilege_command(
tool,
&format!("downgrade {joined}"),
);
let quoted = crate::install::shell_single_quote(&downgrade_cmd);
format!("echo DRY RUN: {quoted}")
} else {
let downgrade_cmd = password.as_ref().map_or_else(
|| {
crate::logic::privilege::build_privilege_command(
tool,
&format!("downgrade {joined}"),
)
},
|pass| {
crate::logic::privilege::build_password_pipe(
tool,
pass,
&format!("downgrade {joined}"),
)
.unwrap_or_else(|| {
crate::logic::privilege::build_privilege_command(
tool,
&format!("downgrade {joined}"),
)
})
},
);
format!(
"if (command -v downgrade >/dev/null 2>&1) || pacman -Qi downgrade >/dev/null 2>&1; then {downgrade_cmd}; else echo 'downgrade tool not found. Install \"downgrade\" package.'; fi"
)
};
app.downgrade_list.clear();
app.downgrade_list_names.clear();
app.downgrade_state.select(None);
crate::install::spawn_shell_commands_in_terminal(&[cmd]);
app.toast_message = Some(crate::i18n::t(app, "app.toasts.downgrade_started"));
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
return true;
}
let header_chips = app.pending_exec_header_chips.take().unwrap_or_default();
if let Some(custom_cmd) = app.pending_custom_command.take() {
app.modal = Modal::PreflightExec {
items: items.clone(),
action: crate::state::PreflightAction::Install,
tab: crate::state::PreflightTab::Summary,
verbose: false,
log_lines: Vec::new(),
abortable: false,
header_chips,
success: None,
};
app.pending_executor_request = Some(ExecutorRequest::CustomCommand {
command: custom_cmd,
password,
dry_run: app.dry_run,
});
return true;
}
if matches!(purpose, crate::state::modal::PasswordPurpose::Update) {
if let Some(commands) = app.pending_update_commands.take() {
match (&mut app.pending_executor_password, &password) {
(Some(p), Some(pass)) => p.clone_from(pass),
(None, Some(pass)) => app.pending_executor_password = Some(pass.clone()),
(_, None) => app.pending_executor_password = None,
}
app.pending_exec_header_chips = Some(header_chips.clone());
app.modal = Modal::PreflightExec {
items: Vec::new(), action: crate::state::PreflightAction::Install,
tab: crate::state::PreflightTab::Summary,
verbose: false,
log_lines: Vec::new(),
abortable: false,
header_chips,
success: None,
};
app.pending_executor_request = Some(ExecutorRequest::Update {
commands,
password,
dry_run: app.dry_run,
});
return true;
}
app.modal = Modal::Alert {
message: "No update commands found".to_string(),
};
return true;
}
if matches!(purpose, crate::state::modal::PasswordPurpose::RepoApply) {
if let Some(commands) = app.pending_repo_apply_commands.take() {
match (&mut app.pending_executor_password, &password) {
(Some(p), Some(pass)) => p.clone_from(pass),
(None, Some(pass)) => app.pending_executor_password = Some(pass.clone()),
(_, None) => app.pending_executor_password = None,
}
app.pending_exec_header_chips = Some(header_chips.clone());
let log_lines = app.pending_repo_apply_summary.take().unwrap_or_default();
app.modal = Modal::PreflightExec {
items: Vec::new(),
action: crate::state::PreflightAction::Install,
tab: crate::state::PreflightTab::Summary,
verbose: false,
log_lines,
abortable: false,
header_chips,
success: None,
};
app.pending_executor_request = Some(ExecutorRequest::Update {
commands,
password,
dry_run: app.dry_run,
});
return true;
}
app.modal = Modal::Alert {
message: crate::i18n::t(app, "app.modals.repositories.apply.missing_commands"),
};
return true;
}
if matches!(
purpose,
crate::state::modal::PasswordPurpose::RepoForeignMigrate
) {
if let Some(commands) = app.pending_foreign_migrate_commands.take() {
match (&mut app.pending_executor_password, &password) {
(Some(p), Some(pass)) => p.clone_from(pass),
(None, Some(pass)) => app.pending_executor_password = Some(pass.clone()),
(_, None) => app.pending_executor_password = None,
}
app.pending_exec_header_chips = Some(header_chips.clone());
let log_lines = app
.pending_foreign_migrate_summary
.take()
.unwrap_or_default();
app.modal = Modal::PreflightExec {
items: Vec::new(),
action: crate::state::PreflightAction::Install,
tab: crate::state::PreflightTab::Summary,
verbose: false,
log_lines,
abortable: false,
header_chips,
success: None,
};
app.pending_executor_request = Some(ExecutorRequest::Update {
commands,
password,
dry_run: app.dry_run,
});
return true;
}
app.modal = Modal::Alert {
message: crate::i18n::t(
app,
"app.modals.foreign_overlap.missing_migrate_commands",
),
};
return true;
}
if matches!(purpose, crate::state::modal::PasswordPurpose::Install) {
use crate::events::preflight::keys;
keys::start_execution(
app,
items,
crate::state::PreflightAction::Install,
header_chips,
password,
);
return true;
}
let action = match purpose {
crate::state::modal::PasswordPurpose::Install
| crate::state::modal::PasswordPurpose::Update
| crate::state::modal::PasswordPurpose::RepoApply
| crate::state::modal::PasswordPurpose::RepoForeignMigrate => {
unreachable!(
"Install/Update/RepoApply/RepoForeignMigrate should be handled above"
)
}
crate::state::modal::PasswordPurpose::Remove => {
crate::state::PreflightAction::Remove
}
crate::state::modal::PasswordPurpose::Downgrade => {
unreachable!("Downgrade should be handled above")
}
crate::state::modal::PasswordPurpose::FileSync => {
unreachable!("FileSync should be handled via custom command above")
}
};
app.modal = Modal::PreflightExec {
items: items.clone(),
action,
tab: crate::state::PreflightTab::Summary,
verbose: false,
log_lines: Vec::new(),
success: None,
abortable: false,
header_chips,
};
app.pending_executor_request = Some(match purpose {
crate::state::modal::PasswordPurpose::Install
| crate::state::modal::PasswordPurpose::Update
| crate::state::modal::PasswordPurpose::RepoApply
| crate::state::modal::PasswordPurpose::RepoForeignMigrate => {
unreachable!(
"Install/Update/RepoApply/RepoForeignMigrate should be handled above"
)
}
crate::state::modal::PasswordPurpose::Remove => {
let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
ExecutorRequest::Remove {
names,
password,
cascade: app.remove_cascade_mode,
dry_run: app.dry_run,
}
}
crate::state::modal::PasswordPurpose::Downgrade => {
unreachable!("Downgrade should be handled above")
}
crate::state::modal::PasswordPurpose::FileSync => {
unreachable!("FileSync should be handled via custom command above")
}
});
return true;
}
restore::restore_if_not_closed_with_esc(
app,
&ke,
Modal::PasswordPrompt {
purpose: *purpose,
items: items.clone(),
input: input.clone(),
cursor: *cursor,
error: error.clone(),
},
);
}
false
}
pub(super) fn handle_import_help_modal(
ke: KeyEvent,
app: &mut AppState,
add_tx: &mpsc::UnboundedSender<PackageItem>,
_modal: Modal,
) -> bool {
super::import::handle_import_help(ke, app, add_tx);
false
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::VecDeque;
#[test]
fn startup_selector_enter_builds_and_starts_queue() {
let mut app = AppState::default();
let mut selected = std::collections::HashSet::new();
selected.insert(crate::state::modal::StartupSetupTask::ArchNews);
selected.insert(crate::state::modal::StartupSetupTask::VirusTotalSetup);
let modal = Modal::StartupSetupSelector {
cursor: 0,
selected,
active_privilege_tool: None,
};
let handled = handle_startup_setup_selector_modal(
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
&mut app,
modal,
);
assert!(handled);
assert!(matches!(app.modal, Modal::VirusTotalSetup { .. }));
assert_eq!(
app.pending_startup_setup_steps,
VecDeque::from([crate::state::modal::StartupSetupTask::ArchNews])
);
}
#[test]
fn startup_selector_esc_skips_all() {
let mut app = AppState {
pending_startup_setup_steps: VecDeque::from([
crate::state::modal::StartupSetupTask::ArchNews,
]),
..AppState::default()
};
let modal = Modal::StartupSetupSelector {
cursor: 0,
selected: std::collections::HashSet::new(),
active_privilege_tool: None,
};
let handled = handle_startup_setup_selector_modal(
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
&mut app,
modal,
);
assert!(handled);
assert!(matches!(app.modal, Modal::None));
assert!(app.pending_startup_setup_steps.is_empty());
}
#[test]
fn startup_selector_space_with_out_of_range_cursor_keeps_modal_and_clamps() {
let mut app = AppState::default();
let selected = std::collections::HashSet::new();
let modal = Modal::StartupSetupSelector {
cursor: usize::MAX,
selected,
active_privilege_tool: None,
};
let handled = handle_startup_setup_selector_modal(
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty()),
&mut app,
modal,
);
assert!(!handled);
match &app.modal {
Modal::StartupSetupSelector { cursor, .. } => {
assert_eq!(*cursor, STARTUP_SETUP_SELECTOR_ITEMS - 1);
}
_ => panic!("startup selector modal should remain active"),
}
}
#[test]
fn sudo_setup_finish_consumes_enter_key() {
let mut app = AppState::default();
let modal = Modal::SudoTimestampSetup {
setup: crate::state::modal::SudoTimestampSetupModalState {
phase: crate::state::modal::SudoTimestampSetupPhase::Select,
select_cursor: crate::state::modal::SUDO_TIMESTAMP_SELECT_ROWS - 1,
},
};
let handled = handle_sudo_timestamp_setup_modal(
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
&mut app,
modal,
);
assert!(handled);
assert!(matches!(app.modal, Modal::None));
}
#[test]
fn doas_setup_finish_consumes_enter_key() {
let mut app = AppState::default();
let modal = Modal::DoasPersistSetup {
setup: crate::state::modal::DoasPersistSetupModalState {
phase: crate::state::modal::DoasPersistSetupPhase::Select,
select_cursor: crate::state::modal::DOAS_PERSIST_SELECT_ROWS - 1,
},
};
let handled = handle_doas_persist_setup_modal(
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
&mut app,
modal,
);
assert!(handled);
assert!(matches!(app.modal, Modal::None));
}
#[test]
fn virustotal_setup_enter_consumes_key_when_closing_modal() {
let mut app = AppState::default();
app.pending_startup_setup_steps.clear();
let modal = Modal::VirusTotalSetup {
input: "dummy-api-key".to_string(),
cursor: 12,
};
let handled = handle_virustotal_setup_modal(
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
&mut app,
modal,
);
assert!(handled);
assert!(
!matches!(app.modal, Modal::VirusTotalSetup { .. }),
"virustotal setup modal should close on Enter with non-empty key"
);
}
#[test]
fn news_setup_esc_does_not_restore_stale_previous_modal() {
let mut app = AppState::default();
app.pending_startup_setup_steps.clear();
app.previous_modal = Some(Modal::News {
items: vec![crate::state::types::NewsFeedItem {
id: "news-1".to_string(),
date: "2026-01-01".to_string(),
title: "Old news".to_string(),
summary: None,
url: Some("https://example.com/news-1".to_string()),
source: crate::state::types::NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
}],
selected: 0,
scroll: 0,
});
let modal = Modal::NewsSetup {
show_arch_news: true,
show_advisories: true,
show_aur_updates: true,
show_aur_comments: true,
show_pkg_updates: true,
max_age_days: Some(30),
cursor: 0,
};
let handled = handle_news_setup_modal(
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
&mut app,
modal,
);
assert!(handled);
assert!(
!matches!(app.modal, Modal::News { .. }),
"Esc in NewsSetup must not resurrect stale News modal"
);
assert!(
app.previous_modal.is_none(),
"stale previous_modal should be cleared on cancel"
);
}
}