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    tracing::debug!(
189        action = ?action,
190        item_count = items.len(),
191        header_chips = ?header_chips,
192        has_password = password.is_some(),
193        "[Preflight] Transitioning modal: Preflight -> PreflightExec"
194    );
195
196    // Transition to PreflightExec modal
197    app.modal = crate::state::Modal::PreflightExec {
198        items: items.to_vec(),
199        action,
200        tab: crate::state::PreflightTab::Summary,
201        verbose: false,
202        log_lines: Vec::new(),
203        abortable: false,
204        header_chips,
205        success: None,
206    };
207
208    // Store executor request for processing in tick handler
209    app.pending_executor_request = Some(match action {
210        crate::state::PreflightAction::Install => ExecutorRequest::Install {
211            items: items.to_vec(),
212            password,
213            dry_run: app.dry_run,
214        },
215        crate::state::PreflightAction::Remove => {
216            let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
217            ExecutorRequest::Remove {
218                names,
219                password,
220                cascade: app.remove_cascade_mode,
221                dry_run: app.dry_run,
222            }
223        }
224        crate::state::PreflightAction::Downgrade => {
225            let names: Vec<String> = items.iter().map(|p| p.name.clone()).collect();
226            ExecutorRequest::Downgrade {
227                names,
228                password,
229                dry_run: app.dry_run,
230            }
231        }
232    });
233}
234
235/// What: Handle Space key - toggle expand/collapse.
236///
237/// Inputs:
238/// - `ctx`: Preflight key context
239///
240/// Output:
241/// - Always returns `false`.
242pub(super) fn handle_space_key(ctx: &mut PreflightKeyContext<'_>) -> bool {
243    handle_enter_or_space(EnterOrSpaceContext {
244        tab: ctx.tab,
245        items: ctx.items,
246        dependency_info: ctx.dependency_info,
247        dep_selected: *ctx.dep_selected,
248        dep_tree_expanded: ctx.dep_tree_expanded,
249        file_info: ctx.file_info,
250        file_selected: *ctx.file_selected,
251        file_tree_expanded: ctx.file_tree_expanded,
252        sandbox_info: ctx.sandbox_info,
253        sandbox_selected: *ctx.sandbox_selected,
254        sandbox_tree_expanded: ctx.sandbox_tree_expanded,
255        selected_optdepends: ctx.selected_optdepends,
256        service_info: ctx.service_info,
257        service_selected: *ctx.service_selected,
258    });
259    false
260}
261
262/// What: Handle Shift+R key - re-run all analyses.
263///
264/// Inputs:
265/// - `app`: Mutable application state
266///
267/// Output:
268/// - Always returns `false`.
269pub(super) fn handle_shift_r_key(app: &mut AppState) -> bool {
270    tracing::info!("Shift+R pressed: Re-running all preflight analyses");
271
272    let (items, action) = if let crate::state::Modal::Preflight { items, action, .. } = &app.modal {
273        (items.clone(), *action)
274    } else {
275        return false;
276    };
277
278    // Clear all cached data in the modal
279    if let crate::state::Modal::Preflight {
280        dependency_info,
281        deps_error,
282        file_info,
283        files_error,
284        service_info,
285        services_error,
286        services_loaded,
287        sandbox_info,
288        sandbox_error,
289        sandbox_loaded,
290        summary,
291        dep_selected,
292        file_selected,
293        service_selected,
294        sandbox_selected,
295        dep_tree_expanded,
296        file_tree_expanded,
297        sandbox_tree_expanded,
298        ..
299    } = &mut app.modal
300    {
301        *dependency_info = Vec::new();
302        *deps_error = None;
303        *file_info = Vec::new();
304        *files_error = None;
305        *service_info = Vec::new();
306        *services_error = None;
307        *services_loaded = false;
308        *sandbox_info = Vec::new();
309        *sandbox_error = None;
310        *sandbox_loaded = false;
311        *summary = None;
312
313        *dep_selected = 0;
314        *file_selected = 0;
315        *service_selected = 0;
316        *sandbox_selected = 0;
317
318        dep_tree_expanded.clear();
319        file_tree_expanded.clear();
320        sandbox_tree_expanded.clear();
321    }
322
323    // Reset cancellation flag
324    app.preflight_cancelled
325        .store(false, std::sync::atomic::Ordering::Relaxed);
326
327    // Queue all stages for background resolution (same as opening modal)
328    app.preflight_summary_items = Some((items.clone(), action));
329    app.preflight_summary_resolving = true;
330
331    if matches!(action, crate::state::PreflightAction::Install) {
332        app.preflight_deps_items = Some((items.clone(), crate::state::PreflightAction::Install));
333        app.preflight_deps_resolving = true;
334
335        app.preflight_files_items = Some(items.clone());
336        app.preflight_files_resolving = true;
337
338        app.preflight_services_items = Some(items.clone());
339        app.preflight_services_resolving = true;
340
341        // Only queue sandbox for AUR packages
342        let aur_items: Vec<_> = items
343            .iter()
344            .filter(|p| matches!(p.source, crate::state::Source::Aur))
345            .cloned()
346            .collect();
347        if aur_items.is_empty() {
348            app.preflight_sandbox_items = None;
349            app.preflight_sandbox_resolving = false;
350            if let crate::state::Modal::Preflight { sandbox_loaded, .. } = &mut app.modal {
351                *sandbox_loaded = true;
352            }
353        } else {
354            app.preflight_sandbox_items = Some(aur_items);
355            app.preflight_sandbox_resolving = true;
356        }
357    }
358
359    app.toast_message = Some("Re-running all preflight analyses...".to_string());
360    app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
361    false
362}
363
364/// What: Handle regular R key - retry resolution for current tab.
365///
366/// Inputs:
367/// - `ctx`: Preflight key context
368///
369/// Output:
370/// - Always returns `false`.
371pub(super) fn handle_r_key(ctx: &mut PreflightKeyContext<'_>) -> bool {
372    if *ctx.tab == crate::state::PreflightTab::Services && !ctx.service_info.is_empty() {
373        // Toggle restart decision for selected service (only if no error)
374        if *ctx.service_selected >= ctx.service_info.len() {
375            *ctx.service_selected = ctx.service_info.len().saturating_sub(1);
376        }
377        if let Some(service) = ctx.service_info.get_mut(*ctx.service_selected) {
378            service.restart_decision = ServiceRestartDecision::Restart;
379        }
380    } else if *ctx.tab == crate::state::PreflightTab::Deps
381        && matches!(*ctx.action, crate::state::PreflightAction::Install)
382    {
383        // Retry dependency resolution
384        *ctx.deps_error = None;
385        *ctx.dependency_info = crate::logic::deps::resolve_dependencies(ctx.items);
386        *ctx.dep_selected = 0;
387    } else if *ctx.tab == crate::state::PreflightTab::Files {
388        // Retry file resolution
389        *ctx.files_error = None;
390        *ctx.file_info = crate::logic::files::resolve_file_changes(ctx.items, *ctx.action);
391        *ctx.file_selected = 0;
392    } else if *ctx.tab == crate::state::PreflightTab::Services {
393        // Retry service resolution
394        *ctx.services_error = None;
395        *ctx.services_loaded = false;
396        *ctx.service_info = crate::logic::services::resolve_service_impacts(ctx.items, *ctx.action);
397        *ctx.service_selected = 0;
398        *ctx.services_loaded = true;
399    }
400    false
401}
402
403/// What: Handle D key - set service restart decision to Defer.
404///
405/// Inputs:
406/// - `ctx`: Preflight key context
407///
408/// Output:
409/// - Always returns `false`.
410pub(super) fn handle_d_key(ctx: &mut PreflightKeyContext<'_>) -> bool {
411    if *ctx.tab == crate::state::PreflightTab::Services && !ctx.service_info.is_empty() {
412        if *ctx.service_selected >= ctx.service_info.len() {
413            *ctx.service_selected = ctx.service_info.len().saturating_sub(1);
414        }
415        if let Some(service) = ctx.service_info.get_mut(*ctx.service_selected) {
416            service.restart_decision = ServiceRestartDecision::Defer;
417        }
418    }
419    false
420}
421
422/// What: Handle A key - expand/collapse all package groups.
423///
424/// Inputs:
425/// - `ctx`: Preflight key context
426///
427/// Output:
428/// - Always returns `false`.
429pub(super) fn handle_a_key(ctx: &mut PreflightKeyContext<'_>) -> bool {
430    if *ctx.tab == crate::state::PreflightTab::Deps && !ctx.dependency_info.is_empty() {
431        let mut grouped: HashMap<String, Vec<&crate::state::modal::DependencyInfo>> =
432            HashMap::new();
433        for dep in ctx.dependency_info.iter() {
434            for req_by in &dep.required_by {
435                grouped.entry(req_by.clone()).or_default().push(dep);
436            }
437        }
438
439        let all_expanded = ctx
440            .items
441            .iter()
442            .all(|p| ctx.dep_tree_expanded.contains(&p.name));
443        if all_expanded {
444            // Collapse all
445            ctx.dep_tree_expanded.clear();
446        } else {
447            // Expand all packages (even if they have no dependencies)
448            for pkg_name in ctx.items.iter().map(|p| &p.name) {
449                ctx.dep_tree_expanded.insert(pkg_name.clone());
450            }
451        }
452    } else if *ctx.tab == crate::state::PreflightTab::Files && !ctx.file_info.is_empty() {
453        // Expand/collapse all packages in Files tab
454        let all_expanded = ctx
455            .file_info
456            .iter()
457            .filter(|p| !p.files.is_empty())
458            .all(|p| ctx.file_tree_expanded.contains(&p.name));
459        if all_expanded {
460            // Collapse all
461            ctx.file_tree_expanded.clear();
462        } else {
463            // Expand all
464            for pkg_info in ctx.file_info.iter() {
465                if !pkg_info.files.is_empty() {
466                    ctx.file_tree_expanded.insert(pkg_info.name.clone());
467                }
468            }
469        }
470    }
471    false
472}