Skip to main content

pacsea/events/preflight/keys/
action_keys.rs

1//! Action key handlers for Preflight modal.
2
3use std::collections::HashMap;
4
5use crate::state::AppState;
6use crate::state::modal::ServiceRestartDecision;
7
8use super::context::{EnterOrSpaceContext, PreflightKeyContext};
9use super::tab_handlers::handle_enter_or_space;
10use crate::events::preflight::modal::close_preflight_modal;
11
12/// What: Handle Esc key - close the Preflight modal.
13///
14/// Inputs:
15/// - `app`: Mutable application state
16///
17/// Output:
18/// - Always returns `false` to continue event processing.
19///
20/// Details:
21/// - Closes the preflight modal but keeps the TUI open.
22pub(super) fn handle_esc_key(app: &mut AppState) -> bool {
23    let service_info = if let crate::state::Modal::Preflight { service_info, .. } = &app.modal {
24        service_info.clone()
25    } else {
26        Vec::new()
27    };
28    close_preflight_modal(app, &service_info);
29    // Return false to keep TUI open - modal is closed but app continues
30    false
31}
32
33/// What: Handle Enter key - execute Enter/Space action.
34///
35/// Inputs:
36/// - `app`: Mutable application state
37///
38/// Output:
39/// - Always returns `false` to continue event processing.
40///
41/// Details:
42/// - May close the modal if action requires it, but TUI remains open.
43pub(super) fn handle_enter_key(app: &mut AppState) -> bool {
44    let should_close = if let crate::state::Modal::Preflight {
45        tab,
46        items,
47        dependency_info,
48        dep_selected,
49        dep_tree_expanded,
50        file_info,
51        file_selected,
52        file_tree_expanded,
53        sandbox_info,
54        sandbox_selected,
55        sandbox_tree_expanded,
56        selected_optdepends,
57        service_info,
58        service_selected,
59        ..
60    } = &mut app.modal
61    {
62        handle_enter_or_space(EnterOrSpaceContext {
63            tab,
64            items,
65            dependency_info,
66            dep_selected: *dep_selected,
67            dep_tree_expanded,
68            file_info,
69            file_selected: *file_selected,
70            file_tree_expanded,
71            sandbox_info,
72            sandbox_selected: *sandbox_selected,
73            sandbox_tree_expanded,
74            selected_optdepends,
75            service_info,
76            service_selected: *service_selected,
77        })
78    } else {
79        false
80    };
81
82    if should_close {
83        // Use the same flow as "p" key - delegate to handle_proceed functions
84        // This ensures reinstall check and batch update check happen before password prompt
85        let (items_clone, action_clone, header_chips_clone, cascade_mode) =
86            if let crate::state::Modal::Preflight {
87                items,
88                action,
89                header_chips,
90                cascade_mode,
91                ..
92            } = &app.modal
93            {
94                (items.clone(), *action, header_chips.clone(), *cascade_mode)
95            } else {
96                // Not a Preflight modal, just close it
97                let service_info =
98                    if let crate::state::Modal::Preflight { service_info, .. } = &app.modal {
99                        service_info.clone()
100                    } else {
101                        Vec::new()
102                    };
103                close_preflight_modal(app, &service_info);
104                return false;
105            };
106
107        // Get service info before closing modal
108        let service_info = if let crate::state::Modal::Preflight { service_info, .. } = &app.modal {
109            service_info.clone()
110        } else {
111            Vec::new()
112        };
113        close_preflight_modal(app, &service_info);
114
115        // Use the same proceed handlers as "p" key to ensure consistent flow
116        match action_clone {
117            crate::state::PreflightAction::Install => {
118                use super::command_keys;
119                command_keys::handle_proceed_install(app, items_clone, header_chips_clone);
120            }
121            crate::state::PreflightAction::Remove => {
122                use super::command_keys;
123                command_keys::handle_proceed_remove(
124                    app,
125                    items_clone,
126                    cascade_mode,
127                    header_chips_clone,
128                );
129            }
130            crate::state::PreflightAction::Downgrade => {
131                // Downgrade operations always need sudo (downgrade tool requires sudo)
132                // Check faillock status before showing password prompt
133                let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
134                if let Some(lockout_msg) =
135                    crate::logic::faillock::get_lockout_message_if_locked(&username, app)
136                {
137                    // User is locked out - show warning and don't show password prompt
138                    app.modal = crate::state::Modal::Alert {
139                        message: lockout_msg,
140                    };
141                    return false;
142                }
143                let settings = crate::theme::settings();
144                if crate::logic::password::should_use_interactive_auth_handoff(&settings) {
145                    crate::events::spawn_downgrade_in_terminal(app, &items_clone);
146                } else {
147                    app.modal = crate::state::Modal::PasswordPrompt {
148                        purpose: crate::state::modal::PasswordPurpose::Downgrade,
149                        items: items_clone,
150                        input: crate::state::SecureString::default(),
151                        cursor: 0,
152                        error: None,
153                    };
154                    app.pending_exec_header_chips = Some(header_chips_clone);
155                }
156            }
157        }
158        // Return false to keep TUI open - modal is closed but app continues
159        return false;
160    }
161    false
162}
163
164/// What: Start command execution by transitioning to `PreflightExec` and storing `ExecutorRequest`.
165///
166/// Inputs:
167/// - `app`: Mutable application state
168/// - `items`: Packages to install/remove
169/// - `action`: Install or Remove action
170/// - `header_chips`: Header chip metrics
171/// - `password`: Optional password (if already obtained from password prompt)
172///
173/// Details:
174/// - Transitions to `PreflightExec` modal and stores `ExecutorRequest` for processing in tick handler
175#[allow(clippy::needless_pass_by_value)] // header_chips is consumed in modal creation
176pub fn start_execution(
177    app: &mut AppState,
178    items: &[crate::state::PackageItem],
179    action: crate::state::PreflightAction,
180    header_chips: crate::state::modal::PreflightHeaderChips,
181    password: Option<crate::state::SecureString>,
182) {
183    use crate::install::ExecutorRequest;
184
185    // Note: Reinstall check is now done in handle_proceed_install BEFORE password prompt
186    // This function is called after reinstall confirmation (if needed) and password prompt (if needed)
187
188    // Guardrail: refuse to start while the pacman database is locked (dry-run executes nothing)
189    if !app.dry_run
190        && let Some(message) = crate::events::guardrails::db_lock_alert_message(app)
191    {
192        tracing::warn!("[Preflight] Blocking execution: pacman database is locked");
193        app.modal = crate::state::Modal::Alert { message };
194        return;
195    }
196
197    tracing::debug!(
198        action = ?action,
199        item_count = items.len(),
200        header_chips = ?header_chips,
201        has_password = password.is_some(),
202        "[Preflight] Transitioning modal: Preflight -> PreflightExec"
203    );
204
205    // Transition to PreflightExec modal
206    app.modal = crate::state::Modal::PreflightExec {
207        items: items.to_vec(),
208        action,
209        tab: crate::state::PreflightTab::Summary,
210        verbose: false,
211        log_lines: Vec::new(),
212        abortable: false,
213        header_chips,
214        success: None,
215    };
216
217    // Store executor request for processing in tick handler
218    app.pending_executor_request = Some(match action {
219        crate::state::PreflightAction::Install => ExecutorRequest::Install {
220            items: items.to_vec(),
221            password,
222            dry_run: app.dry_run,
223        },
224        crate::state::PreflightAction::Remove => {
225            let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
226            ExecutorRequest::Remove {
227                names,
228                password,
229                cascade: app.remove_cascade_mode,
230                dry_run: app.dry_run,
231            }
232        }
233        crate::state::PreflightAction::Downgrade => {
234            let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
235            ExecutorRequest::Downgrade {
236                names,
237                password,
238                dry_run: app.dry_run,
239            }
240        }
241    });
242}
243
244/// What: Handle Space key - toggle expand/collapse.
245///
246/// Inputs:
247/// - `ctx`: Preflight key context
248///
249/// Output:
250/// - Always returns `false`.
251pub(super) fn handle_space_key(ctx: &mut PreflightKeyContext<'_>) -> bool {
252    handle_enter_or_space(EnterOrSpaceContext {
253        tab: ctx.tab,
254        items: ctx.items,
255        dependency_info: ctx.dependency_info,
256        dep_selected: *ctx.dep_selected,
257        dep_tree_expanded: ctx.dep_tree_expanded,
258        file_info: ctx.file_info,
259        file_selected: *ctx.file_selected,
260        file_tree_expanded: ctx.file_tree_expanded,
261        sandbox_info: ctx.sandbox_info,
262        sandbox_selected: *ctx.sandbox_selected,
263        sandbox_tree_expanded: ctx.sandbox_tree_expanded,
264        selected_optdepends: ctx.selected_optdepends,
265        service_info: ctx.service_info,
266        service_selected: *ctx.service_selected,
267    });
268    false
269}
270
271/// What: Handle Shift+R key - re-run all analyses.
272///
273/// Inputs:
274/// - `app`: Mutable application state
275///
276/// Output:
277/// - Always returns `false`.
278pub(super) fn handle_shift_r_key(app: &mut AppState) -> bool {
279    tracing::info!("Shift+R pressed: Re-running all preflight analyses");
280
281    let (items, action) = if let crate::state::Modal::Preflight { items, action, .. } = &app.modal {
282        (items.clone(), *action)
283    } else {
284        return false;
285    };
286
287    // Clear all cached data in the modal
288    if let crate::state::Modal::Preflight {
289        dependency_info,
290        deps_error,
291        file_info,
292        files_error,
293        service_info,
294        services_error,
295        services_loaded,
296        sandbox_info,
297        sandbox_error,
298        sandbox_loaded,
299        summary,
300        dep_selected,
301        file_selected,
302        service_selected,
303        sandbox_selected,
304        dep_tree_expanded,
305        file_tree_expanded,
306        sandbox_tree_expanded,
307        ..
308    } = &mut app.modal
309    {
310        *dependency_info = Vec::new();
311        *deps_error = None;
312        *file_info = Vec::new();
313        *files_error = None;
314        *service_info = Vec::new();
315        *services_error = None;
316        *services_loaded = false;
317        *sandbox_info = Vec::new();
318        *sandbox_error = None;
319        *sandbox_loaded = false;
320        *summary = None;
321
322        *dep_selected = 0;
323        *file_selected = 0;
324        *service_selected = 0;
325        *sandbox_selected = 0;
326
327        dep_tree_expanded.clear();
328        file_tree_expanded.clear();
329        sandbox_tree_expanded.clear();
330    }
331
332    // Reset cancellation flag
333    app.preflight_cancelled
334        .store(false, std::sync::atomic::Ordering::Relaxed);
335
336    // Queue all stages for background resolution (same as opening modal)
337    app.preflight_summary_items = Some((items.clone(), action));
338    app.preflight_summary_resolving = true;
339
340    if matches!(action, crate::state::PreflightAction::Install) {
341        app.preflight_deps_items = Some((items.clone(), crate::state::PreflightAction::Install));
342        app.preflight_deps_resolving = true;
343
344        app.preflight_files_items = Some(items.clone());
345        app.preflight_files_resolving = true;
346
347        app.preflight_services_items = Some(items.clone());
348        app.preflight_services_resolving = true;
349
350        // Only queue sandbox for AUR packages
351        let aur_items: Vec<_> = items
352            .iter()
353            .filter(|p| matches!(p.source, crate::state::Source::Aur))
354            .cloned()
355            .collect();
356        if aur_items.is_empty() {
357            app.preflight_sandbox_items = None;
358            app.preflight_sandbox_resolving = false;
359            if let crate::state::Modal::Preflight { sandbox_loaded, .. } = &mut app.modal {
360                *sandbox_loaded = true;
361            }
362        } else {
363            app.preflight_sandbox_items = Some(aur_items);
364            app.preflight_sandbox_resolving = true;
365        }
366    }
367
368    app.toast_message = Some("Re-running all preflight analyses...".to_string());
369    app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
370    false
371}
372
373/// What: Handle regular R key - retry resolution for current tab.
374///
375/// Inputs:
376/// - `ctx`: Preflight key context
377///
378/// Output:
379/// - Always returns `false`.
380pub(super) fn handle_r_key(ctx: &mut PreflightKeyContext<'_>) -> bool {
381    if *ctx.tab == crate::state::PreflightTab::Services && !ctx.service_info.is_empty() {
382        // Toggle restart decision for selected service (only if no error)
383        if *ctx.service_selected >= ctx.service_info.len() {
384            *ctx.service_selected = ctx.service_info.len().saturating_sub(1);
385        }
386        if let Some(service) = ctx.service_info.get_mut(*ctx.service_selected) {
387            service.restart_decision = ServiceRestartDecision::Restart;
388        }
389    } else if *ctx.tab == crate::state::PreflightTab::Deps
390        && matches!(*ctx.action, crate::state::PreflightAction::Install)
391    {
392        // Retry dependency resolution
393        *ctx.deps_error = None;
394        *ctx.dependency_info = crate::logic::deps::resolve_dependencies(ctx.items);
395        *ctx.dep_selected = 0;
396    } else if *ctx.tab == crate::state::PreflightTab::Files {
397        // Retry file resolution
398        *ctx.files_error = None;
399        *ctx.file_info = crate::logic::files::resolve_file_changes(ctx.items, *ctx.action);
400        *ctx.file_selected = 0;
401    } else if *ctx.tab == crate::state::PreflightTab::Services {
402        // Retry service resolution
403        *ctx.services_error = None;
404        *ctx.services_loaded = false;
405        *ctx.service_info = crate::logic::services::resolve_service_impacts(ctx.items, *ctx.action);
406        *ctx.service_selected = 0;
407        *ctx.services_loaded = true;
408    }
409    false
410}
411
412/// What: Handle D key - set service restart decision to Defer.
413///
414/// Inputs:
415/// - `ctx`: Preflight key context
416///
417/// Output:
418/// - Always returns `false`.
419pub(super) fn handle_d_key(ctx: &mut PreflightKeyContext<'_>) -> bool {
420    if *ctx.tab == crate::state::PreflightTab::Services && !ctx.service_info.is_empty() {
421        if *ctx.service_selected >= ctx.service_info.len() {
422            *ctx.service_selected = ctx.service_info.len().saturating_sub(1);
423        }
424        if let Some(service) = ctx.service_info.get_mut(*ctx.service_selected) {
425            service.restart_decision = ServiceRestartDecision::Defer;
426        }
427    }
428    false
429}
430
431/// What: Handle A key - expand/collapse all package groups.
432///
433/// Inputs:
434/// - `ctx`: Preflight key context
435///
436/// Output:
437/// - Always returns `false`.
438pub(super) fn handle_a_key(ctx: &mut PreflightKeyContext<'_>) -> bool {
439    if *ctx.tab == crate::state::PreflightTab::Deps && !ctx.dependency_info.is_empty() {
440        let mut grouped: HashMap<String, Vec<&crate::state::modal::DependencyInfo>> =
441            HashMap::new();
442        for dep in ctx.dependency_info.iter() {
443            for req_by in &dep.required_by {
444                grouped.entry(req_by.clone()).or_default().push(dep);
445            }
446        }
447
448        let all_expanded = ctx
449            .items
450            .iter()
451            .all(|p| ctx.dep_tree_expanded.contains(&p.name));
452        if all_expanded {
453            // Collapse all
454            ctx.dep_tree_expanded.clear();
455        } else {
456            // Expand all packages (even if they have no dependencies)
457            for pkg_name in ctx.items.iter().map(|p| &p.name) {
458                ctx.dep_tree_expanded.insert(pkg_name.clone());
459            }
460        }
461    } else if *ctx.tab == crate::state::PreflightTab::Files && !ctx.file_info.is_empty() {
462        // Expand/collapse all packages in Files tab
463        let all_expanded = ctx
464            .file_info
465            .iter()
466            .filter(|p| !p.files.is_empty())
467            .all(|p| ctx.file_tree_expanded.contains(&p.name));
468        if all_expanded {
469            // Collapse all
470            ctx.file_tree_expanded.clear();
471        } else {
472            // Expand all
473            for pkg_info in ctx.file_info.iter() {
474                if !pkg_info.files.is_empty() {
475                    ctx.file_tree_expanded.insert(pkg_info.name.clone());
476                }
477            }
478        }
479    }
480    false
481}