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