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