use ratatui::{
prelude::Rect,
style::{Modifier, Style},
text::{Line, Span},
};
use crate::i18n;
use crate::state::modal::{FileChangeType, PackageFileInfo};
use crate::state::{AppState, PackageItem};
use crate::theme::theme;
use crate::ui::helpers::ChangeLogger;
use crate::ui::modals::preflight::helpers::format_count_with_indicator;
use std::sync::{Mutex, OnceLock};
type FileDisplayItem = (
bool,
String,
Option<(FileChangeType, String, bool, bool, bool)>,
);
#[derive(Clone, PartialEq, Eq)]
struct FilePackageSummary {
name: String,
total: usize,
new_count: usize,
changed_count: usize,
removed_count: usize,
config_count: usize,
file_entries: usize,
}
#[derive(Clone, PartialEq, Eq)]
struct FilesLogState {
items_len: usize,
file_info_len: usize,
file_selected: usize,
expanded_len: usize,
preflight_resolving: bool,
global_resolving: bool,
has_error: bool,
package_summaries: Vec<FilePackageSummary>,
}
fn log_files_state(state: &FilesLogState) {
static LOGGER: OnceLock<Mutex<ChangeLogger<FilesLogState>>> = OnceLock::new();
let mut logger = LOGGER
.get_or_init(|| Mutex::new(ChangeLogger::new()))
.lock()
.expect("Files log state mutex poisoned");
if logger.should_log(state) {
let package_text = if state.package_summaries.is_empty() {
"no file details".to_string()
} else {
state
.package_summaries
.iter()
.map(|p| {
format!(
"{}: total={}, new={}, changed={}, removed={}, config={}, files={}",
p.name,
p.total,
p.new_count,
p.changed_count,
p.removed_count,
p.config_count,
p.file_entries
)
})
.collect::<Vec<_>>()
.join(" | ")
};
tracing::debug!(
"[UI] render_files_tab: items={}, file_info={}, file_selected={}, expanded={}, resolving={}/{}, error={}, packages=[{}]",
state.items_len,
state.file_info_len,
state.file_selected,
state.expanded_len,
state.preflight_resolving,
state.global_resolving,
state.has_error,
package_text
);
}
}
fn render_loading_state(
app: &AppState,
items: &[PackageItem],
message_key: &str,
message_color: ratatui::style::Color,
) -> Vec<Line<'static>> {
let th = theme();
let mut lines = Vec::new();
for item in items {
let mut spans = Vec::new();
spans.push(Span::styled(
format!("â–¶ {} ", item.name),
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled("(0 files)", Style::default().fg(th.subtext1)));
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
i18n::t(app, message_key),
Style::default().fg(message_color),
)));
lines
}
fn render_error_state(app: &AppState, err_msg: &str) -> Vec<Line<'static>> {
let th = theme();
vec![
Line::from(Span::styled(
i18n::t_fmt1(app, "app.modals.preflight.files.error", err_msg),
Style::default().fg(th.red),
)),
Line::from(""),
Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.files.retry_hint"),
Style::default().fg(th.subtext1),
)),
]
}
fn build_display_items(
items: &[PackageItem],
file_info: &[PackageFileInfo],
file_tree_expanded: &std::collections::HashSet<String>,
) -> Vec<FileDisplayItem> {
use std::collections::HashMap;
let file_info_map: HashMap<String, &PackageFileInfo> = file_info
.iter()
.map(|info| (info.name.clone(), info))
.collect();
let mut display_items = Vec::new();
for item in items {
let pkg_name = &item.name;
let is_expanded = file_tree_expanded.contains(pkg_name);
display_items.push((true, pkg_name.clone(), None));
if is_expanded {
if let Some(pkg_info) = file_info_map.get(pkg_name) {
for file in &pkg_info.files {
display_items.push((
false,
String::new(),
Some((
file.change_type.clone(),
file.path.clone(),
file.is_config,
file.predicted_pacnew,
file.predicted_pacsave,
)),
));
}
}
}
}
display_items
}
fn render_sync_timestamp(
app: &AppState,
sync_info: Option<&(u64, String, u8)>,
) -> (Option<Line<'static>>, usize) {
let th = theme();
if let Some((_age_days, date_str, color_category)) = sync_info {
let (sync_color, sync_text) = match color_category {
0 => (
th.green,
i18n::t_fmt1(app, "app.modals.preflight.files.files_updated_on", date_str),
),
1 => (
th.yellow,
i18n::t_fmt1(app, "app.modals.preflight.files.files_updated_on", date_str),
),
_ => (
th.red,
i18n::t_fmt1(app, "app.modals.preflight.files.files_updated_on", date_str),
),
};
(
Some(Line::from(Span::styled(
sync_text,
Style::default().fg(sync_color),
))),
2,
)
} else {
(None, 0)
}
}
fn render_empty_state(
app: &AppState,
items: &[PackageItem],
file_info: &[PackageFileInfo],
is_stale: Option<&bool>,
sync_info: Option<&(u64, String, u8)>,
) -> Vec<Line<'static>> {
let th = theme();
let mut lines = Vec::new();
let has_aur_packages = items
.iter()
.any(|p| matches!(p.source, crate::state::Source::Aur));
let has_official_packages = items
.iter()
.any(|p| matches!(p.source, crate::state::Source::Official { .. }));
let mut unresolved_packages = Vec::new();
for pkg_info in file_info {
if pkg_info.files.is_empty() {
unresolved_packages.push(pkg_info.name.clone());
}
}
if file_info.is_empty() {
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.files.file_resolution_progress"),
Style::default().fg(th.subtext1),
)));
} else if unresolved_packages.is_empty() {
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.files.no_file_changes_display"),
Style::default().fg(th.subtext1),
)));
} else {
lines.push(Line::from(Span::styled(
i18n::t_fmt1(
app,
"app.modals.preflight.files.no_file_changes",
unresolved_packages.len(),
),
Style::default().fg(th.subtext1),
)));
lines.push(Line::from(""));
if has_official_packages {
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.files.file_db_sync_note"),
Style::default().fg(th.subtext0),
)));
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.files.sync_file_db_hint"),
Style::default().fg(th.subtext0),
)));
}
if has_aur_packages {
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.files.aur_file_note"),
Style::default().fg(th.subtext0),
)));
}
}
if matches!(is_stale, Some(true)) {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.files.file_db_stale"),
Style::default().fg(th.yellow),
)));
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.files.sync_file_db_root"),
Style::default().fg(th.subtext0),
)));
}
let (sync_line, _) = render_sync_timestamp(app, sync_info);
if let Some(line) = sync_line {
lines.push(Line::from(""));
lines.push(line);
}
lines
}
fn render_package_header(
app: &AppState,
pkg_info: &PackageFileInfo,
pkg_name: &str,
is_expanded: bool,
is_selected: bool,
) -> Line<'static> {
let th = theme();
let arrow_symbol = if is_expanded { "â–¼" } else { "â–¶" };
let header_style = if is_selected {
Style::default()
.fg(th.crust)
.bg(th.lavender)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD)
};
let total_count = pkg_info.total_count;
let mut spans = vec![
Span::styled(format!("{arrow_symbol} {pkg_name} "), header_style),
Span::styled(
format!("({total_count} files"),
Style::default().fg(th.subtext1),
),
];
if pkg_info.new_count > 0 {
spans.push(Span::styled(
format!(
", {}",
i18n::t_fmt1(app, "app.modals.preflight.files.new", pkg_info.new_count)
),
Style::default().fg(th.green),
));
}
if pkg_info.changed_count > 0 {
let changed_count = pkg_info.changed_count;
spans.push(Span::styled(
format!(", {changed_count} changed"),
Style::default().fg(th.yellow),
));
}
if pkg_info.removed_count > 0 {
let removed_count = pkg_info.removed_count;
spans.push(Span::styled(
format!(", {removed_count} removed"),
Style::default().fg(th.red),
));
}
if pkg_info.config_count > 0 {
let config_count = pkg_info.config_count;
spans.push(Span::styled(
format!(", {config_count} config"),
Style::default().fg(th.mauve),
));
}
if pkg_info.pacnew_candidates > 0 {
let pacnew_candidates = pkg_info.pacnew_candidates;
spans.push(Span::styled(
format!(", {pacnew_candidates} pacnew"),
Style::default().fg(th.yellow),
));
}
if pkg_info.pacsave_candidates > 0 {
let pacsave_candidates = pkg_info.pacsave_candidates;
spans.push(Span::styled(
format!(", {pacsave_candidates} pacsave"),
Style::default().fg(th.red),
));
}
spans.push(Span::styled(")", Style::default().fg(th.subtext1)));
Line::from(spans)
}
struct FileTotals {
files: usize,
new: usize,
changed: usize,
removed: usize,
config: usize,
pacnew: usize,
pacsave: usize,
}
fn calculate_file_totals(file_info: &[PackageFileInfo]) -> FileTotals {
file_info.iter().fold(
FileTotals {
files: 0,
new: 0,
changed: 0,
removed: 0,
config: 0,
pacnew: 0,
pacsave: 0,
},
|acc, p| FileTotals {
files: acc.files + p.total_count,
new: acc.new + p.new_count,
changed: acc.changed + p.changed_count,
removed: acc.removed + p.removed_count,
config: acc.config + p.config_count,
pacnew: acc.pacnew + p.pacnew_candidates,
pacsave: acc.pacsave + p.pacsave_candidates,
},
)
}
fn build_summary_parts(
app: &AppState,
totals: &FileTotals,
items_len: usize,
has_incomplete_data: bool,
) -> Vec<String> {
let total_files_text =
format_count_with_indicator(totals.files, items_len * 10, has_incomplete_data);
let mut summary_parts = vec![i18n::t_fmt1(
app,
"app.modals.preflight.files.total",
total_files_text,
)];
if totals.new > 0 {
let count_text = format_count_with_indicator(totals.new, totals.files, has_incomplete_data);
summary_parts.push(i18n::t_fmt1(
app,
"app.modals.preflight.files.new",
count_text,
));
}
if totals.changed > 0 {
let count_text =
format_count_with_indicator(totals.changed, totals.files, has_incomplete_data);
summary_parts.push(i18n::t_fmt1(
app,
"app.modals.preflight.files.changed",
count_text,
));
}
if totals.removed > 0 {
let count_text =
format_count_with_indicator(totals.removed, totals.files, has_incomplete_data);
summary_parts.push(i18n::t_fmt1(
app,
"app.modals.preflight.files.removed",
count_text,
));
}
if totals.config > 0 {
let count_text =
format_count_with_indicator(totals.config, totals.files, has_incomplete_data);
summary_parts.push(i18n::t_fmt1(
app,
"app.modals.preflight.files.config",
count_text,
));
}
if totals.pacnew > 0 {
summary_parts.push(i18n::t_fmt1(
app,
"app.modals.preflight.files.pacnew",
totals.pacnew,
));
}
if totals.pacsave > 0 {
summary_parts.push(i18n::t_fmt1(
app,
"app.modals.preflight.files.pacsave",
totals.pacsave,
));
}
summary_parts
}
fn calculate_viewport(
total_items: usize,
available_height: usize,
file_selected: &mut usize,
) -> (usize, usize) {
let file_selected_clamped = (*file_selected).min(total_items.saturating_sub(1));
if *file_selected != file_selected_clamped {
*file_selected = file_selected_clamped;
}
if total_items <= available_height {
(0, total_items)
} else {
let start = file_selected_clamped
.saturating_sub(available_height / 2)
.min(total_items.saturating_sub(available_height));
let end = (start + available_height).min(total_items);
if file_selected_clamped < start {
(
file_selected_clamped,
(file_selected_clamped + available_height).min(total_items),
)
} else if file_selected_clamped >= end {
let new_end = (file_selected_clamped + 1).min(total_items);
(new_end.saturating_sub(available_height).max(0), new_end)
} else {
(start, end)
}
}
}
fn render_missing_package_header(
pkg_name: &str,
is_expanded: bool,
is_selected: bool,
) -> Line<'static> {
let th = theme();
let arrow_symbol = if is_expanded { "â–¼" } else { "â–¶" };
let header_style = if is_selected {
Style::default()
.fg(th.crust)
.bg(th.lavender)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD)
};
let spans = vec![
Span::styled(format!("{arrow_symbol} {pkg_name} "), header_style),
Span::styled("(0 files)", Style::default().fg(th.subtext1)),
];
Line::from(spans)
}
fn render_display_items(
app: &AppState,
display_items: &[FileDisplayItem],
file_info: &[PackageFileInfo],
file_tree_expanded: &std::collections::HashSet<String>,
file_selected: usize,
start_idx: usize,
end_idx: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for (display_idx, (is_header, pkg_name, file_opt)) in display_items
.iter()
.enumerate()
.skip(start_idx)
.take(end_idx - start_idx)
{
let is_selected = display_idx == file_selected;
if *is_header {
let is_expanded = file_tree_expanded.contains(pkg_name);
if let Some(pkg_info) = file_info.iter().find(|p| p.name == *pkg_name) {
lines.push(render_package_header(
app,
pkg_info,
pkg_name,
is_expanded,
is_selected,
));
} else {
lines.push(render_missing_package_header(
pkg_name,
is_expanded,
is_selected,
));
}
} else if let Some((change_type, path, is_config, predicted_pacnew, predicted_pacsave)) =
file_opt
{
lines.push(render_file_entry(
app,
change_type,
path,
*is_config,
*predicted_pacnew,
*predicted_pacsave,
is_selected,
));
}
}
lines
}
struct FileListContext<'a> {
file_info: &'a [PackageFileInfo],
items: &'a [PackageItem],
display_items: &'a [FileDisplayItem],
file_tree_expanded: &'a std::collections::HashSet<String>,
sync_info: &'a Option<(u64, String, u8)>,
}
#[allow(clippy::fn_params_excessive_bools)]
fn render_file_entry(
app: &AppState,
change_type: &FileChangeType,
path: &str,
is_config: bool,
predicted_pacnew: bool,
predicted_pacsave: bool,
is_selected: bool,
) -> Line<'static> {
let th = theme();
let (icon, color) = match change_type {
FileChangeType::New => ("+", th.green),
FileChangeType::Changed => ("~", th.yellow),
FileChangeType::Removed => ("-", th.red),
};
let highlight_bg = if is_selected { Some(th.lavender) } else { None };
let icon_style = highlight_bg.map_or_else(
|| Style::default().fg(color),
|bg| Style::default().fg(color).bg(bg),
);
let mut spans = vec![Span::styled(format!(" {icon} "), icon_style)];
if is_config {
let cfg_style = highlight_bg.map_or_else(
|| Style::default().fg(th.mauve),
|bg| Style::default().fg(th.mauve).bg(bg),
);
spans.push(Span::styled("âš™ ", cfg_style));
}
if predicted_pacnew {
let pacnew_style = highlight_bg.map_or_else(
|| Style::default().fg(th.yellow),
|bg| Style::default().fg(th.yellow).bg(bg),
);
spans.push(Span::styled(
i18n::t(app, "app.modals.preflight.files.pacnew_label"),
pacnew_style,
));
}
if predicted_pacsave {
let pacsave_style = highlight_bg.map_or_else(
|| Style::default().fg(th.red),
|bg| Style::default().fg(th.red).bg(bg),
);
spans.push(Span::styled(
i18n::t(app, "app.modals.preflight.files.pacsave_label"),
pacsave_style,
));
}
let path_style = highlight_bg.map_or_else(
|| Style::default().fg(th.text),
|bg| {
Style::default()
.fg(th.crust)
.bg(bg)
.add_modifier(Modifier::BOLD)
},
);
spans.push(Span::styled(path.to_string(), path_style));
Line::from(spans)
}
fn render_file_list(
app: &AppState,
ctx: &FileListContext,
file_selected: &mut usize,
content_rect: Rect,
) -> Vec<Line<'static>> {
let th = theme();
let mut lines = Vec::new();
let totals = calculate_file_totals(ctx.file_info);
let has_incomplete_data = ctx.file_info.len() < ctx.items.len();
let summary_parts = build_summary_parts(app, &totals, ctx.items.len(), has_incomplete_data);
lines.push(Line::from(Span::styled(
i18n::t_fmt1(
app,
"app.modals.preflight.files.files_label",
summary_parts.join(", "),
),
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let (sync_line, sync_timestamp_lines) = render_sync_timestamp(app, ctx.sync_info.as_ref());
if let Some(line) = sync_line {
lines.push(line);
lines.push(Line::from(""));
}
let header_lines = 4 + sync_timestamp_lines;
let available_height = (content_rect.height.saturating_sub(1) as usize)
.saturating_sub(header_lines)
.max(1);
let total_items = ctx.display_items.len();
let (start_idx, end_idx) = calculate_viewport(total_items, available_height, file_selected);
let mut item_lines = render_display_items(
app,
ctx.display_items,
ctx.file_info,
ctx.file_tree_expanded,
*file_selected,
start_idx,
end_idx,
);
lines.append(&mut item_lines);
if total_items > available_height {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
i18n::t_fmt(
app,
"app.modals.preflight.files.showing_range_items",
&[
&(start_idx + 1).to_string(),
&end_idx.to_string(),
&total_items.to_string(),
],
),
Style::default().fg(th.subtext1),
)));
}
lines
}
#[allow(clippy::too_many_arguments)]
pub fn render_files_tab(
app: &AppState,
items: &[PackageItem],
file_info: &[PackageFileInfo],
file_selected: &mut usize,
file_tree_expanded: &std::collections::HashSet<String>,
files_error: Option<&String>,
content_rect: Rect,
) -> Vec<Line<'static>> {
const STALE_THRESHOLD_DAYS: u64 = 7;
let is_resolving = app.preflight_files_resolving || app.files_resolving;
let package_summaries = file_info
.iter()
.map(|pkg| FilePackageSummary {
name: pkg.name.clone(),
total: pkg.total_count,
new_count: pkg.new_count,
changed_count: pkg.changed_count,
removed_count: pkg.removed_count,
config_count: pkg.config_count,
file_entries: pkg.files.len(),
})
.collect();
log_files_state(&FilesLogState {
items_len: items.len(),
file_info_len: file_info.len(),
file_selected: *file_selected,
expanded_len: file_tree_expanded.len(),
preflight_resolving: app.preflight_files_resolving,
global_resolving: app.files_resolving,
has_error: files_error.is_some(),
package_summaries,
});
if is_resolving {
let th = theme();
return render_loading_state(app, items, "app.modals.preflight.files.updating", th.yellow);
}
if let Some(err_msg) = files_error {
return render_error_state(app, err_msg);
}
if file_info.is_empty() {
let th = theme();
return render_loading_state(
app,
items,
"app.modals.preflight.files.resolving",
th.subtext1,
);
}
let display_items = build_display_items(items, file_info, file_tree_expanded);
let sync_info = crate::logic::files::get_file_db_sync_info();
let is_stale = crate::logic::files::is_file_db_stale(STALE_THRESHOLD_DAYS);
if display_items.is_empty() {
render_empty_state(app, items, file_info, is_stale.as_ref(), sync_info.as_ref())
} else {
let ctx = FileListContext {
file_info,
items,
display_items: &display_items,
file_tree_expanded,
sync_info: &sync_info,
};
render_file_list(app, &ctx, file_selected, content_rect)
}
}