use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::logic::repos::{
DEFAULT_MAIN_PACMAN_PATH, MANAGED_DROPIN_FILE, ReposConfFile, build_repo_apply_bundle,
build_repo_key_refresh_bundle, disable_repo_section_in_repos_conf_if_enabled,
list_foreign_packages, load_repos_config_into_app, load_resolve_repos_from_str,
read_main_pacman_conf_text, refresh_dynamic_filters_in_app,
repos_conf_section_is_disabled_with_apply_sources, repositories_linux_actions_supported,
toggle_repo_enabled_for_section_in_file,
};
use crate::state::AppState;
use crate::state::modal::RepositoriesModalResume;
use crate::state::types::{RepositoryModalRow, RepositoryPacmanStatus};
use crate::theme::{config_dir, resolve_repos_config_path};
const REPOS_CONF_REFERENCE_SHIPPED: &str = include_str!("../../../config/repos.conf");
const REPOS_VIEWPORT_ROWS: usize = 12;
fn repo_row_is_pacsea_managed_active(row: &RepositoryModalRow) -> bool {
matches!(row.pacman_status, RepositoryPacmanStatus::Active)
&& row
.source_hint
.as_deref()
.is_some_and(|h| h == MANAGED_DROPIN_FILE)
}
fn repo_row_allows_space_toggle(
row: &RepositoryModalRow,
repos_conf_text: &str,
) -> Result<bool, String> {
if repo_row_is_pacsea_managed_active(row) {
return Ok(true);
}
repos_conf_section_is_disabled_with_apply_sources(
repos_conf_text,
row.pacman_section_name.trim(),
)
}
fn clamp_repositories_scroll(scroll: u16, row_count: usize) -> u16 {
if row_count == 0 {
return 0;
}
let max_start = row_count.saturating_sub(1);
let cap = max_start
.saturating_sub(REPOS_VIEWPORT_ROWS.saturating_sub(1))
.min(max_start);
let cap_u16 = u16::try_from(cap).unwrap_or(u16::MAX);
scroll.min(cap_u16)
}
pub fn reopen_repositories_modal_if_pending(app: &mut AppState) {
let Some(resume) = app.pending_repositories_modal_resume.take() else {
return;
};
let (rows, repos_conf_error, pacman_warnings) =
crate::logic::repos::build_repositories_modal_fields_default();
let want = resume.section_name.trim().to_lowercase();
let selected = rows
.iter()
.position(|r| r.pacman_section_name.trim().to_lowercase() == want)
.unwrap_or(0);
let row_count = rows.len();
let scroll = clamp_repositories_scroll(resume.scroll, row_count);
app.modal = crate::state::Modal::Repositories {
rows,
selected,
scroll,
repos_conf_error,
pacman_warnings,
};
}
pub(super) fn queue_repo_disable_apply_after_overlap_warn_cancel(
app: &mut AppState,
section_name: &str,
) -> Result<(), String> {
let trimmed = section_name.trim();
if trimmed.is_empty() {
return Err(crate::i18n::t(
app,
"app.modals.foreign_overlap.cancel_revert_empty_section",
));
}
let path = resolve_repos_config_path()
.ok_or_else(|| crate::i18n::t(app, "app.modals.repositories.apply.no_config_path"))?;
disable_repo_section_in_repos_conf_if_enabled(&path, trimmed).map_err(|e| {
crate::i18n::t_fmt1(app, "app.modals.foreign_overlap.cancel_revert_failed", e)
})?;
load_repos_config_into_app(app, resolve_repos_config_path());
refresh_dynamic_filters_in_app(app, &crate::theme::settings());
let text = std::fs::read_to_string(&path).map_err(|e| {
crate::i18n::t_fmt1(
app,
"app.modals.foreign_overlap.cancel_revert_failed",
format!("{}: {e}", path.display()),
)
})?;
let (repo_rows, _) = load_resolve_repos_from_str(&text).map_err(|e| {
crate::i18n::t_fmt1(app, "app.modals.foreign_overlap.cancel_revert_failed", e)
})?;
let repos = ReposConfFile { repo: repo_rows };
let main = read_main_pacman_conf_text(Path::new(DEFAULT_MAIN_PACMAN_PATH)).map_err(|e| {
crate::i18n::t_fmt1(app, "app.modals.foreign_overlap.cancel_revert_failed", e)
})?;
let bundle = build_repo_apply_bundle(&repos, &main, trimmed).map_err(|e| {
crate::i18n::t_fmt1(app, "app.modals.foreign_overlap.cancel_revert_failed", e)
})?;
app.pending_repo_apply_summary = Some(bundle.summary_lines);
app.pending_repo_apply_overlap_check = None;
let resume = app.pending_repositories_modal_resume.clone();
queue_repo_apply_execution(app, bundle.commands, resume);
Ok(())
}
pub(super) fn handle_repositories_modal_keys(
ke: KeyEvent,
app: &mut AppState,
row_count: usize,
selected: &mut usize,
scroll: &mut u16,
) -> Option<bool> {
match ke.code {
KeyCode::Char('o' | 'O') => {
open_user_repos_conf_in_editor(app);
Some(false)
}
KeyCode::Esc | KeyCode::Char('q') => {
app.modal = crate::state::Modal::None;
Some(false)
}
KeyCode::Up | KeyCode::Char('k') => {
if *selected > 0 {
*selected -= 1;
}
clamp_scroll_for_selection(*selected, scroll, row_count);
Some(false)
}
KeyCode::Down | KeyCode::Char('j') => {
if *selected + 1 < row_count {
*selected += 1;
}
clamp_scroll_for_selection(*selected, scroll, row_count);
Some(false)
}
KeyCode::PageUp => {
*scroll = scroll.saturating_sub(u16::try_from(REPOS_VIEWPORT_ROWS).unwrap_or(u16::MAX));
clamp_scroll_bounds(scroll, row_count);
Some(false)
}
KeyCode::PageDown => {
*scroll = scroll.saturating_add(u16::try_from(REPOS_VIEWPORT_ROWS).unwrap_or(12));
clamp_scroll_bounds(scroll, row_count);
Some(false)
}
KeyCode::Home => {
*scroll = 0;
Some(false)
}
KeyCode::End => {
end_scroll(scroll, row_count);
Some(false)
}
_ => {
if ke.modifiers.contains(KeyModifiers::CONTROL) && matches!(ke.code, KeyCode::Char('d'))
{
*scroll =
scroll.saturating_add(u16::try_from(REPOS_VIEWPORT_ROWS / 2).unwrap_or(6));
clamp_scroll_bounds(scroll, row_count);
return Some(false);
}
if ke.modifiers.contains(KeyModifiers::CONTROL) && matches!(ke.code, KeyCode::Char('u'))
{
*scroll =
scroll.saturating_sub(u16::try_from(REPOS_VIEWPORT_ROWS / 2).unwrap_or(6));
clamp_scroll_bounds(scroll, row_count);
return Some(false);
}
None
}
}
}
fn repos_conf_path_for_read() -> Option<PathBuf> {
resolve_repos_config_path().or_else(|| {
let p = config_dir().join("repos.conf");
p.is_file().then_some(p)
})
}
pub(super) fn open_user_repos_conf_in_editor(app: &mut AppState) {
let path = config_dir().join("repos.conf");
if let Some(parent) = path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
app.toast_message = Some(crate::i18n::t(
app,
"app.modals.repositories.open_config.mkdir_failed",
));
app.toast_expires_at = Some(Instant::now() + Duration::from_secs(4));
return;
}
if !path.exists() {
let seed = crate::theme::REPOS_SKELETON_CONTENT;
if std::fs::write(&path, seed).is_err() {
app.toast_message = Some(crate::i18n::t(
app,
"app.modals.repositories.open_config.create_failed",
));
app.toast_expires_at = Some(Instant::now() + Duration::from_secs(4));
return;
}
}
#[cfg(target_os = "windows")]
{
crate::util::open_file(&path);
}
#[cfg(not(target_os = "windows"))]
{
let editor_cmd = crate::install::editor_open_config_command(&path);
let cmds = vec![editor_cmd];
std::thread::spawn(move || {
crate::install::spawn_shell_commands_in_terminal(&cmds);
});
}
app.toast_message = Some(crate::i18n::t_fmt1(
app,
"app.modals.repositories.open_config.started",
path.display().to_string(),
));
app.toast_expires_at = Some(Instant::now() + Duration::from_secs(3));
}
pub(super) fn open_repos_conf_example_in_editor(app: &mut AppState) {
let path = config_dir().join("repos_reference.conf");
if let Some(parent) = path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
app.toast_message = Some(crate::i18n::t(
app,
"app.modals.repositories.open_config.mkdir_failed",
));
app.toast_expires_at = Some(Instant::now() + Duration::from_secs(4));
return;
}
if std::fs::write(&path, REPOS_CONF_REFERENCE_SHIPPED).is_err() {
app.toast_message = Some(crate::i18n::t(
app,
"app.modals.repositories.setup_example_write_failed",
));
app.toast_expires_at = Some(Instant::now() + Duration::from_secs(4));
return;
}
#[cfg(target_os = "windows")]
{
crate::util::open_file(&path);
}
#[cfg(not(target_os = "windows"))]
{
let editor_cmd = crate::install::editor_open_config_command(&path);
let cmds = vec![editor_cmd];
std::thread::spawn(move || {
crate::install::spawn_shell_commands_in_terminal(&cmds);
});
}
app.toast_message = Some(crate::i18n::t_fmt1(
app,
"app.modals.repositories.setup_example_started",
path.display().to_string(),
));
app.toast_expires_at = Some(Instant::now() + Duration::from_secs(3));
}
pub(super) fn enter_repo_key_refresh(
app: &mut AppState,
rows: &[RepositoryModalRow],
selected: usize,
repos_conf_error: Option<&str>,
) -> Result<(), String> {
if !repositories_linux_actions_supported() {
return Err(crate::i18n::t(
app,
"app.modals.repositories.refresh_key.unsupported_platform",
));
}
if repos_conf_error.is_some() {
return Err(crate::i18n::t(
app,
"app.modals.repositories.apply.fix_repos_conf",
));
}
let row = rows
.get(selected)
.ok_or_else(|| crate::i18n::t(app, "app.modals.repositories.apply.no_selection"))?;
let path = repos_conf_path_for_read()
.ok_or_else(|| crate::i18n::t(app, "app.modals.repositories.apply.no_config_path"))?;
let text = std::fs::read_to_string(&path).map_err(|e| {
format!(
"{} {}: {e}",
crate::i18n::t(app, "app.modals.repositories.apply.read_failed"),
path.display()
)
})?;
let (repo_rows, _) = load_resolve_repos_from_str(&text).map_err(|e| {
format!(
"{} {e}",
crate::i18n::t(app, "app.modals.repositories.apply.parse_failed"),
)
})?;
let repos = ReposConfFile { repo: repo_rows };
let section = row.pacman_section_name.trim();
let bundle = build_repo_key_refresh_bundle(&repos, section)?;
app.pending_repo_apply_summary = Some(bundle.summary_lines);
queue_repo_apply_execution(app, bundle.commands, None);
Ok(())
}
pub fn repositories_modal_wheel_step(
row_count: usize,
selected: &mut usize,
scroll: &mut u16,
down: bool,
) {
if down {
if *selected + 1 < row_count {
*selected += 1;
}
} else if *selected > 0 {
*selected -= 1;
}
clamp_scroll_for_selection(*selected, scroll, row_count);
}
fn clamp_scroll_for_selection(selected: usize, scroll: &mut u16, row_count: usize) {
if row_count == 0 {
*scroll = 0;
return;
}
let sc = *scroll as usize;
if selected < sc {
*scroll = u16::try_from(selected).unwrap_or(0);
return;
}
let bottom = sc.saturating_add(REPOS_VIEWPORT_ROWS);
if selected >= bottom && REPOS_VIEWPORT_ROWS > 0 {
let new_sc = selected + 1 - REPOS_VIEWPORT_ROWS;
*scroll = u16::try_from(new_sc).unwrap_or(0);
}
clamp_scroll_bounds(scroll, row_count);
}
fn clamp_scroll_bounds(scroll: &mut u16, row_count: usize) {
if row_count == 0 {
*scroll = 0;
return;
}
let max_start = row_count.saturating_sub(1);
let cap = max_start
.saturating_sub(REPOS_VIEWPORT_ROWS.saturating_sub(1))
.min(max_start);
let cap_u16 = u16::try_from(cap).unwrap_or(u16::MAX);
if *scroll > cap_u16 {
*scroll = cap_u16;
}
}
fn end_scroll(scroll: &mut u16, row_count: usize) {
if row_count == 0 {
*scroll = 0;
return;
}
let max_start = row_count.saturating_sub(1);
let cap = max_start.saturating_sub(REPOS_VIEWPORT_ROWS.saturating_sub(1));
*scroll = u16::try_from(cap).unwrap_or(0);
}
pub(super) fn enter_repo_apply(
app: &mut AppState,
rows: &[RepositoryModalRow],
selected: usize,
scroll: u16,
repos_conf_error: Option<&str>,
) -> Result<(), String> {
if repos_conf_error.is_some() {
return Err(crate::i18n::t(
app,
"app.modals.repositories.apply.fix_repos_conf",
));
}
if rows.is_empty() {
return Err(crate::i18n::t(app, "app.modals.repositories.apply.no_rows"));
}
rows.get(selected)
.ok_or_else(|| crate::i18n::t(app, "app.modals.repositories.apply.no_selection"))?;
let path = resolve_repos_config_path()
.ok_or_else(|| crate::i18n::t(app, "app.modals.repositories.apply.no_config_path"))?;
let text = std::fs::read_to_string(&path).map_err(|e| {
format!(
"{} {}: {e}",
crate::i18n::t(app, "app.modals.repositories.apply.read_failed"),
path.display()
)
})?;
let (repo_rows, _) = load_resolve_repos_from_str(&text).map_err(|e| {
format!(
"{} {e}",
crate::i18n::t(app, "app.modals.repositories.apply.parse_failed"),
)
})?;
let repos = ReposConfFile { repo: repo_rows };
let main = read_main_pacman_conf_text(Path::new(DEFAULT_MAIN_PACMAN_PATH))?;
let section = rows[selected].pacman_section_name.trim();
let bundle = build_repo_apply_bundle(&repos, &main, section)?;
app.pending_repo_apply_summary = Some(bundle.summary_lines);
if crate::logic::repos::repositories_linux_actions_supported() {
let pre_apply_foreign_snapshot = match list_foreign_packages() {
Ok(rows) => Some(rows),
Err(e) => {
tracing::warn!(
target: "pacsea::repos",
error = %e,
"pacman -Qm snapshot before repo apply failed; overlap will use live -Qm at completion"
);
None
}
};
app.pending_repo_apply_overlap_check = Some(crate::state::modal::RepoOverlapApplyPending {
repo_section: section.to_lowercase(),
pre_apply_foreign_snapshot,
});
} else {
app.pending_repo_apply_overlap_check = None;
}
queue_repo_apply_execution(
app,
bundle.commands,
Some(RepositoriesModalResume {
section_name: section.to_string(),
scroll,
}),
);
Ok(())
}
pub(super) fn toggle_selected_repo_enabled_and_apply(
app: &mut AppState,
rows: &[RepositoryModalRow],
selected: usize,
scroll: u16,
repos_conf_error: Option<&str>,
) -> Result<(), String> {
if !repositories_linux_actions_supported() {
return Err(crate::i18n::t(
app,
"app.modals.repositories.toggle.unsupported_platform",
));
}
if repos_conf_error.is_some() {
return Err(crate::i18n::t(
app,
"app.modals.repositories.apply.fix_repos_conf",
));
}
let row = rows
.get(selected)
.ok_or_else(|| crate::i18n::t(app, "app.modals.repositories.apply.no_selection"))?;
let path = resolve_repos_config_path()
.ok_or_else(|| crate::i18n::t(app, "app.modals.repositories.apply.no_config_path"))?;
let repos_text = std::fs::read_to_string(&path).map_err(|e| {
format!(
"{} {}: {e}",
crate::i18n::t(app, "app.modals.repositories.apply.read_failed"),
path.display()
)
})?;
if !repo_row_allows_space_toggle(row, &repos_text)? {
return Err(crate::i18n::t(
app,
"app.modals.repositories.toggle.not_managed",
));
}
toggle_repo_enabled_for_section_in_file(&path, row.pacman_section_name.trim()).map_err(
|e| {
format!(
"{} {e}",
crate::i18n::t(app, "app.modals.repositories.toggle.persist_failed"),
)
},
)?;
load_repos_config_into_app(app, resolve_repos_config_path());
refresh_dynamic_filters_in_app(app, &crate::theme::settings());
enter_repo_apply(app, rows, selected, scroll, repos_conf_error)
}
fn queue_repo_apply_execution(
app: &mut AppState,
cmds: Vec<String>,
resume: Option<RepositoriesModalResume>,
) {
if cmds.is_empty() {
app.pending_repo_apply_summary = None;
app.pending_repo_apply_overlap_check = None;
app.pending_repositories_modal_resume = None;
app.modal = crate::state::Modal::Alert {
message: crate::i18n::t(app, "app.modals.repositories.apply.empty_plan"),
};
return;
}
if std::env::var("PACSEA_TEST_OUT").is_ok() {
crate::install::spawn_shell_commands_in_terminal(&cmds);
app.modal = crate::state::Modal::None;
app.pending_repo_apply_summary = None;
app.pending_repo_apply_commands = None;
app.pending_repo_apply_overlap_check = None;
app.pending_repositories_modal_resume = None;
return;
}
let settings = crate::theme::settings();
if crate::logic::password::should_use_interactive_auth_handoff(&settings) {
match crate::events::try_interactive_auth_handoff() {
Ok(true) => {
app.pending_repositories_modal_resume = resume;
let log_lines = app.pending_repo_apply_summary.take().unwrap_or_default();
app.modal = crate::state::Modal::PreflightExec {
items: Vec::new(),
action: crate::state::PreflightAction::Install,
tab: crate::state::PreflightTab::Summary,
verbose: false,
log_lines,
abortable: false,
header_chips: crate::state::modal::PreflightHeaderChips::default(),
success: None,
};
app.pending_executor_request = Some(crate::install::ExecutorRequest::Update {
commands: cmds,
password: None,
dry_run: app.dry_run,
});
}
Ok(false) => {
app.pending_repo_apply_summary = None;
app.pending_repo_apply_commands = None;
app.pending_repo_apply_overlap_check = None;
app.pending_repositories_modal_resume = None;
app.modal = crate::state::Modal::Alert {
message: crate::i18n::t(app, "app.errors.authentication_failed"),
};
}
Err(e) => {
app.pending_repo_apply_summary = None;
app.pending_repo_apply_commands = None;
app.pending_repo_apply_overlap_check = None;
app.pending_repositories_modal_resume = None;
app.modal = crate::state::Modal::Alert { message: e };
}
}
return;
}
if crate::logic::password::resolve_auth_mode(&settings)
== crate::logic::privilege::AuthMode::PasswordlessOnly
&& crate::logic::password::should_use_passwordless_sudo(&settings)
{
app.pending_repositories_modal_resume = resume;
let log_lines = app.pending_repo_apply_summary.take().unwrap_or_default();
app.modal = crate::state::Modal::PreflightExec {
items: Vec::new(),
action: crate::state::PreflightAction::Install,
tab: crate::state::PreflightTab::Summary,
verbose: false,
log_lines,
abortable: false,
header_chips: crate::state::modal::PreflightHeaderChips::default(),
success: None,
};
app.pending_executor_request = Some(crate::install::ExecutorRequest::Update {
commands: cmds,
password: None,
dry_run: app.dry_run,
});
return;
}
app.pending_repositories_modal_resume = resume;
app.pending_repo_apply_commands = Some(cmds);
app.modal = crate::state::Modal::PasswordPrompt {
purpose: crate::state::modal::PasswordPurpose::RepoApply,
items: Vec::new(),
input: crate::state::SecureString::default(),
cursor: 0,
error: None,
};
}
#[cfg(test)]
mod repos_conf_example_embed_tests {
use super::REPOS_CONF_REFERENCE_SHIPPED;
#[test]
fn embedded_repos_example_is_non_empty() {
assert!(
REPOS_CONF_REFERENCE_SHIPPED.len() > 20,
"embedded template should contain substantial content"
);
assert!(
REPOS_CONF_REFERENCE_SHIPPED.contains("Pacsea"),
"embedded template should identify itself as Pacsea documentation"
);
}
}