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