use crossterm::event::{Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
use tokio::sync::mpsc;
use crate::state::{AppState, Focus, PackageItem, PkgbuildCheckRequest, QueryInput};
mod distro;
mod global;
mod install;
mod modals;
mod mouse;
mod preflight;
mod recent;
mod search;
pub mod utils;
pub use search::open_preflight_modal;
pub use preflight::start_execution;
pub fn try_interactive_auth_handoff() -> Result<bool, String> {
let tool = crate::logic::privilege::active_tool()?;
crate::app::terminal::restore_terminal()
.map_err(|e| format!("Failed to restore terminal for interactive auth: {e}"))?;
let auth_result = crate::logic::privilege::run_interactive_auth(tool);
if let Err(e) = crate::app::terminal::setup_terminal() {
tracing::error!(error = %e, "Failed to re-setup terminal after interactive auth");
return Err(format!("Failed to re-setup terminal: {e}"));
}
auth_result
}
pub fn spawn_downgrade_in_terminal(app: &mut AppState, items: &[PackageItem]) -> bool {
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 downgrade_cmd =
crate::logic::privilege::build_privilege_command(tool, &format!("downgrade {joined}"));
let cmd = if app.dry_run {
let quoted = crate::install::shell_single_quote(&downgrade_cmd);
format!("echo DRY RUN: {quoted}")
} else {
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));
true
}
#[allow(clippy::too_many_arguments)]
pub fn handle_event(
ev: &CEvent,
app: &mut AppState,
query_tx: &mpsc::UnboundedSender<QueryInput>,
details_tx: &mpsc::UnboundedSender<PackageItem>,
preview_tx: &mpsc::UnboundedSender<PackageItem>,
add_tx: &mpsc::UnboundedSender<PackageItem>,
pkgb_tx: &mpsc::UnboundedSender<PackageItem>,
comments_tx: &mpsc::UnboundedSender<String>,
pkgb_check_tx: &mpsc::UnboundedSender<PkgbuildCheckRequest>,
) -> bool {
handle_event_with_pkgbuild_checks(
ev,
app,
query_tx,
details_tx,
preview_tx,
add_tx,
pkgb_tx,
comments_tx,
pkgb_check_tx,
)
}
#[allow(clippy::too_many_arguments)]
pub fn handle_event_with_pkgbuild_checks(
ev: &CEvent,
app: &mut AppState,
query_tx: &mpsc::UnboundedSender<QueryInput>,
details_tx: &mpsc::UnboundedSender<PackageItem>,
preview_tx: &mpsc::UnboundedSender<PackageItem>,
add_tx: &mpsc::UnboundedSender<PackageItem>,
pkgb_tx: &mpsc::UnboundedSender<PackageItem>,
comments_tx: &mpsc::UnboundedSender<String>,
pkgb_check_tx: &mpsc::UnboundedSender<PkgbuildCheckRequest>,
) -> bool {
if let CEvent::Key(ke) = ev {
if ke.kind != KeyEventKind::Press {
return false;
}
if ke.code == KeyCode::Char('t') && ke.modifiers.contains(KeyModifiers::CONTROL) {
tracing::debug!(
"[Event] Ctrl+T key event: code={:?}, mods={:?}, modal={:?}, focus={:?}",
ke.code,
ke.modifiers,
app.modal,
app.focus
);
}
if let Some(should_exit) = global::handle_global_key(
*ke,
app,
details_tx,
pkgb_tx,
comments_tx,
query_tx,
pkgb_check_tx,
) {
if ke.code == KeyCode::Char('t') && ke.modifiers.contains(KeyModifiers::CONTROL) {
tracing::debug!(
"[Event] Global handler returned should_exit={}",
should_exit
);
}
if should_exit {
return true; }
return false;
}
if ke.code == KeyCode::Char('t') && ke.modifiers.contains(KeyModifiers::CONTROL) {
tracing::warn!(
"[Event] Ctrl+T was NOT handled by global handler, continuing to other handlers"
);
}
if matches!(app.modal, crate::state::Modal::Preflight { .. }) {
return preflight::handle_preflight_key(*ke, app);
}
if modals::handle_modal_key(*ke, app, add_tx) {
return false;
}
if !matches!(app.modal, crate::state::Modal::None) {
return false;
}
if matches!(app.focus, Focus::Recent) {
let should_exit =
recent::handle_recent_key(*ke, app, query_tx, details_tx, preview_tx, add_tx);
return should_exit;
}
if matches!(app.focus, Focus::Install) {
let should_exit = install::handle_install_key(*ke, app, details_tx, preview_tx, add_tx);
return should_exit;
}
if matches!(app.focus, Focus::Search) {
let should_exit = search::handle_search_key(
*ke,
app,
query_tx,
details_tx,
add_tx,
preview_tx,
comments_tx,
);
return should_exit;
}
return false;
}
if let CEvent::Mouse(m) = ev {
return mouse::handle_mouse_event_with_pkgbuild_checks(
*m,
app,
details_tx,
preview_tx,
add_tx,
pkgb_tx,
comments_tx,
query_tx,
pkgb_check_tx,
);
}
false
}
#[cfg(all(test, not(target_os = "windows")))]
mod tests {
use super::*;
use crossterm::event::{
Event as CEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
#[test]
fn ui_options_update_system_enter_triggers_xfce4_args_shape() {
let _guard = crate::global_test_mutex_lock();
let mut dir: PathBuf = std::env::temp_dir();
dir.push(format!(
"pacsea_test_term_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
fs::create_dir_all(&dir).expect("create test directory");
let mut out_path = dir.clone();
out_path.push("args.txt");
let mut term_path = dir.clone();
term_path.push("xfce4-terminal");
let script = "#!/bin/sh\n: > \"$PACSEA_TEST_OUT\"\nfor a in \"$@\"; do printf '%s\n' \"$a\" >> \"$PACSEA_TEST_OUT\"; done\n";
fs::write(&term_path, script.as_bytes()).expect("Failed to write test terminal script");
let mut perms = fs::metadata(&term_path)
.expect("Failed to read test terminal script metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&term_path, perms)
.expect("Failed to set test terminal script permissions");
let orig_path = std::env::var_os("PATH");
let combined_path = std::env::var("PATH").map_or_else(
|_| dir.display().to_string(),
|p| format!("{}:{p}", dir.display()),
);
unsafe {
std::env::set_var("PATH", combined_path);
std::env::set_var("PACSEA_TEST_OUT", out_path.display().to_string());
std::env::set_var("PACSEA_TEST_HEADLESS", "1");
}
let mut app = AppState::default();
let (qtx, _qrx) = mpsc::unbounded_channel();
let (dtx, _drx) = mpsc::unbounded_channel();
let (ptx, _prx) = mpsc::unbounded_channel();
let (atx, _arx) = mpsc::unbounded_channel();
let (pkgb_tx, _pkgb_rx) = mpsc::unbounded_channel();
let (pkgb_check_tx, _pkgb_check_rx) = mpsc::unbounded_channel::<PkgbuildCheckRequest>();
app.options_button_rect = Some((5, 5, 10, 1));
let click_options = CEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 6,
row: 5,
modifiers: KeyModifiers::empty(),
});
let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
let _ = super::handle_event(
&click_options,
&mut app,
&qtx,
&dtx,
&ptx,
&atx,
&pkgb_tx,
&comments_tx,
&pkgb_check_tx,
);
assert!(app.options_menu_open);
app.options_menu_rect = Some((5, 6, 20, 3));
let click_menu_update = CEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 6,
row: 7,
modifiers: KeyModifiers::empty(),
});
let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
let _ = super::handle_event(
&click_menu_update,
&mut app,
&qtx,
&dtx,
&ptx,
&atx,
&pkgb_tx,
&comments_tx,
&pkgb_check_tx,
);
let enter = CEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
let _ = super::handle_event(
&enter,
&mut app,
&qtx,
&dtx,
&ptx,
&atx,
&pkgb_tx,
&comments_tx,
&pkgb_check_tx,
);
let mut attempts = 0;
while !out_path.exists() && attempts < 50 {
std::thread::sleep(std::time::Duration::from_millis(10));
attempts += 1;
}
std::thread::sleep(std::time::Duration::from_millis(100));
let body = fs::read_to_string(&out_path).expect("fake terminal args file written");
let lines: Vec<&str> = body.lines().collect();
let command_idx = lines.iter().rposition(|&l| l == "--command");
if command_idx.is_none() {
eprintln!(
"Warning: xfce4-terminal was not used (no --command found, got: {lines:?}), skipping xfce4-specific assertion"
);
unsafe {
if let Some(v) = orig_path {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
std::env::remove_var("PACSEA_TEST_OUT");
}
return;
}
let command_idx = command_idx.expect("command_idx should be Some after is_none() check");
assert!(
command_idx + 1 < lines.len(),
"--command found at index {command_idx} but no following argument. Lines: {lines:?}"
);
assert!(
lines[command_idx + 1].starts_with("bash -lc "),
"Expected argument after --command to start with 'bash -lc ', got: '{}'. All lines: {:?}",
lines[command_idx + 1],
lines
);
unsafe {
if let Some(v) = orig_path {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
std::env::remove_var("PACSEA_TEST_OUT");
}
}
#[test]
fn optional_deps_rows_reflect_installed_and_x11_and_reflector() {
let _guard = crate::global_test_mutex_lock();
let (dir, orig_path, orig_wl) = setup_test_executables();
let (mut app, channels) = setup_app_with_translations();
open_optional_deps_modal(&mut app, &channels);
verify_optional_deps_rows(&app.modal);
teardown_test_environment(orig_path, orig_wl, &dir);
}
fn setup_test_executables() -> (
std::path::PathBuf,
Option<std::ffi::OsString>,
Option<std::ffi::OsString>,
) {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
let mut dir: PathBuf = std::env::temp_dir();
dir.push(format!(
"pacsea_test_optional_deps_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
let _ = fs::create_dir_all(&dir);
let make_exec = |name: &str| {
let mut p = dir.clone();
p.push(name);
fs::write(&p, b"#!/bin/sh\nexit 0\n").expect("Failed to write test executable stub");
let mut perms = fs::metadata(&p)
.expect("Failed to read test executable stub metadata")
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&p, perms).expect("Failed to set test executable stub permissions");
};
make_exec("nvim");
make_exec("kitty");
let orig_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", dir.display().to_string());
std::env::set_var("PACSEA_TEST_HEADLESS", "1");
};
let orig_wl = std::env::var_os("WAYLAND_DISPLAY");
unsafe { std::env::remove_var("WAYLAND_DISPLAY") };
(dir, orig_path, orig_wl)
}
type AppChannels = (
tokio::sync::mpsc::UnboundedSender<QueryInput>,
tokio::sync::mpsc::UnboundedSender<PackageItem>,
tokio::sync::mpsc::UnboundedSender<PackageItem>,
tokio::sync::mpsc::UnboundedSender<PackageItem>,
tokio::sync::mpsc::UnboundedSender<PackageItem>,
tokio::sync::mpsc::UnboundedSender<String>,
tokio::sync::mpsc::UnboundedSender<PkgbuildCheckRequest>,
);
type SetupAppResult = (AppState, AppChannels);
fn setup_app_with_translations() -> SetupAppResult {
use std::collections::HashMap;
let mut app = AppState::default();
let mut translations = HashMap::new();
translations.insert(
"app.optional_deps.categories.editor".to_string(),
"Editor".to_string(),
);
translations.insert(
"app.optional_deps.categories.terminal".to_string(),
"Terminal".to_string(),
);
translations.insert(
"app.optional_deps.categories.clipboard".to_string(),
"Clipboard".to_string(),
);
translations.insert(
"app.optional_deps.categories.aur_helper".to_string(),
"AUR Helper".to_string(),
);
translations.insert(
"app.optional_deps.categories.security".to_string(),
"Security".to_string(),
);
app.translations.clone_from(&translations);
app.translations_fallback = translations;
let (qtx, _qrx) = mpsc::unbounded_channel();
let (dtx, _drx) = mpsc::unbounded_channel();
let (ptx, _prx) = mpsc::unbounded_channel();
let (atx, _arx) = mpsc::unbounded_channel();
let (pkgb_tx, _pkgb_rx) = mpsc::unbounded_channel();
let (comments_tx, _comments_rx) = mpsc::unbounded_channel();
let (pkgb_check_tx, _pkgb_check_rx) = mpsc::unbounded_channel::<PkgbuildCheckRequest>();
(
app,
(qtx, dtx, ptx, atx, pkgb_tx, comments_tx, pkgb_check_tx),
)
}
fn open_optional_deps_modal(app: &mut AppState, channels: &AppChannels) {
app.options_button_rect = Some((5, 5, 12, 1));
let click_options = CEvent::Mouse(crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 6,
row: 5,
modifiers: KeyModifiers::empty(),
});
let _ = super::handle_event(
&click_options,
app,
&channels.0,
&channels.1,
&channels.2,
&channels.3,
&channels.4,
&channels.5,
&channels.6,
);
assert!(app.options_menu_open);
let mut key_three_event =
crossterm::event::KeyEvent::new(KeyCode::Char('3'), KeyModifiers::empty());
key_three_event.kind = KeyEventKind::Press;
let key_three = CEvent::Key(key_three_event);
let _ = super::handle_event(
&key_three,
app,
&channels.0,
&channels.1,
&channels.2,
&channels.3,
&channels.4,
&channels.5,
&channels.6,
);
}
fn verify_optional_deps_rows(modal: &crate::state::Modal) {
match modal {
crate::state::Modal::OptionalDeps { rows, .. } => {
let find = |prefix: &str| rows.iter().find(|r| r.label.starts_with(prefix));
let ed = find("Editor: nvim").expect("editor row nvim");
assert!(ed.installed, "nvim should be marked installed");
assert!(!ed.selectable, "installed editor should not be selectable");
let term = find("Terminal: kitty").expect("terminal row kitty");
assert!(term.installed, "kitty should be marked installed");
assert!(
!term.selectable,
"installed terminal should not be selectable"
);
let clip = find("Clipboard: xclip").expect("clipboard xclip row");
assert!(
!clip.installed,
"xclip should not appear installed by default"
);
assert!(
clip.selectable,
"xclip should be selectable when not installed"
);
assert_eq!(clip.note.as_deref(), Some("X11"));
let mirrors = find("Mirrors: reflector").expect("reflector row");
assert!(
!mirrors.installed,
"reflector should not be installed by default"
);
assert!(mirrors.selectable, "reflector should be selectable");
let paru = find("AUR Helper: paru").expect("paru row");
assert!(!paru.installed);
assert!(paru.selectable);
let yay = find("AUR Helper: yay").expect("yay row");
assert!(!yay.installed);
assert!(yay.selectable);
}
other => panic!("Expected OptionalDeps modal, got {other:?}"),
}
}
fn teardown_test_environment(
orig_path: Option<std::ffi::OsString>,
orig_wl: Option<std::ffi::OsString>,
dir: &std::path::PathBuf,
) {
unsafe {
if let Some(v) = orig_path {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
if let Some(v) = orig_wl {
std::env::set_var("WAYLAND_DISPLAY", v);
} else {
std::env::remove_var("WAYLAND_DISPLAY");
}
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn optional_deps_rows_wayland_shows_wl_clipboard() {
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
let _guard = crate::global_test_mutex_lock();
let mut dir: PathBuf = std::env::temp_dir();
dir.push(format!(
"pacsea_test_optional_deps_wl_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
let _ = fs::create_dir_all(&dir);
let orig_path = std::env::var_os("PATH");
unsafe {
std::env::set_var("PATH", dir.display().to_string());
std::env::set_var("PACSEA_TEST_HEADLESS", "1");
};
let orig_wl = std::env::var_os("WAYLAND_DISPLAY");
unsafe { std::env::set_var("WAYLAND_DISPLAY", "1") };
let mut app = AppState::default();
let mut translations = HashMap::new();
translations.insert(
"app.optional_deps.categories.editor".to_string(),
"Editor".to_string(),
);
translations.insert(
"app.optional_deps.categories.terminal".to_string(),
"Terminal".to_string(),
);
translations.insert(
"app.optional_deps.categories.clipboard".to_string(),
"Clipboard".to_string(),
);
translations.insert(
"app.optional_deps.categories.aur_helper".to_string(),
"AUR Helper".to_string(),
);
translations.insert(
"app.optional_deps.categories.security".to_string(),
"Security".to_string(),
);
app.translations.clone_from(&translations);
app.translations_fallback = translations;
let (qtx, _qrx) = mpsc::unbounded_channel();
let (dtx, _drx) = mpsc::unbounded_channel();
let (ptx, _prx) = mpsc::unbounded_channel();
let (atx, _arx) = mpsc::unbounded_channel();
let (pkgb_tx, _pkgb_rx) = mpsc::unbounded_channel();
let (pkgb_check_tx, _pkgb_check_rx) = mpsc::unbounded_channel::<PkgbuildCheckRequest>();
app.options_button_rect = Some((5, 5, 12, 1));
let click_options = CEvent::Mouse(crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 6,
row: 5,
modifiers: KeyModifiers::empty(),
});
let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
let _ = super::handle_event(
&click_options,
&mut app,
&qtx,
&dtx,
&ptx,
&atx,
&pkgb_tx,
&comments_tx,
&pkgb_check_tx,
);
assert!(app.options_menu_open);
let mut key_three_event =
crossterm::event::KeyEvent::new(KeyCode::Char('3'), KeyModifiers::empty());
key_three_event.kind = KeyEventKind::Press;
let key_three = CEvent::Key(key_three_event);
let (comments_tx, _comments_rx) = mpsc::unbounded_channel::<String>();
let _ = super::handle_event(
&key_three,
&mut app,
&qtx,
&dtx,
&ptx,
&atx,
&pkgb_tx,
&comments_tx,
&pkgb_check_tx,
);
match &app.modal {
crate::state::Modal::OptionalDeps { rows, .. } => {
let clip = rows
.iter()
.find(|r| r.label.starts_with("Clipboard: wl-clipboard"))
.expect("wl-clipboard row");
assert_eq!(clip.note.as_deref(), Some("Wayland"));
assert!(!clip.installed);
assert!(clip.selectable);
assert!(
!rows.iter().any(|r| r.label.starts_with("Clipboard: xclip")),
"xclip should not be listed on Wayland"
);
}
other => panic!("Expected OptionalDeps modal, got {other:?}"),
}
unsafe {
if let Some(v) = orig_path {
std::env::set_var("PATH", v);
} else {
std::env::remove_var("PATH");
}
if let Some(v) = orig_wl {
std::env::set_var("WAYLAND_DISPLAY", v);
} else {
std::env::remove_var("WAYLAND_DISPLAY");
}
}
let _ = fs::remove_dir_all(&dir);
}
}