use ratatui::{
prelude::Rect,
style::{Modifier, Style},
text::{Line, Span},
};
use crate::i18n;
use crate::state::{AppState, PackageItem};
use crate::theme::theme;
use crate::ui::helpers::ChangeLogger;
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::sync::{Mutex, OnceLock};
type SandboxDisplayItem = (
bool,
String,
Option<(
&'static str, // "depends", "makedepends", "checkdepends", "optdepends"
String, // dependency name
crate::logic::sandbox::DependencyDelta,
)>,
);
pub struct SandboxTabContext<'a> {
pub items: &'a [PackageItem],
pub sandbox_info: &'a [crate::logic::sandbox::SandboxInfo],
pub sandbox_tree_expanded: &'a HashSet<String>,
pub sandbox_loaded: bool,
pub sandbox_error: Option<&'a String>,
pub selected_optdepends: &'a HashMap<String, HashSet<String>>,
pub content_rect: Rect,
}
enum SandboxRenderState {
Error(String),
Loading,
Analyzing,
NoAurPackages,
Ready,
}
#[derive(Clone, PartialEq, Eq)]
struct SandboxLogState {
items_len: usize,
sandbox_info_len: usize,
sandbox_loaded: bool,
sandbox_selected: usize,
expanded_len: usize,
resolving_mask: u8,
has_error: bool,
}
fn log_sandbox_state(state: &SandboxLogState) {
static LOGGER: OnceLock<Mutex<ChangeLogger<SandboxLogState>>> = OnceLock::new();
let mut logger = LOGGER
.get_or_init(|| Mutex::new(ChangeLogger::new()))
.lock()
.expect("Sandbox log state mutex poisoned");
if logger.should_log(state) {
tracing::debug!(
"[UI] render_sandbox_tab: items={}, sandbox_info={}, sandbox_loaded={}, sandbox_selected={}, expanded={}, resolving={}/{}, error={}",
state.items_len,
state.sandbox_info_len,
state.sandbox_loaded,
state.sandbox_selected,
state.expanded_len,
state.resolving_mask & 1 != 0,
state.resolving_mask & 2 != 0,
state.has_error
);
}
}
struct SandboxRenderData {
display_items: Vec<SandboxDisplayItem>,
viewport: (usize, usize),
available_height: usize,
total_items: usize,
}
fn determine_sandbox_state(app: &AppState, ctx: &SandboxTabContext) -> SandboxRenderState {
let has_aur = ctx
.items
.iter()
.any(|i| matches!(i.source, crate::state::Source::Aur));
if !has_aur {
return SandboxRenderState::NoAurPackages;
}
if let Some(err) = ctx.sandbox_error {
return SandboxRenderState::Error(err.clone());
} else if app.preflight_sandbox_resolving || app.sandbox_resolving {
return SandboxRenderState::Loading;
} else if !ctx.sandbox_loaded || ctx.sandbox_info.is_empty() {
return SandboxRenderState::Analyzing;
}
SandboxRenderState::Ready
}
fn compute_render_data(ctx: &SandboxTabContext, sandbox_selected: &mut usize) -> SandboxRenderData {
let display_items = build_display_items(ctx.items, ctx.sandbox_info, ctx.sandbox_tree_expanded);
let available_height = (ctx.content_rect.height as usize).saturating_sub(6);
let total_items = display_items.len();
let viewport = calculate_viewport(
total_items,
*sandbox_selected,
available_height,
sandbox_selected,
);
SandboxRenderData {
display_items,
viewport,
available_height,
total_items,
}
}
fn render_error_state(app: &AppState, error: &str) -> Vec<Line<'static>> {
let th = theme();
let mut lines = Vec::new();
tracing::warn!("[UI] render_sandbox_tab: Displaying error: {}", error);
lines.push(Line::from(Span::styled(
i18n::t_fmt1(app, "app.modals.preflight.sandbox.error", error),
Style::default().fg(th.red),
)));
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.sandbox.retry_hint"),
Style::default().fg(th.subtext0),
)));
lines.push(Line::from(""));
lines
}
fn render_aur_package_headers(items: &[PackageItem]) -> Vec<Line<'static>> {
let th = theme();
let mut lines = Vec::new();
for item in items {
let is_aur = matches!(item.source, crate::state::Source::Aur);
if is_aur {
let item_name = &item.name;
lines.push(Line::from(Span::styled(
format!("▶ {item_name} "),
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD),
)));
}
}
lines
}
fn render_loading_state(app: &AppState, items: &[PackageItem]) -> Vec<Line<'static>> {
let th = theme();
let mut lines = render_aur_package_headers(items);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.sandbox.updating"),
Style::default().fg(th.yellow),
)));
lines
}
fn render_analyzing_state(app: &AppState, items: &[PackageItem]) -> Vec<Line<'static>> {
let th = theme();
let mut lines = render_aur_package_headers(items);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.sandbox.analyzing"),
Style::default().fg(th.subtext0),
)));
lines
}
fn render_no_aur_packages_state(app: &AppState) -> Vec<Line<'static>> {
let th = theme();
vec![
Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.sandbox.no_aur_packages"),
Style::default().fg(th.subtext1),
)),
Line::from(""),
Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.sandbox.sandbox_only_for_aur"),
Style::default().fg(th.subtext0),
)),
]
}
fn build_display_items(
items: &[PackageItem],
sandbox_info: &[crate::logic::sandbox::SandboxInfo],
sandbox_tree_expanded: &HashSet<String>,
) -> Vec<SandboxDisplayItem> {
let mut display_items = Vec::new();
for item in items {
let is_aur = matches!(item.source, crate::state::Source::Aur);
let is_expanded = sandbox_tree_expanded.contains(&item.name);
display_items.push((true, item.name.clone(), None));
if is_expanded
&& is_aur
&& let Some(info) = sandbox_info.iter().find(|s| s.package_name == item.name)
{
tracing::debug!(
"[UI] render_sandbox_tab: Expanding package '{}' with {} depends, {} makedepends, {} checkdepends, {} optdepends",
item.name,
info.depends.len(),
info.makedepends.len(),
info.checkdepends.len(),
info.optdepends.len()
);
for dep in &info.depends {
display_items.push((
false,
item.name.clone(),
Some(("depends", dep.name.clone(), dep.clone())),
));
}
for dep in &info.makedepends {
display_items.push((
false,
item.name.clone(),
Some(("makedepends", dep.name.clone(), dep.clone())),
));
}
for dep in &info.checkdepends {
display_items.push((
false,
item.name.clone(),
Some(("checkdepends", dep.name.clone(), dep.clone())),
));
}
for dep in &info.optdepends {
display_items.push((
false,
item.name.clone(),
Some(("optdepends", dep.name.clone(), dep.clone())),
));
}
} else if is_aur && is_expanded {
tracing::warn!(
"[UI] render_sandbox_tab: Package '{}' is AUR and expanded but no sandbox info found",
item.name
);
}
}
display_items
}
fn calculate_viewport(
total_items: usize,
selected_idx: usize,
available_height: usize,
sandbox_selected: &mut usize,
) -> (usize, usize) {
let sandbox_selected_clamped = if total_items > 0 {
selected_idx.min(total_items.saturating_sub(1))
} else {
0
};
if *sandbox_selected != sandbox_selected_clamped {
tracing::warn!(
"[UI] render_sandbox_tab: Clamping sandbox_selected from {} to {} (total_items={})",
*sandbox_selected,
sandbox_selected_clamped,
total_items
);
*sandbox_selected = sandbox_selected_clamped;
}
if total_items <= available_height {
return (0, total_items);
}
let effective_height = available_height.saturating_sub(2); let mut start_idx = sandbox_selected_clamped
.saturating_sub(effective_height / 2)
.max(0)
.min(total_items.saturating_sub(effective_height));
let mut end_idx = (start_idx + effective_height).min(total_items);
if sandbox_selected_clamped < start_idx {
start_idx = sandbox_selected_clamped;
end_idx = (start_idx + effective_height).min(total_items);
} else if sandbox_selected_clamped >= end_idx {
end_idx = (sandbox_selected_clamped + 1).min(total_items);
start_idx = end_idx.saturating_sub(effective_height).max(0);
end_idx = (start_idx + effective_height).min(total_items);
if sandbox_selected_clamped >= end_idx {
end_idx = sandbox_selected_clamped + 1;
start_idx = end_idx.saturating_sub(effective_height).max(0);
}
}
(start_idx, end_idx)
}
fn get_dependency_status_icon(
dep: &crate::logic::sandbox::DependencyDelta,
dep_type: &str,
is_optdep_selected: bool,
) -> &'static str {
if dep.is_installed {
if dep.version_satisfied { "✓" } else { "⚠" }
} else {
match dep_type {
"optdepends" => {
if is_optdep_selected {
"☑" } else {
"☐" }
}
"checkdepends" => "○",
_ => "✗",
}
}
}
fn get_dependency_status_color(
dep: &crate::logic::sandbox::DependencyDelta,
dep_type: &str,
is_optdep_selected: bool,
) -> ratatui::style::Color {
let th = theme();
if dep.is_installed {
if dep.version_satisfied {
th.green
} else {
th.yellow
}
} else {
match dep_type {
"optdepends" => {
if is_optdep_selected {
th.sapphire } else {
th.subtext0
}
}
"checkdepends" => th.subtext0,
_ => th.red,
}
}
}
fn render_package_header(
app: &AppState,
item: &PackageItem,
is_expanded: bool,
is_selected: bool,
) -> Line<'static> {
let th = theme();
let is_aur = matches!(item.source, crate::state::Source::Aur);
let arrow_symbol = if is_aur && is_expanded {
"▼"
} else if is_aur {
"▶"
} else {
""
};
let header_style = if is_selected {
Style::default()
.fg(th.crust)
.bg(th.sapphire)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(th.sapphire)
.add_modifier(Modifier::BOLD)
};
let source_str = match &item.source {
crate::state::Source::Aur => "AUR".to_string(),
crate::state::Source::Official { repo, .. } => repo.clone(),
};
let mut header_text = i18n::t_fmt(
app,
"app.modals.preflight.sandbox.package_label",
&[&item.name, &source_str],
);
if !arrow_symbol.is_empty() {
header_text = format!("{arrow_symbol} {header_text}");
}
Line::from(Span::styled(header_text, header_style))
}
fn render_package_header_details(
app: &AppState,
pkg_name: &str,
is_aur: bool,
is_expanded: bool,
sandbox_info: &[crate::logic::sandbox::SandboxInfo],
) -> Vec<Line<'static>> {
let th = theme();
let mut lines = Vec::new();
if !is_aur {
lines.push(Line::from(Span::styled(
format!(
" {}",
i18n::t(
app,
"app.modals.preflight.sandbox.official_packages_prebuilt"
)
),
Style::default().fg(th.subtext0),
)));
} else if !is_expanded {
if let Some(info) = sandbox_info.iter().find(|s| s.package_name == pkg_name) {
let dep_count = info.depends.len()
+ info.makedepends.len()
+ info.checkdepends.len()
+ info.optdepends.len();
if dep_count > 0 {
lines.push(Line::from(Span::styled(
format!(
" {}",
i18n::t_fmt1(
app,
"app.modals.preflight.sandbox.dependencies_expand_hint",
dep_count.to_string()
)
),
Style::default().fg(th.subtext1),
)));
} else {
lines.push(Line::from(Span::styled(
format!(
" {}",
i18n::t(app, "app.modals.preflight.sandbox.no_build_dependencies")
),
Style::default().fg(th.green),
)));
}
} else {
tracing::debug!(
"[UI] render_sandbox_tab: AUR package '{}' collapsed but no sandbox info found",
pkg_name
);
}
}
lines
}
fn render_dependency_section_header(app: &AppState, dep_type: &str) -> Option<Line<'static>> {
let th = theme();
let section_name = match dep_type {
"depends" => i18n::t(app, "app.modals.preflight.sandbox.runtime_dependencies"),
"makedepends" => i18n::t(app, "app.modals.preflight.sandbox.build_dependencies"),
"checkdepends" => i18n::t(app, "app.modals.preflight.sandbox.test_dependencies"),
"optdepends" => i18n::t(app, "app.modals.preflight.sandbox.optional_dependencies"),
_ => return None,
};
Some(Line::from(Span::styled(
section_name,
Style::default()
.fg(th.sapphire)
.add_modifier(Modifier::BOLD),
)))
}
fn render_dependency_line(
dep_name: &str,
dep: &crate::logic::sandbox::DependencyDelta,
dep_type: &str,
is_optdep_selected: bool,
is_selected: bool,
) -> Line<'static> {
let th = theme();
let status_icon = get_dependency_status_icon(dep, dep_type, is_optdep_selected);
let status_color = get_dependency_status_color(dep, dep_type, is_optdep_selected);
let dep_style = if is_selected {
Style::default()
.fg(th.crust)
.bg(th.sapphire)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(status_color)
};
let mut dep_line = format!(" {status_icon} {dep_name}");
if let Some(ref version) = dep.installed_version {
let _ = write!(dep_line, " (installed: {version})");
}
if dep_type == "optdepends" && is_optdep_selected {
dep_line.push_str(" [selected]");
}
Line::from(Span::styled(dep_line, dep_style))
}
fn render_display_items(
app: &AppState,
ctx: &SandboxTabContext,
render_data: &SandboxRenderData,
selected_idx: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let (start_idx, end_idx) = render_data.viewport;
let mut last_dep_type: Option<&str> = None;
for (idx, (is_header, pkg_name, dep_opt)) in render_data
.display_items
.iter()
.enumerate()
.skip(start_idx)
.take(end_idx - start_idx)
{
let is_selected = idx == selected_idx;
if *is_header {
let Some(item) = ctx.items.iter().find(|p| p.name == *pkg_name) else {
tracing::warn!(
"[UI] render_sandbox_tab: Package '{}' not found in items list, skipping",
pkg_name
);
continue;
};
let is_aur = matches!(item.source, crate::state::Source::Aur);
let is_expanded = ctx.sandbox_tree_expanded.contains(pkg_name);
lines.push(render_package_header(app, item, is_expanded, is_selected));
last_dep_type = None;
lines.extend(render_package_header_details(
app,
pkg_name,
is_aur,
is_expanded,
ctx.sandbox_info,
));
} else if let Some((dep_type, dep_name, dep)) = dep_opt {
if last_dep_type != Some(dep_type) {
if let Some(header_line) = render_dependency_section_header(app, dep_type) {
lines.push(header_line);
}
last_dep_type = Some(dep_type);
}
let is_optdep_selected = if *dep_type == "optdepends" {
ctx.selected_optdepends.get(pkg_name).is_some_and(|set| {
let pkg_name_from_dep = crate::logic::sandbox::extract_package_name(dep_name);
set.contains(dep_name) || set.contains(&pkg_name_from_dep)
})
} else {
false
};
lines.push(render_dependency_line(
dep_name,
dep,
dep_type,
is_optdep_selected,
is_selected,
));
}
}
lines
}
pub fn render_sandbox_tab(
app: &AppState,
ctx: &SandboxTabContext,
sandbox_selected: &mut usize,
) -> Vec<Line<'static>> {
let th = theme();
let resolving_mask =
u8::from(app.preflight_sandbox_resolving) | (u8::from(app.sandbox_resolving) << 1);
log_sandbox_state(&SandboxLogState {
items_len: ctx.items.len(),
sandbox_info_len: ctx.sandbox_info.len(),
sandbox_loaded: ctx.sandbox_loaded,
sandbox_selected: *sandbox_selected,
expanded_len: ctx.sandbox_tree_expanded.len(),
resolving_mask,
has_error: ctx.sandbox_error.is_some(),
});
let render_state = determine_sandbox_state(app, ctx);
match render_state {
SandboxRenderState::NoAurPackages => {
tracing::debug!(
"[UI] render_sandbox_tab: No AUR packages in list (all {} packages are official), showing info message",
ctx.items.len()
);
return render_no_aur_packages_state(app);
}
SandboxRenderState::Error(err) => {
return render_error_state(app, &err);
}
SandboxRenderState::Loading => {
let aur_count = ctx
.items
.iter()
.filter(|i| matches!(i.source, crate::state::Source::Aur))
.count();
tracing::debug!(
"[UI] render_sandbox_tab: Showing loading state (resolving={}/{}, {} AUR packages)",
app.preflight_sandbox_resolving,
app.sandbox_resolving,
aur_count
);
return render_loading_state(app, ctx.items);
}
SandboxRenderState::Analyzing => {
tracing::debug!(
"[UI] render_sandbox_tab: Not loaded or empty (loaded={}, info_len={}), showing analyzing message",
ctx.sandbox_loaded,
ctx.sandbox_info.len()
);
return render_analyzing_state(app, ctx.items);
}
SandboxRenderState::Ready => {
}
}
let render_data = compute_render_data(ctx, sandbox_selected);
tracing::debug!(
"[UI] render_sandbox_tab: Rendering data - total_items={}, sandbox_selected={}, items={}, sandbox_info={}, expanded_count={}, available_height={}",
render_data.total_items,
*sandbox_selected,
ctx.items.len(),
ctx.sandbox_info.len(),
ctx.sandbox_tree_expanded.len(),
render_data.available_height
);
let mut lines = render_display_items(app, ctx, &render_data, *sandbox_selected);
let (_, end_idx) = render_data.viewport;
if end_idx < render_data.total_items {
lines.push(Line::from(Span::styled(
format!(
"… {} more item{}",
render_data.total_items - end_idx,
if render_data.total_items - end_idx == 1 {
""
} else {
"s"
}
),
Style::default().fg(th.subtext1),
)));
}
if ctx.items.is_empty() {
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.preflight.sandbox.no_packages"),
Style::default().fg(th.subtext0),
)));
}
lines
}