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