Skip to main content

fresh/app/
popup_actions.rs

1//! Popup action handlers.
2//!
3//! This module contains handlers for popup-related actions like confirmation and cancellation.
4
5use super::Editor;
6use crate::model::event::Event;
7use crate::primitives::snippet::{expand_snippet, is_snippet};
8use crate::primitives::word_navigation::find_completion_word_start;
9use rust_i18n::t;
10
11/// Result of handling a popup confirmation.
12pub enum PopupConfirmResult {
13    /// Popup handled, continue normally
14    Done,
15    /// Popup handled, should return early from handle_action
16    EarlyReturn,
17}
18
19impl Editor {
20    /// Handle PopupConfirm action.
21    ///
22    /// Dispatches by reading the currently-focused popup's `PopupResolver`
23    /// — the popup itself carries its own "how do I confirm?" identity.
24    /// This eliminates the old side-channel cascade where `pending_X:
25    /// Option<...>` flags competed for precedence: two popups coexisting
26    /// (e.g. plugin action popup on the global stack + LSP auto-prompt
27    /// on the buffer stack) would race on whose flag the cascade hit
28    /// first, and the wrong branch would claim the key.
29    ///
30    /// Global popups shadow buffer popups for keyboard focus (see
31    /// `input_dispatch::dispatch_modal_input`), so the confirm path
32    /// picks the same popup: global first, then the active buffer.
33    pub fn handle_popup_confirm(&mut self) -> PopupConfirmResult {
34        use crate::view::popup::PopupResolver;
35
36        // Clone the top popup's resolver so we can `match` on it without
37        // keeping a borrow on `self.global_popups` / `self.buffers`
38        // while the handler mutates the editor.
39        let resolver = if self.global_popups.is_visible() {
40            self.global_popups.top().map(|p| p.resolver.clone())
41        } else {
42            self.active_state().popups.top().map(|p| p.resolver.clone())
43        };
44
45        match resolver {
46            Some(PopupResolver::PluginAction { popup_id }) => {
47                let action_id = self
48                    .global_popups
49                    .top()
50                    .or_else(|| self.active_state().popups.top())
51                    .and_then(|p| p.selected_item())
52                    .and_then(|item| item.data.clone())
53                    .unwrap_or_else(|| "dismissed".to_string());
54                self.hide_popup();
55                self.plugin_manager.read().unwrap().run_hook(
56                    "action_popup_result",
57                    crate::services::plugins::hooks::HookArgs::ActionPopupResult {
58                        popup_id,
59                        action_id,
60                    },
61                );
62                PopupConfirmResult::EarlyReturn
63            }
64
65            Some(PopupResolver::LspStatus) => {
66                let action_key = self
67                    .active_state()
68                    .popups
69                    .top()
70                    .and_then(|p| p.selected_item())
71                    .and_then(|item| item.data.clone());
72                self.hide_popup();
73                if let Some(key) = action_key {
74                    self.handle_lsp_status_action(&key);
75                }
76                PopupConfirmResult::EarlyReturn
77            }
78
79            Some(PopupResolver::CodeAction) => {
80                let selected_index = self
81                    .active_state()
82                    .popups
83                    .top()
84                    .and_then(|p| p.selected_item())
85                    .and_then(|item| item.data.as_ref())
86                    .and_then(|data| data.parse::<usize>().ok());
87                self.hide_popup();
88                if let Some(index) = selected_index {
89                    self.execute_code_action(index);
90                }
91                self.active_window_mut().pending_code_actions = None;
92                PopupConfirmResult::EarlyReturn
93            }
94
95            Some(PopupResolver::LspConfirm { language }) => {
96                let action = self
97                    .active_state()
98                    .popups
99                    .top()
100                    .and_then(|p| p.selected_item())
101                    .and_then(|item| item.data.clone());
102                if let Some(action) = action {
103                    self.hide_popup();
104                    self.handle_lsp_confirmation_response(&language, &action);
105                    PopupConfirmResult::EarlyReturn
106                } else {
107                    self.hide_popup();
108                    PopupConfirmResult::EarlyReturn
109                }
110            }
111
112            Some(PopupResolver::RemoteIndicator) => {
113                let action_key = self
114                    .active_state()
115                    .popups
116                    .top()
117                    .and_then(|p| p.selected_item())
118                    .and_then(|item| item.data.clone());
119                self.hide_popup();
120                if let Some(key) = action_key {
121                    self.handle_remote_indicator_action(&key);
122                }
123                PopupConfirmResult::EarlyReturn
124            }
125
126            Some(PopupResolver::ReadOnly) => {
127                let action_key = self
128                    .active_state()
129                    .popups
130                    .top()
131                    .and_then(|p| p.selected_item())
132                    .and_then(|item| item.data.clone());
133                self.hide_popup();
134                if let Some(key) = action_key {
135                    self.handle_read_only_menu_action(&key);
136                }
137                PopupConfirmResult::EarlyReturn
138            }
139
140            Some(PopupResolver::WorkspaceTrust) => {
141                // The trust prompt lives on the global stack; read its
142                // selection there (global-first, matching the resolver lookup).
143                let action_key = self
144                    .global_popups
145                    .top()
146                    .or_else(|| self.active_state().popups.top())
147                    .and_then(|p| p.selected_item())
148                    .and_then(|item| item.data.clone());
149                self.hide_popup();
150                if let Some(key) = action_key {
151                    self.handle_workspace_trust_action(&key);
152                }
153                PopupConfirmResult::EarlyReturn
154            }
155
156            Some(PopupResolver::Completion) => {
157                // Grab the selected item's label + insert-text before we
158                // mutate the popup stack — insert_completion_text edits
159                // the buffer, which invalidates the borrow.
160                let completion_info = self
161                    .active_state()
162                    .popups
163                    .top()
164                    .and_then(|p| p.selected_item())
165                    .map(|item| (item.text.clone(), item.data.clone()));
166                if let Some((label, insert_text)) = completion_info {
167                    if let Some(text) = insert_text {
168                        self.insert_completion_text(text);
169                    }
170                    self.apply_completion_additional_edits(&label);
171                }
172                self.hide_popup();
173                PopupConfirmResult::Done
174            }
175
176            Some(PopupResolver::SettingsSaveError { layer }) => {
177                // Acknowledging the save-failed popup opens the offending
178                // config file so the user can fix the syntax error.
179                self.hide_popup();
180                if let Err(e) = self.open_config_file(layer) {
181                    tracing::warn!("Failed to open config file after save error: {}", e);
182                }
183                PopupConfirmResult::Done
184            }
185
186            Some(PopupResolver::None) | None => {
187                self.hide_popup();
188                PopupConfirmResult::Done
189            }
190        }
191    }
192
193    /// Insert completion text, replacing the word prefix at *every* cursor.
194    /// If the text contains LSP snippet syntax, it will be expanded.
195    ///
196    /// Multi-cursor: each cursor's own word prefix is replaced, so cursors
197    /// stay in lock-step after the accept (issue #1901, accept path). All
198    /// per-cursor edits go through `apply_events_as_bulk_edit` so undo is
199    /// atomic.
200    fn insert_completion_text(&mut self, text: String) {
201        use crate::model::event::CursorId;
202
203        // Check if this is a snippet and expand it
204        let (insert_text, cursor_offset) = if is_snippet(&text) {
205            let expanded = expand_snippet(&text);
206            (expanded.text, Some(expanded.cursor_offset))
207        } else {
208            (text, None)
209        };
210
211        // Collect per-cursor data: id, current position, word_start, prefix text.
212        let cursor_data: Vec<(CursorId, usize, usize, String)> = {
213            let positions: Vec<(CursorId, usize)> = self
214                .active_cursors()
215                .iter()
216                .map(|(id, c)| (id, c.position))
217                .collect();
218            positions
219                .into_iter()
220                .map(|(id, pos)| {
221                    let word_start = {
222                        let state = self.active_state();
223                        find_completion_word_start(&state.buffer, pos)
224                    };
225                    let prefix = if word_start < pos {
226                        self.active_state_mut().get_text_range(word_start, pos)
227                    } else {
228                        String::new()
229                    };
230                    (id, pos, word_start, prefix)
231                })
232                .collect()
233        };
234
235        // Build delete+insert events. `apply_events_as_bulk_edit` sorts by
236        // descending position internally, so emission order doesn't matter.
237        let mut events: Vec<Event> = Vec::new();
238        for (cursor_id, pos, word_start, prefix) in &cursor_data {
239            if *word_start < *pos {
240                events.push(Event::Delete {
241                    range: *word_start..*pos,
242                    deleted_text: prefix.clone(),
243                    cursor_id: *cursor_id,
244                });
245            }
246            events.push(Event::Insert {
247                position: *word_start,
248                text: insert_text.clone(),
249                cursor_id: *cursor_id,
250            });
251        }
252
253        if events.is_empty() {
254            return;
255        }
256
257        let description = "Accept completion".to_string();
258        if cursor_data.len() > 1 || events.len() > 1 {
259            // Multi-cursor (or replacement = delete+insert): one atomic bulk edit.
260            if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description) {
261                self.active_event_log_mut().append(bulk_edit);
262            }
263        } else {
264            for event in events {
265                self.log_and_apply_event(&event);
266            }
267        }
268
269        // Snippet placement: after the bulk edit, each cursor sits at the end
270        // of its own inserted text; the snippet's $0 sits `cursor_offset` bytes
271        // into that text. Walk each cursor back to its $0 placeholder.
272        if let Some(offset) = cursor_offset {
273            if offset != insert_text.len() {
274                let move_events: Vec<Event> = self
275                    .active_cursors()
276                    .iter()
277                    .map(|(cursor_id, cursor)| {
278                        let current = cursor.position;
279                        let target = current.saturating_sub(insert_text.len()) + offset;
280                        Event::MoveCursor {
281                            cursor_id,
282                            old_position: current,
283                            new_position: target,
284                            old_anchor: cursor.anchor,
285                            new_anchor: None,
286                            old_sticky_column: cursor.sticky_column,
287                            new_sticky_column: 0,
288                        }
289                    })
290                    .collect();
291                for event in move_events {
292                    self.log_and_apply_event(&event);
293                }
294            }
295        }
296    }
297
298    /// Apply additional_text_edits from the accepted completion item (e.g. auto-imports).
299    /// If the item already has additional_text_edits, apply them directly.
300    /// If not and the server supports completionItem/resolve, send a resolve request
301    /// so the server can fill them in (the response is handled asynchronously).
302    fn apply_completion_additional_edits(&mut self, label: &str) {
303        // Find the matching CompletionItem from stored items
304        let item = self
305            .active_window_mut()
306            .completion_items
307            .as_ref()
308            .and_then(|items| items.iter().find(|item| item.label == label).cloned());
309
310        let Some(item) = item else { return };
311
312        if let Some(edits) = &item.additional_text_edits {
313            if !edits.is_empty() {
314                tracing::info!(
315                    "Applying {} additional text edits from completion '{}'",
316                    edits.len(),
317                    label
318                );
319                let buffer_id = self.active_buffer();
320                if let Err(e) = self.apply_lsp_text_edits(buffer_id, edits.clone()) {
321                    tracing::error!("Failed to apply completion additional_text_edits: {}", e);
322                }
323                return;
324            }
325        }
326
327        // No additional_text_edits present — try resolve if server supports it
328        if self.active_window().server_supports_completion_resolve() {
329            tracing::info!(
330                "Completion '{}' has no additional_text_edits, sending completionItem/resolve",
331                label
332            );
333            self.active_window_mut().send_completion_resolve(item);
334        }
335    }
336
337    /// Handle PopupCancel action.
338    ///
339    /// Mirrors `handle_popup_confirm`: dispatch on the focused popup's
340    /// `PopupResolver`. Each flavour does its own cleanup; no
341    /// precedence between unrelated popup types.
342    pub fn handle_popup_cancel(&mut self) {
343        use crate::view::popup::PopupResolver;
344
345        let resolver = if self.global_popups.is_visible() {
346            self.global_popups.top().map(|p| p.resolver.clone())
347        } else {
348            self.active_state().popups.top().map(|p| p.resolver.clone())
349        };
350
351        match resolver {
352            Some(PopupResolver::PluginAction { popup_id }) => {
353                tracing::info!(
354                    "handle_popup_cancel: dismissing action popup id={}",
355                    popup_id
356                );
357                self.hide_popup();
358                self.plugin_manager.read().unwrap().run_hook(
359                    "action_popup_result",
360                    crate::services::plugins::hooks::HookArgs::ActionPopupResult {
361                        popup_id,
362                        action_id: "dismissed".to_string(),
363                    },
364                );
365            }
366
367            Some(PopupResolver::LspStatus) => {
368                self.hide_popup();
369            }
370
371            Some(PopupResolver::CodeAction) => {
372                self.active_window_mut().pending_code_actions = None;
373                self.hide_popup();
374            }
375
376            Some(PopupResolver::LspConfirm { language: _ }) => {
377                self.set_status_message(t!("lsp.startup_cancelled_msg").to_string());
378                self.hide_popup();
379            }
380
381            Some(PopupResolver::Completion) => {
382                self.hide_popup();
383                self.active_window_mut().completion_items = None;
384            }
385
386            Some(PopupResolver::RemoteIndicator) => {
387                self.hide_popup();
388            }
389
390            Some(PopupResolver::ReadOnly) => {
391                self.hide_popup();
392            }
393
394            Some(PopupResolver::WorkspaceTrust) => {
395                // The trust prompt is a forced choice: there is no "undecided"
396                // outcome, so Escape does nothing. The user must pick Trust /
397                // Restricted / Blocked (each records a concrete decision).
398            }
399
400            Some(PopupResolver::SettingsSaveError { layer }) => {
401                // Acknowledging the save-failed popup opens the offending
402                // config file so the user can fix the syntax error.
403                self.hide_popup();
404                if let Err(e) = self.open_config_file(layer) {
405                    tracing::warn!("Failed to open config file after save error: {}", e);
406                }
407            }
408
409            Some(PopupResolver::None) | None => {
410                self.hide_popup();
411                self.active_window_mut().completion_items = None;
412            }
413        }
414    }
415
416    /// Get the formatted key hint for the completion accept action (e.g. "Tab").
417    /// Looks up the keybinding for the ConfirmPopup/Tab action in completion context.
418    pub(crate) fn completion_accept_key_hint(&self) -> Option<String> {
419        // Tab is hardcoded in the completion input handler, so default to "Tab"
420        Some("Tab".to_string())
421    }
422
423    /// Format the keybinding currently bound to `Action::PopupFocus`,
424    /// rendered into popup titles when the popup is unfocused so the
425    /// user can see how to grab the keyboard. Falls back to `Alt+T`
426    /// (the default) when no binding is registered.
427    pub(crate) fn popup_focus_key_hint(&self) -> Option<String> {
428        let kb = self.keybindings.read().ok()?;
429        // The keymap registers `popup_focus` in the `Normal` and
430        // `FileExplorer` contexts (not `Global`) so a user's own
431        // `alt+t` rebinding in those same contexts wins at the same
432        // precedence level — a Global default would shadow the
433        // override and silently swallow the user's keystroke. Look up
434        // Normal first (the most likely place a user is when the
435        // popup pops up), then fall through to FileExplorer, and
436        // finally to a hard-coded `Alt+T` so the title is never an
437        // empty parenthetical.
438        kb.get_keybinding_for_action(
439            &crate::input::keybindings::Action::PopupFocus,
440            crate::input::keybindings::KeyContext::Normal,
441        )
442        .or_else(|| {
443            kb.get_keybinding_for_action(
444                &crate::input::keybindings::Action::PopupFocus,
445                crate::input::keybindings::KeyContext::FileExplorer,
446            )
447        })
448        .or_else(|| Some("Alt+T".to_string()))
449    }
450
451    /// Mark the topmost visible popup as focused so subsequent key
452    /// events route into the popup's input handler.
453    ///
454    /// Editor-level (global) popups shadow buffer popups for keyboard
455    /// focus, mirroring the priority encoded in `dispatch_modal_input`,
456    /// so we focus whichever popup the user actually sees.
457    ///
458    /// No-op when no popup is visible — the user pressing the
459    /// focus-popup key with nothing to focus shouldn't error or steal
460    /// the keystroke from the buffer.
461    pub fn handle_popup_focus(&mut self) {
462        if let Some(popup) = self.global_popups.top_mut() {
463            popup.focused = true;
464            return;
465        }
466        if let Some(popup) = self.active_state_mut().popups.top_mut() {
467            popup.focused = true;
468        }
469    }
470
471    /// Handle typing a character while completion popup is open.
472    /// Inserts the character at every cursor and re-filters the completion list.
473    ///
474    /// Routes through `Action::InsertChar` so multi-cursor edits land in lock-
475    /// step with normal typing: secondary cursors stay in sync with the
476    /// primary one (issue #1901) and a single bulk-edit goes into the undo log.
477    pub fn handle_popup_type_char(&mut self, c: char) {
478        use crate::input::keybindings::Action;
479
480        if let Some(events) = self
481            .active_window_mut()
482            .action_to_events(Action::InsertChar(c))
483        {
484            if events.len() > 1 {
485                let description = format!("Insert '{}'", c);
486                if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description) {
487                    self.active_event_log_mut().append(bulk_edit);
488                }
489            } else {
490                for event in events {
491                    self.log_and_apply_event(&event);
492                }
493            }
494        }
495
496        self.refilter_completion_popup();
497    }
498
499    /// Handle backspace while completion popup is open.
500    /// Deletes one character behind every cursor and re-filters the
501    /// completion list.
502    ///
503    /// Routes through `Action::DeleteBackward` so multi-cursor edits stay in
504    /// sync (issue #1901). The action handler already no-ops cursors at the
505    /// start of the buffer.
506    pub fn handle_popup_backspace(&mut self) {
507        use crate::input::keybindings::Action;
508
509        if let Some(events) = self
510            .active_window_mut()
511            .action_to_events(Action::DeleteBackward)
512        {
513            if events.len() > 1 {
514                if let Some(bulk_edit) =
515                    self.apply_events_as_bulk_edit(events, "Backspace".to_string())
516                {
517                    self.active_event_log_mut().append(bulk_edit);
518                }
519            } else {
520                for event in events {
521                    self.log_and_apply_event(&event);
522                }
523            }
524        }
525
526        self.refilter_completion_popup();
527    }
528
529    /// Re-filter the completion popup based on current prefix.
530    /// If no items match, dismiss the popup.
531    fn refilter_completion_popup(&mut self) {
532        // Get stored LSP completion items (may be empty if no LSP).
533        let lsp_items = self
534            .active_window_mut()
535            .completion_items
536            .clone()
537            .unwrap_or_default();
538
539        // Get current prefix
540        let (word_start, cursor_pos) = {
541            let cursor_pos = self.active_cursors().primary().position;
542            let state = self.active_state();
543            let word_start = find_completion_word_start(&state.buffer, cursor_pos);
544            (word_start, cursor_pos)
545        };
546
547        let prefix = if word_start < cursor_pos {
548            self.active_state_mut()
549                .get_text_range(word_start, cursor_pos)
550                .to_lowercase()
551        } else {
552            String::new()
553        };
554
555        // Filter LSP items
556        let filtered_lsp: Vec<&lsp_types::CompletionItem> = if prefix.is_empty() {
557            lsp_items.iter().collect()
558        } else {
559            lsp_items
560                .iter()
561                .filter(|item| {
562                    item.label.to_lowercase().starts_with(&prefix)
563                        || item
564                            .filter_text
565                            .as_ref()
566                            .map(|ft| ft.to_lowercase().starts_with(&prefix))
567                            .unwrap_or(false)
568                })
569                .collect()
570        };
571
572        // Build combined items: LSP first, then buffer-word results.
573        let mut all_popup_items = lsp_items_to_popup_items(&filtered_lsp);
574        let buffer_word_items = self.get_buffer_completion_popup_items();
575        let lsp_labels: std::collections::HashSet<String> = all_popup_items
576            .iter()
577            .map(|i| i.text.to_lowercase())
578            .collect();
579        all_popup_items.extend(
580            buffer_word_items
581                .into_iter()
582                .filter(|item| !lsp_labels.contains(&item.text.to_lowercase())),
583        );
584
585        // If no items match from either source, dismiss popup.
586        if all_popup_items.is_empty() {
587            self.hide_popup();
588            self.active_window_mut().completion_items = None;
589            return;
590        }
591
592        // Get current selection to try preserving it
593        let current_selection = self
594            .active_state()
595            .popups
596            .top()
597            .and_then(|p| p.selected_item())
598            .map(|item| item.text.clone());
599
600        // Try to preserve selection
601        let selected = current_selection
602            .and_then(|sel| all_popup_items.iter().position(|item| item.text == sel))
603            .unwrap_or(0);
604
605        let popup_data = build_completion_popup_from_items(all_popup_items, selected);
606        let accept_hint = self.completion_accept_key_hint();
607        let (popup_bg, popup_border_fg) = {
608            let theme = self.theme();
609            (theme.popup_bg, theme.popup_border_fg)
610        };
611
612        // Close old popup and show new one
613        self.hide_popup();
614        let buffer_id = self.active_buffer();
615        let state = self
616            .windows
617            .get_mut(&self.active_window)
618            .map(|w| &mut w.buffers)
619            .expect("active window present")
620            .get_mut(&buffer_id)
621            .unwrap();
622        let mut popup_obj =
623            crate::state::convert_popup_data_to_popup(&popup_data, popup_bg, popup_border_fg);
624        popup_obj.accept_key_hint = accept_hint;
625        popup_obj.resolver = crate::view::popup::PopupResolver::Completion;
626        state.popups.show_or_replace(popup_obj);
627    }
628}
629
630/// Build a completion popup from a combined list of already-converted items.
631///
632/// Used when merging LSP results + buffer-word results into a single popup.
633pub(crate) fn build_completion_popup_from_items(
634    items: Vec<crate::model::event::PopupListItemData>,
635    selected: usize,
636) -> crate::model::event::PopupData {
637    use crate::model::event::{PopupContentData, PopupKindHint, PopupPositionData};
638
639    crate::model::event::PopupData {
640        kind: PopupKindHint::Completion,
641        title: None,
642        description: None,
643        transient: false,
644        content: PopupContentData::List { items, selected },
645        position: PopupPositionData::BelowCursor,
646        width: 50,
647        max_height: 15,
648        bordered: true,
649    }
650}
651
652/// Convert LSP `CompletionItem`s to `PopupListItemData`s.
653pub(crate) fn lsp_items_to_popup_items(
654    items: &[&lsp_types::CompletionItem],
655) -> Vec<crate::model::event::PopupListItemData> {
656    use crate::model::event::PopupListItemData;
657
658    items
659        .iter()
660        .map(|item| {
661            let icon = match item.kind {
662                Some(lsp_types::CompletionItemKind::FUNCTION)
663                | Some(lsp_types::CompletionItemKind::METHOD) => Some("λ".to_string()),
664                Some(lsp_types::CompletionItemKind::VARIABLE) => Some("v".to_string()),
665                Some(lsp_types::CompletionItemKind::STRUCT)
666                | Some(lsp_types::CompletionItemKind::CLASS) => Some("S".to_string()),
667                Some(lsp_types::CompletionItemKind::CONSTANT) => Some("c".to_string()),
668                Some(lsp_types::CompletionItemKind::KEYWORD) => Some("k".to_string()),
669                _ => None,
670            };
671
672            PopupListItemData {
673                text: item.label.clone(),
674                detail: item.detail.clone(),
675                icon,
676                data: item
677                    .insert_text
678                    .clone()
679                    .or_else(|| Some(item.label.clone())),
680            }
681        })
682        .collect()
683}