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