use ratatui::Terminal;
use tokio::select;
use crate::i18n;
use crate::state::types::NewsFeedPayload;
use crate::state::{AppState, PackageItem};
use crate::ui::ui;
use crate::util::parse_update_entry;
use tracing::info;
use super::background::Channels;
use super::handlers::{
handle_add_to_install_list, handle_dependency_result, handle_details_update,
handle_file_result, handle_preview, handle_sandbox_result, handle_search_results,
handle_service_result,
};
use super::tick_handler::{
handle_comments_result, handle_news, handle_pkgbuild_check_result, handle_pkgbuild_result,
handle_status, handle_summary_result, handle_tick,
};
fn parse_updates_file(updates_file: &std::path::Path) -> Vec<(String, String, String)> {
if updates_file.exists() {
std::fs::read_to_string(updates_file)
.ok()
.map(|content| {
content
.lines()
.filter_map(parse_update_entry)
.collect::<Vec<(String, String, String)>>()
})
.unwrap_or_default()
} else {
Vec::new()
}
}
fn handle_add_batch(app: &mut AppState, channels: &mut Channels, first: PackageItem) {
let mut batch = vec![first];
while let Ok(it) = channels.add_rx.try_recv() {
batch.push(it);
}
for it in batch {
handle_add_to_install_list(
app,
it,
&channels.deps_req_tx,
&channels.files_req_tx,
&channels.services_req_tx,
&channels.sandbox_req_tx,
);
}
}
fn handle_file_result_with_logging(
app: &mut AppState,
channels: &Channels,
files: &[crate::state::modal::PackageFileInfo],
) {
tracing::debug!(
"[Runtime] Received file result: {} entries for packages: {:?}",
files.len(),
files.iter().map(|f| &f.name).collect::<Vec<_>>()
);
for file_info in files {
tracing::debug!(
"[Runtime] Package '{}' - total={}, new={}, changed={}, removed={}, config={}",
file_info.name,
file_info.total_count,
file_info.new_count,
file_info.changed_count,
file_info.removed_count,
file_info.config_count
);
}
handle_file_result(app, files, &channels.tick_tx);
}
fn handle_remote_announcement(
app: &mut AppState,
announcement: crate::announcements::RemoteAnnouncement,
) {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
if !crate::announcements::version_matches(
CURRENT_VERSION,
announcement.min_version.as_deref(),
announcement.max_version.as_deref(),
) {
tracing::debug!(
id = %announcement.id,
current_version = CURRENT_VERSION,
min_version = ?announcement.min_version,
max_version = ?announcement.max_version,
"announcement version range mismatch"
);
return;
}
if crate::announcements::is_expired(announcement.expires.as_deref()) {
tracing::debug!(
id = %announcement.id,
expires = ?announcement.expires,
"announcement expired"
);
return;
}
if app.announcements_read_ids.contains(&announcement.id) {
tracing::info!(
id = %announcement.id,
"remote announcement already marked as read"
);
return;
}
if matches!(app.modal, crate::state::Modal::None) {
app.modal = crate::state::Modal::Announcement {
title: announcement.title,
content: announcement.content,
id: announcement.id,
scroll: 0,
};
tracing::info!("showing remote announcement modal");
} else {
let announcement_id = announcement.id.clone();
app.pending_announcements.push(announcement);
tracing::info!(
id = %announcement_id,
queue_size = app.pending_announcements.len(),
"queued remote announcement (modal already open)"
);
}
}
fn handle_index_notification(app: &mut AppState, channels: &Channels) -> bool {
app.loading_index = false;
crate::logic::send_query(app, &channels.query_tx);
let _ = channels.tick_tx.send(());
false
}
fn handle_updates_list(
app: &mut AppState,
payload: crate::app::runtime::workers::UpdateCheckPayload,
) {
let count = payload.count;
let list = payload.package_names;
app.updates_last_check_authoritative = Some(payload.authoritative);
app.updates_count = Some(count);
app.updates_list = list;
app.updates_loading = false;
if !payload.authoritative {
app.toast_message = Some(i18n::t(app, "app.toasts.update_check_degraded"));
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(8));
tracing::info!(
authoritative = false,
reasons = %payload.reason_codes.join(","),
strategy = payload.official_strategy,
"update check completed in degraded mode for official repositories"
);
}
if app.pending_updates_modal {
app.pending_updates_modal = false;
let updates_file = crate::theme::lists_dir().join("available_updates.txt");
let entries = parse_updates_file(&updates_file);
let filtered_indices: Vec<usize> = (0..entries.len()).collect();
app.modal = crate::state::Modal::Updates {
entries,
scroll: 0,
selected: 0,
filter_active: false,
filter_query: String::new(),
filter_caret: 0,
last_selected_pkg_name: None,
filtered_indices,
selected_pkg_names: std::collections::HashSet::new(),
};
}
}
fn handle_aur_vote_response(
app: &mut AppState,
response: crate::app::runtime::workers::aur_vote::AurVoteResponse,
) {
match response.result {
Ok(outcome) => {
let state = match outcome.action {
crate::sources::VoteAction::Vote => crate::state::app_state::AurVoteStateUi::Voted,
crate::sources::VoteAction::Unvote => {
crate::state::app_state::AurVoteStateUi::NotVoted
}
};
if !outcome.dry_run {
app.aur_vote_state_by_pkgbase
.insert(outcome.pkgbase.clone(), state);
app.aur_vote_state_dirty = true;
}
app.toast_message = Some(outcome.message());
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(4));
}
Err(error) => match error {
crate::sources::AurVoteError::AlreadyVoted(pkgbase) => {
app.aur_vote_state_by_pkgbase.insert(
pkgbase.clone(),
crate::state::app_state::AurVoteStateUi::Voted,
);
app.aur_vote_state_dirty = true;
app.toast_message = Some(format!(
"Already voted for '{pkgbase}'. Local vote state synced."
));
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(4));
}
crate::sources::AurVoteError::NotVoted(pkgbase) => {
app.aur_vote_state_by_pkgbase.insert(
pkgbase.clone(),
crate::state::app_state::AurVoteStateUi::NotVoted,
);
app.aur_vote_state_dirty = true;
app.toast_message = Some(format!(
"No vote exists for '{pkgbase}'. Local vote state synced."
));
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(4));
}
other_error => {
let guidance = match &other_error {
crate::sources::AurVoteError::NotFound(_) => {
"Verify the selected package base name."
}
crate::sources::AurVoteError::AuthFailed(_) => {
"Upload your SSH public key to https://aur.archlinux.org/account and retry."
}
crate::sources::AurVoteError::Maintenance => {
"Wait for AUR maintenance to end and retry later."
}
crate::sources::AurVoteError::Banned => {
"Your IP is blocked from the SSH interface. Contact AUR support."
}
crate::sources::AurVoteError::Timeout(_)
| crate::sources::AurVoteError::NetworkError(_) => {
"Check network connectivity and SSH reachability."
}
crate::sources::AurVoteError::SshNotFound(_) => {
"Install openssh or configure aur_vote_ssh_command in settings.conf."
}
crate::sources::AurVoteError::Unexpected(_) => {
"Retry once, then inspect logs if the issue persists."
}
crate::sources::AurVoteError::AlreadyVoted(_)
| crate::sources::AurVoteError::NotVoted(_) => {
"Use the opposite action or leave as-is."
}
};
app.modal = crate::state::Modal::Alert {
message: format!("AUR vote failed: {other_error}\n\nNext step: {guidance}"),
};
}
},
}
}
fn handle_aur_vote_state_response(
app: &mut AppState,
response: crate::app::runtime::workers::aur_vote::AurVoteStateResponse,
) {
let pkgbase = response.pkgbase;
let next_state = match response.result {
Ok(crate::sources::AurPackageVoteState::Voted) => {
crate::state::app_state::AurVoteStateUi::Voted
}
Ok(crate::sources::AurPackageVoteState::NotVoted) => {
crate::state::app_state::AurVoteStateUi::NotVoted
}
Err(error) => {
if crate::sources::is_vote_state_unsupported_error(&error) {
app.aur_vote_state_lookup_supported = false;
match app.aur_vote_state_by_pkgbase.get(&pkgbase) {
Some(crate::state::app_state::AurVoteStateUi::Voted) => {
crate::state::app_state::AurVoteStateUi::Voted
}
Some(crate::state::app_state::AurVoteStateUi::NotVoted) => {
crate::state::app_state::AurVoteStateUi::NotVoted
}
_ => crate::state::app_state::AurVoteStateUi::Unknown,
}
} else {
crate::state::app_state::AurVoteStateUi::Error(format!("{error}"))
}
}
};
let should_persist = matches!(
next_state,
crate::state::app_state::AurVoteStateUi::Voted
| crate::state::app_state::AurVoteStateUi::NotVoted
);
app.aur_vote_state_by_pkgbase.insert(pkgbase, next_state);
if should_persist {
app.aur_vote_state_dirty = true;
}
}
fn handle_news_feed_items(app: &mut AppState, payload: NewsFeedPayload) {
tracing::info!(
items_count = payload.items.len(),
"received aggregated news feed payload in event loop"
);
app.news_items = payload.items;
app.news_seen_pkg_versions = payload.seen_pkg_versions;
app.news_seen_pkg_versions_dirty = true;
app.news_seen_aur_comments = payload.seen_aur_comments;
app.news_seen_aur_comments_dirty = true;
match serde_json::to_string_pretty(&app.news_items) {
Ok(serialized) => {
if let Err(e) = std::fs::write(&app.news_feed_path, serialized) {
tracing::warn!(error = %e, path = ?app.news_feed_path, "failed to persist news feed cache");
}
}
Err(e) => tracing::warn!(error = %e, "failed to serialize news feed cache"),
}
app.refresh_news_results();
app.news_loading = false;
app.toast_message = None;
app.toast_expires_at = None;
info!(
fetched = app.news_items.len(),
visible = app.news_results.len(),
max_age_days = app.news_max_age_days.map(i64::from),
installed_only = app.news_filter_installed_only,
arch_on = app.news_filter_show_arch_news,
advisories_on = app.news_filter_show_advisories,
"news feed updated"
);
if crate::sources::take_network_error() {
app.toast_message = Some("Network error: some news sources unreachable".to_string());
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
}
}
fn handle_incremental_news_item(app: &mut AppState, item: crate::state::types::NewsFeedItem) {
if app.news_items.iter().any(|existing| existing.id == item.id) {
tracing::debug!(
item_id = %item.id,
"incremental news item already exists, skipping"
);
return;
}
tracing::info!(
item_id = %item.id,
source = ?item.source,
title = %item.title,
"received incremental news item"
);
app.news_items.push(item);
app.refresh_news_results();
if let Ok(serialized) = serde_json::to_string_pretty(&app.news_items)
&& let Err(e) = std::fs::write(&app.news_feed_path, serialized)
{
tracing::warn!(error = %e, path = ?app.news_feed_path, "failed to persist incremental news feed cache");
}
}
fn handle_news_content(app: &mut AppState, url: &str, content: String) {
let is_error = content.starts_with("Failed to load content:");
if is_error {
tracing::debug!(
url,
"news_content: not caching error response to allow retry"
);
} else {
app.news_content_cache
.insert(url.to_string(), content.clone());
app.news_content_cache_dirty = true;
}
if let Some(selected_url) = app
.news_results
.get(app.news_selected)
.and_then(|selected| selected.url.as_deref())
&& selected_url == url
{
tracing::debug!(
url,
len = content.len(),
selected = app.news_selected,
"news_content: response matches selection"
);
app.news_content_loading = false;
app.news_content = if content.is_empty() {
None
} else {
Some(content)
};
} else {
tracing::debug!(
url,
len = content.len(),
selected = app.news_selected,
selected_url = ?app
.news_results
.get(app.news_selected)
.and_then(|selected| selected.url.as_deref()),
"news_content: response does not match current selection"
);
app.news_content_loading = false;
}
app.news_content_loading_since = None;
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
async fn process_channel_messages(app: &mut AppState, channels: &mut Channels) -> bool {
select! {
Some(ev) = channels.event_rx.recv() => {
crate::events::handle_event_with_pkgbuild_checks(
&ev,
app,
&channels.query_tx,
&channels.details_req_tx,
&channels.preview_tx,
&channels.add_tx,
&channels.pkgb_req_tx,
&channels.comments_req_tx,
&channels.pkgb_check_req_tx,
)
}
Some(()) = channels.index_notify_rx.recv() => {
handle_index_notification(app, channels)
}
Some(new_results) = channels.results_rx.recv() => {
handle_search_results(
app,
new_results,
&channels.details_req_tx,
&channels.index_notify_tx,
);
false
}
Some(details) = channels.details_res_rx.recv() => {
handle_details_update(app, &details, &channels.tick_tx);
false
}
Some(item) = channels.preview_rx.recv() => {
handle_preview(app, item, &channels.details_req_tx);
false
}
Some(first) = channels.add_rx.recv() => {
handle_add_batch(app, channels, first);
false
}
Some(deps) = channels.deps_res_rx.recv() => {
handle_dependency_result(app, &deps, &channels.tick_tx);
false
}
Some(files) = channels.files_res_rx.recv() => {
handle_file_result_with_logging(app, channels, &files);
false
}
Some(services) = channels.services_res_rx.recv() => {
handle_service_result(app, &services, &channels.tick_tx);
false
}
Some(sandbox_info) = channels.sandbox_res_rx.recv() => {
handle_sandbox_result(app, &sandbox_info, &channels.tick_tx);
false
}
Some(summary_outcome) = channels.summary_res_rx.recv() => {
handle_summary_result(app, summary_outcome, &channels.tick_tx);
false
}
Some((pkgname, text)) = channels.pkgb_res_rx.recv() => {
handle_pkgbuild_result(app, pkgname, text, &channels.tick_tx);
false
}
Some((pkgname, result)) = channels.comments_res_rx.recv() => {
handle_comments_result(app, pkgname, result, &channels.tick_tx);
false
}
Some(response) = channels.pkgb_check_res_rx.recv() => {
handle_pkgbuild_check_result(app, response, &channels.tick_tx);
false
}
Some(feed) = channels.news_feed_rx.recv() => {
handle_news_feed_items(app, feed);
false
}
Some(item) = channels.news_incremental_rx.recv() => {
handle_incremental_news_item(app, item);
false
}
Some((url, content)) = channels.news_content_res_rx.recv() => {
handle_news_content(app, &url, content);
false
}
Some(msg) = channels.net_err_rx.recv() => {
tracing::warn!(error = %msg, "Network error received");
#[cfg(not(windows))]
{
let is_details_unavailable = msg.starts_with("Official package details unavailable for")
|| msg.starts_with("AUR package details unavailable for");
if !is_details_unavailable {
app.modal = crate::state::Modal::Alert {
message: msg,
};
}
}
false
}
Some(()) = channels.tick_rx.recv() => {
handle_tick(
app,
&channels.query_tx,
&channels.details_req_tx,
&channels.pkgb_req_tx,
&channels.deps_req_tx,
&channels.files_req_tx,
&channels.services_req_tx,
&channels.sandbox_req_tx,
&channels.summary_req_tx,
&channels.updates_tx,
&channels.aur_vote_req_tx,
&channels.aur_vote_state_req_tx,
&channels.executor_req_tx,
&channels.post_summary_req_tx,
&channels.news_content_req_tx,
);
false
}
Some(items) = channels.news_rx.recv() => {
tracing::info!(
items_count = items.len(),
news_loading_before = app.news_loading,
"received news items from channel"
);
handle_news(app, &items);
tracing::info!(
news_loading_after = app.news_loading,
modal = ?app.modal,
"handle_news completed"
);
false
}
Some(announcement) = channels.announcement_rx.recv() => {
handle_remote_announcement(app, announcement);
false
}
Some((txt, color)) = channels.status_rx.recv() => {
handle_status(app, &txt, color);
false
}
Some(payload) = channels.updates_rx.recv() => { handle_updates_list(app, payload); false }
Some(aur_vote_response) = channels.aur_vote_res_rx.recv() => { handle_aur_vote_response(app, aur_vote_response); false }
Some(aur_vote_state_response) = channels.aur_vote_state_res_rx.recv() => { handle_aur_vote_state_response(app, aur_vote_state_response); false }
Some(executor_output) = channels.executor_res_rx.recv() => {
handle_executor_output(app, executor_output);
false
}
Some(post_summary_data) = channels.post_summary_res_rx.recv() => {
handle_post_summary_result(app, post_summary_data);
false
}
else => false
}
}
fn handle_post_summary_result(app: &mut AppState, data: crate::logic::summary::PostSummaryData) {
if matches!(app.modal, crate::state::Modal::Loading { .. }) {
tracing::debug!(
success = data.success,
changed_files = data.changed_files,
pacnew_count = data.pacnew_count,
pacsave_count = data.pacsave_count,
services_pending = data.services_pending.len(),
snapshot_label = ?data.snapshot_label,
"[EventLoop] Transitioning modal: Loading -> PostSummary"
);
app.modal = crate::state::Modal::PostSummary {
success: data.success,
changed_files: data.changed_files,
pacnew_count: data.pacnew_count,
pacsave_count: data.pacsave_count,
services_pending: data.services_pending,
snapshot_label: data.snapshot_label,
};
}
}
fn handle_install_success(app: &mut AppState, items: &[crate::state::PackageItem]) {
if !items.is_empty() {
let installed_names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
app.pending_install_names = Some(installed_names);
}
app.refresh_installed_until =
Some(std::time::Instant::now() + std::time::Duration::from_secs(8));
app.refresh_updates = true;
tracing::info!(
"Install operation completed: triggered refresh of installed packages and updates"
);
}
fn handle_remove_success(app: &mut AppState, items: &[crate::state::PackageItem]) {
let removed_names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
app.remove_list.clear();
app.remove_list_names.clear();
app.remove_state.select(None);
app.pending_remove_names = Some(removed_names);
app.refresh_installed_until =
Some(std::time::Instant::now() + std::time::Duration::from_secs(8));
app.refresh_updates = true;
tracing::info!("Remove operation completed: cleared remove list and triggered refresh");
}
fn handle_downgrade_success(app: &mut AppState, items: &[crate::state::PackageItem]) {
let downgraded_names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
app.downgrade_list.clear();
app.downgrade_list_names.clear();
app.downgrade_state.select(None);
app.pending_remove_names = Some(downgraded_names);
app.refresh_installed_until =
Some(std::time::Instant::now() + std::time::Duration::from_secs(8));
app.refresh_updates = true;
tracing::info!("Downgrade operation completed: cleared downgrade list and triggered refresh");
}
#[allow(clippy::too_many_lines)] fn handle_executor_output(app: &mut AppState, output: crate::install::ExecutorOutput) {
match &output {
crate::install::ExecutorOutput::Line(line) => {
tracing::trace!(
"[EventLoop] Received executor line: {}...",
&line[..line.len().min(50)]
);
}
crate::install::ExecutorOutput::ReplaceLastLine(line) => {
tracing::trace!(
"[EventLoop] Received executor replace line: {}...",
&line[..line.len().min(50)]
);
}
crate::install::ExecutorOutput::Finished {
success,
exit_code,
failed_command: _,
} => {
tracing::debug!(
"[EventLoop] Received executor Finished: success={}, exit_code={:?}",
success,
exit_code
);
}
crate::install::ExecutorOutput::Error(err) => {
tracing::warn!("[EventLoop] Received executor Error: {}", err);
}
}
if let crate::state::Modal::PreflightExec {
ref mut log_lines,
ref mut abortable,
ref mut success,
ref items,
ref action,
..
} = app.modal
{
match output {
crate::install::ExecutorOutput::Line(line) => {
log_lines.push(line);
if log_lines.len() > 1000 {
log_lines.remove(0);
}
tracing::debug!(
"[EventLoop] PreflightExec log_lines count: {}",
log_lines.len()
);
}
crate::install::ExecutorOutput::ReplaceLastLine(line) => {
if log_lines.is_empty() {
log_lines.push(line);
} else {
let last_idx = log_lines.len() - 1;
log_lines[last_idx] = line;
}
}
crate::install::ExecutorOutput::Finished {
success: exec_success,
exit_code,
failed_command: _,
} => {
tracing::info!(
"Received Finished: success={exec_success}, exit_code={exit_code:?}"
);
*abortable = false;
if !exec_success {
app.pending_repo_apply_overlap_check = None;
app.pending_repositories_modal_resume = None;
}
*success = Some(exec_success);
log_lines.push(String::new()); if exec_success {
let completion_msg = match action {
crate::state::PreflightAction::Install => {
"Installation successfully completed!".to_string()
}
crate::state::PreflightAction::Remove => {
"Removal successfully completed!".to_string()
}
crate::state::PreflightAction::Downgrade => {
"Downgrade successfully completed!".to_string()
}
};
log_lines.push(completion_msg);
tracing::info!(
"Added completion message, log_lines.len()={}",
log_lines.len()
);
let items_clone = items.clone();
let action_clone = *action;
match action_clone {
crate::state::PreflightAction::Install => {
handle_install_success(app, &items_clone);
}
crate::state::PreflightAction::Remove => {
handle_remove_success(app, &items_clone);
}
crate::state::PreflightAction::Downgrade => {
handle_downgrade_success(app, &items_clone);
}
}
} else {
log_lines.push(format!("Execution failed (exit code: {exit_code:?})"));
if items.is_empty() && app.pending_aur_update_command.is_some() {
tracing::info!(
"[EventLoop] System update failed (exit_code: {:?}), AUR update pending - showing confirmation popup",
exit_code
);
let failed_command_name = app
.pending_update_commands
.as_ref()
.and_then(|cmds| {
cmds.first().map(|cmd| {
if cmd.contains("pacman") {
"pacman"
} else if cmd.contains("paru") {
"paru"
} else if cmd.contains("yay") {
"yay"
} else if cmd.contains("reflector") {
"reflector"
} else if cmd.contains("pacman-mirrors") {
"pacman-mirrors"
} else if cmd.contains("eos-rankmirrors") {
"eos-rankmirrors"
} else if cmd.contains("cachyos-rate-mirrors") {
"cachyos-rate-mirrors"
} else {
"update command"
}
})
})
.unwrap_or("update command");
let exit_code_str =
exit_code.map_or_else(|| "unknown".to_string(), |c| c.to_string());
app.modal = crate::state::Modal::ConfirmAurUpdate {
message: format!(
"{}\n\n{}\n{}\n\n{}",
i18n::t_fmt2(
app,
"app.modals.confirm_aur_update.command_failed",
failed_command_name,
&exit_code_str
),
i18n::t(app, "app.modals.confirm_aur_update.continue_prompt"),
i18n::t(app, "app.modals.confirm_aur_update.warning"),
i18n::t(app, "app.modals.confirm_aur_update.hint")
),
};
} else {
tracing::debug!(
"[EventLoop] System update failed but no confirmation popup - items.is_empty(): {}, pending_aur_update_command.is_some(): {}",
items.is_empty(),
app.pending_aur_update_command.is_some()
);
}
}
}
crate::install::ExecutorOutput::Error(err) => {
*abortable = false;
log_lines.push(format!("Error: {err}"));
}
}
} else {
tracing::warn!(
"[EventLoop] Received executor output but modal is not PreflightExec, modal={:?}",
std::mem::discriminant(&app.modal)
);
}
}
fn trigger_startup_news_fetch(channels: &Channels, app: &mut AppState) {
use crate::sources;
use crate::state::types::NewsSortMode;
use std::collections::HashSet;
let prefs = crate::theme::settings();
if !prefs.startup_news_configured {
return;
}
app.news_loading = true;
tracing::info!("news_loading set to true, triggering startup news fetch");
let news_tx = channels.news_tx.clone();
let read_urls = app.news_read_urls.clone();
let read_ids = app.news_read_ids.clone();
let installed: HashSet<String> = crate::index::explicit_names().into_iter().collect();
let mut seen_versions = app.news_seen_pkg_versions.clone();
let mut seen_aur_comments = app.news_seen_aur_comments.clone();
tokio::spawn(async move {
tracing::info!("on-demand startup news fetch task started");
let mut installed_set = installed;
if installed_set.is_empty() {
crate::index::refresh_installed_cache().await;
crate::index::refresh_explicit_cache(crate::state::InstalledPackagesMode::AllExplicit)
.await;
let refreshed: HashSet<String> = crate::index::explicit_names().into_iter().collect();
if !refreshed.is_empty() {
installed_set = refreshed;
}
}
let include_pkg_updates =
prefs.startup_news_show_pkg_updates || prefs.startup_news_show_aur_updates;
#[allow(clippy::items_after_statements)]
const STARTUP_NEWS_LIMIT: usize = 20;
let updates_limit =
if prefs.startup_news_show_pkg_updates && prefs.startup_news_show_aur_updates {
STARTUP_NEWS_LIMIT * 2
} else {
STARTUP_NEWS_LIMIT
};
let ctx = sources::NewsFeedContext {
force_emit_all: true,
updates_list_path: Some(crate::theme::lists_dir().join("available_updates.txt")),
limit: updates_limit,
include_arch_news: prefs.startup_news_show_arch_news,
include_advisories: prefs.startup_news_show_advisories,
include_pkg_updates,
include_aur_comments: prefs.startup_news_show_aur_comments,
installed_filter: Some(&installed_set),
installed_only: false,
sort_mode: NewsSortMode::DateDesc,
seen_pkg_versions: &mut seen_versions,
seen_aur_comments: &mut seen_aur_comments,
max_age_days: prefs.startup_news_max_age_days,
};
tracing::info!(
limit = updates_limit,
include_arch_news = prefs.startup_news_show_arch_news,
include_advisories = prefs.startup_news_show_advisories,
include_pkg_updates,
include_aur_comments = prefs.startup_news_show_aur_comments,
max_age_days = ?prefs.startup_news_max_age_days,
installed_count = installed_set.len(),
"starting on-demand startup news fetch"
);
match sources::fetch_news_feed(ctx).await {
Ok(feed) => {
tracing::info!(
total_items = feed.len(),
"on-demand startup news fetch completed successfully"
);
let source_filtered: Vec<crate::state::types::NewsFeedItem> = feed
.into_iter()
.filter(|item| match item.source {
crate::state::types::NewsFeedSource::ArchNews => {
prefs.startup_news_show_arch_news
}
crate::state::types::NewsFeedSource::SecurityAdvisory => {
prefs.startup_news_show_advisories
}
crate::state::types::NewsFeedSource::InstalledPackageUpdate => {
prefs.startup_news_show_pkg_updates
}
crate::state::types::NewsFeedSource::AurPackageUpdate => {
prefs.startup_news_show_aur_updates
}
crate::state::types::NewsFeedSource::AurComment => {
prefs.startup_news_show_aur_comments
}
})
.collect();
let filtered: Vec<crate::state::types::NewsFeedItem> =
if let Some(max_days) = prefs.startup_news_max_age_days {
let cutoff_date = chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(i64::from(max_days)))
.map(|dt| dt.format("%Y-%m-%d").to_string());
#[allow(clippy::unnecessary_map_or)]
let filtered_items = source_filtered
.into_iter()
.filter(|item| {
cutoff_date
.as_ref()
.map_or(true, |cutoff| &item.date >= cutoff)
})
.collect();
filtered_items
} else {
source_filtered
};
#[allow(clippy::unnecessary_map_or)]
let unread: Vec<crate::state::types::NewsFeedItem> = filtered
.into_iter()
.filter(|item| {
!read_ids.contains(&item.id)
&& item.url.as_ref().is_none_or(|url| !read_urls.contains(url))
})
.collect();
tracing::info!(
unread_count = unread.len(),
"sending on-demand startup news items to channel"
);
match news_tx.send(unread) {
Ok(()) => {
tracing::info!("on-demand startup news items sent to channel successfully");
}
Err(e) => {
tracing::error!(
error = %e,
"failed to send on-demand startup news items to channel (receiver dropped?)"
);
}
}
}
Err(e) => {
tracing::warn!(error = %e, "on-demand startup news fetch failed");
tracing::info!("sending empty array to clear loading flag after fetch error");
let _ = news_tx.send(Vec::new());
}
}
});
}
#[cfg(test)]
mod startup_news_tests {
use crate::state::types::{NewsFeedItem, NewsFeedSource};
use std::collections::HashSet;
#[test]
fn test_filter_already_read_items() {
let read_ids: HashSet<String> = HashSet::from(["id-1".to_string()]);
let read_urls: HashSet<String> = HashSet::from(["https://example.com/news/2".to_string()]);
let items = vec![
NewsFeedItem {
id: "id-1".to_string(),
date: "2025-01-01".to_string(),
title: "Item 1".to_string(),
summary: None,
url: Some("https://example.com/news/1".to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
},
NewsFeedItem {
id: "id-2".to_string(),
date: "2025-01-02".to_string(),
title: "Item 2".to_string(),
summary: None,
url: Some("https://example.com/news/2".to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
},
NewsFeedItem {
id: "id-3".to_string(),
date: "2025-01-03".to_string(),
title: "Item 3".to_string(),
summary: None,
url: Some("https://example.com/news/3".to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
},
];
let unread: Vec<NewsFeedItem> = items
.into_iter()
.filter(|item| {
!read_ids.contains(&item.id)
&& item.url.as_ref().is_none_or(|url| !read_urls.contains(url))
})
.collect();
assert_eq!(unread.len(), 1);
assert_eq!(unread[0].id, "id-3");
}
}
pub async fn run_event_loop(
terminal: &mut Option<Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>>,
app: &mut AppState,
channels: &mut Channels,
) {
loop {
if app.trigger_startup_news_fetch {
app.trigger_startup_news_fetch = false;
trigger_startup_news_fetch(channels, &mut *app);
}
if let Some(t) = terminal.as_mut() {
let _ = t.draw(|f| ui(f, app));
}
if process_channel_messages(app, channels).await {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::handle_aur_vote_response;
use super::handle_aur_vote_state_response;
use super::handle_index_notification;
use super::handle_news_content;
use super::handle_updates_list;
use crate::app::runtime::background::Channels;
use crate::app::runtime::workers::UpdateCheckPayload;
use crate::state::AppState;
use crate::state::types::{NewsFeedItem, NewsFeedSource};
fn make_news_item(id: &str, url: &str) -> NewsFeedItem {
NewsFeedItem {
id: id.to_string(),
date: "2024-01-01".to_string(),
title: format!("Title {id}"),
summary: None,
url: Some(url.to_string()),
source: NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
}
}
#[test]
fn handle_news_content_keeps_loading_for_mismatched_url() {
let mut app = AppState {
news_results: vec![
make_news_item("a", "https://example.com/a"),
make_news_item("b", "https://example.com/b"),
],
news_selected: 1,
news_content_loading: true,
..AppState::default()
};
handle_news_content(&mut app, "https://example.com/a", "old".to_string());
assert!(!app.news_content_loading);
assert!(app.news_content.is_none());
assert!(app.news_content_cache.contains_key("https://example.com/a"));
}
#[test]
fn handle_news_content_updates_current_selection() {
let mut app = AppState {
news_results: vec![make_news_item("a", "https://example.com/a")],
news_content_loading: true,
..AppState::default()
};
handle_news_content(&mut app, "https://example.com/a", "payload".to_string());
assert!(!app.news_content_loading);
assert_eq!(app.news_content, Some("payload".to_string()));
assert!(app.news_content_cache.contains_key("https://example.com/a"));
}
#[test]
fn handle_updates_list_degraded_surfaces_toast() {
let mut app = AppState::default();
let payload = UpdateCheckPayload {
count: 0,
package_names: Vec::new(),
authoritative: false,
reason_codes: vec!["stale_db_fallback".to_string()],
official_strategy: "stale_pacman_qu",
};
handle_updates_list(&mut app, payload);
assert_eq!(app.updates_last_check_authoritative, Some(false));
assert!(app.toast_message.is_some());
assert!(app.toast_expires_at.is_some());
}
#[test]
fn handle_updates_list_authoritative_skips_degraded_toast() {
let mut app = AppState::default();
let payload = UpdateCheckPayload {
count: 2,
package_names: vec!["a".to_string(), "b".to_string()],
authoritative: true,
reason_codes: Vec::new(),
official_strategy: "checkupdates_db",
};
handle_updates_list(&mut app, payload);
assert_eq!(app.updates_last_check_authoritative, Some(true));
assert!(app.toast_message.is_none());
}
#[tokio::test]
async fn handle_index_notification_retriggers_query() {
let mut app = AppState {
loading_index: true,
..AppState::default()
};
let channels = Channels::new(std::path::PathBuf::from("/tmp"));
let latest_before = app.latest_query_id;
let should_exit = handle_index_notification(&mut app, &channels);
assert!(!should_exit);
assert!(!app.loading_index);
assert!(app.latest_query_id > latest_before);
}
#[test]
fn handle_aur_vote_response_success_sets_toast() {
let mut app = AppState::default();
let response = crate::app::runtime::workers::aur_vote::AurVoteResponse {
result: Ok(crate::sources::AurVoteOutcome {
action: crate::sources::VoteAction::Vote,
pkgbase: "pacsea-bin".to_string(),
dry_run: false,
}),
};
handle_aur_vote_response(&mut app, response);
let toast = app
.toast_message
.as_ref()
.expect("success vote should set a toast");
assert!(toast.contains("Voted for"));
assert!(app.toast_expires_at.is_some());
}
#[test]
fn handle_aur_vote_response_dry_run_does_not_mark_cache_dirty() {
let mut app = AppState::default();
let before_vote_state = app.aur_vote_state_by_pkgbase.clone();
let before_dirty = app.aur_vote_state_dirty;
let response = crate::app::runtime::workers::aur_vote::AurVoteResponse {
result: Ok(crate::sources::AurVoteOutcome {
action: crate::sources::VoteAction::Vote,
pkgbase: "pacsea-bin".to_string(),
dry_run: true,
}),
};
handle_aur_vote_response(&mut app, response);
assert_eq!(app.aur_vote_state_by_pkgbase, before_vote_state);
assert_eq!(app.aur_vote_state_dirty, before_dirty);
let toast = app
.toast_message
.as_ref()
.expect("dry-run vote should set a toast");
assert!(toast.contains("[dry-run]"));
assert!(app.toast_expires_at.is_some());
}
#[test]
fn handle_aur_vote_response_auth_failure_sets_alert() {
let mut app = AppState::default();
let response = crate::app::runtime::workers::aur_vote::AurVoteResponse {
result: Err(crate::sources::AurVoteError::AuthFailed(
"Permission denied".to_string(),
)),
};
handle_aur_vote_response(&mut app, response);
match app.modal {
crate::state::Modal::Alert { message } => {
assert!(message.contains("AUR vote failed"));
assert!(message.contains("Upload your SSH public key"));
}
other => panic!("expected alert modal, got {other:?}"),
}
}
#[test]
fn handle_aur_vote_response_already_voted_syncs_cache() {
let mut app = AppState::default();
let response = crate::app::runtime::workers::aur_vote::AurVoteResponse {
result: Err(crate::sources::AurVoteError::AlreadyVoted(
"pacsea-bin".to_string(),
)),
};
handle_aur_vote_response(&mut app, response);
assert!(matches!(
app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
Some(crate::state::app_state::AurVoteStateUi::Voted)
));
assert!(app.aur_vote_state_dirty);
assert!(matches!(app.modal, crate::state::Modal::None));
assert!(
app.toast_message
.as_ref()
.is_some_and(|msg| msg.contains("Already voted"))
);
}
#[test]
fn handle_aur_vote_response_not_voted_syncs_cache() {
let mut app = AppState::default();
let response = crate::app::runtime::workers::aur_vote::AurVoteResponse {
result: Err(crate::sources::AurVoteError::NotVoted(
"pacsea-bin".to_string(),
)),
};
handle_aur_vote_response(&mut app, response);
assert!(matches!(
app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
Some(crate::state::app_state::AurVoteStateUi::NotVoted)
));
assert!(app.aur_vote_state_dirty);
assert!(matches!(app.modal, crate::state::Modal::None));
assert!(
app.toast_message
.as_ref()
.is_some_and(|msg| msg.contains("No vote exists"))
);
}
#[test]
fn handle_aur_vote_state_response_updates_cache() {
let mut app = AppState::default();
let response = crate::app::runtime::workers::aur_vote::AurVoteStateResponse {
pkgbase: "pacsea-bin".to_string(),
result: Ok(crate::sources::AurPackageVoteState::Voted),
};
handle_aur_vote_state_response(&mut app, response);
assert!(matches!(
app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
Some(crate::state::app_state::AurVoteStateUi::Voted)
));
}
#[test]
fn handle_aur_vote_state_response_unsupported_maps_to_unknown() {
let mut app = AppState::default();
let pkgbase = "pkg-unsupported-unknown-test";
let response = crate::app::runtime::workers::aur_vote::AurVoteStateResponse {
pkgbase: pkgbase.to_string(),
result: Err(crate::sources::AurVoteError::Unexpected(
"AUR SSH server does not support vote-state lookup.".to_string(),
)),
};
handle_aur_vote_state_response(&mut app, response);
assert!(matches!(
app.aur_vote_state_by_pkgbase.get(pkgbase),
Some(crate::state::app_state::AurVoteStateUi::Unknown)
));
assert!(!app.aur_vote_state_lookup_supported);
}
#[test]
fn handle_aur_vote_state_response_unsupported_keeps_stable_cache() {
let mut app = AppState::default();
app.aur_vote_state_by_pkgbase.insert(
"pacsea-bin".to_string(),
crate::state::app_state::AurVoteStateUi::Voted,
);
let response = crate::app::runtime::workers::aur_vote::AurVoteStateResponse {
pkgbase: "pacsea-bin".to_string(),
result: Err(crate::sources::AurVoteError::Unexpected(
"AUR SSH server does not support vote-state lookup.".to_string(),
)),
};
handle_aur_vote_state_response(&mut app, response);
assert!(matches!(
app.aur_vote_state_by_pkgbase.get("pacsea-bin"),
Some(crate::state::app_state::AurVoteStateUi::Voted)
));
assert!(!app.aur_vote_state_lookup_supported);
}
}