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    /// Returns `PopupConfirmResult` indicating what the caller should do next.
23    pub fn handle_popup_confirm(&mut self) -> PopupConfirmResult {
24        // Check if this is an LSP status details popup
25        if self.pending_lsp_status_popup.is_some() {
26            let action_key = self
27                .active_state()
28                .popups
29                .top()
30                .and_then(|p| p.selected_item())
31                .and_then(|item| item.data.clone());
32
33            self.hide_popup();
34            self.pending_lsp_status_popup = None;
35
36            if let Some(key) = action_key {
37                self.handle_lsp_status_action(&key);
38            }
39            return PopupConfirmResult::EarlyReturn;
40        }
41
42        // Check if this is an action popup (from plugin showActionPopup)
43        if let Some((popup_id, _actions)) = &self.active_action_popup {
44            let popup_id = popup_id.clone();
45            let action_id = self
46                .active_state()
47                .popups
48                .top()
49                .and_then(|p| p.selected_item())
50                .and_then(|item| item.data.clone())
51                .unwrap_or_else(|| "dismissed".to_string());
52
53            self.hide_popup();
54            self.active_action_popup = None;
55
56            // Fire the ActionPopupResult hook
57            self.plugin_manager.run_hook(
58                "action_popup_result",
59                crate::services::plugins::hooks::HookArgs::ActionPopupResult {
60                    popup_id,
61                    action_id,
62                },
63            );
64
65            return PopupConfirmResult::EarlyReturn;
66        }
67
68        // Check if this is a code action popup
69        if self.pending_code_actions.is_some() {
70            let selected_index = self
71                .active_state()
72                .popups
73                .top()
74                .and_then(|p| p.selected_item())
75                .and_then(|item| item.data.as_ref())
76                .and_then(|data| data.parse::<usize>().ok());
77
78            self.hide_popup();
79            if let Some(index) = selected_index {
80                self.execute_code_action(index);
81            }
82            self.pending_code_actions = None;
83            return PopupConfirmResult::EarlyReturn;
84        }
85
86        // Check if this is an LSP confirmation popup
87        if self.pending_lsp_confirmation.is_some() {
88            let action = self
89                .active_state()
90                .popups
91                .top()
92                .and_then(|p| p.selected_item())
93                .and_then(|item| item.data.clone());
94            if let Some(action) = action {
95                self.hide_popup();
96                self.handle_lsp_confirmation_response(&action);
97                return PopupConfirmResult::EarlyReturn;
98            }
99        }
100
101        // If it's a completion popup, insert the selected item
102        let completion_info = self
103            .active_state()
104            .popups
105            .top()
106            .filter(|p| p.kind == crate::view::popup::PopupKind::Completion)
107            .and_then(|p| p.selected_item())
108            .map(|item| (item.text.clone(), item.data.clone()));
109
110        // Perform the completion if we have text
111        if let Some((label, insert_text)) = completion_info {
112            if let Some(text) = insert_text {
113                self.insert_completion_text(text);
114            }
115
116            // Apply additional_text_edits (e.g., auto-imports) from the matching CompletionItem
117            self.apply_completion_additional_edits(&label);
118        }
119
120        self.hide_popup();
121        PopupConfirmResult::Done
122    }
123
124    /// Insert completion text, replacing the word prefix at cursor.
125    /// If the text contains LSP snippet syntax, it will be expanded.
126    fn insert_completion_text(&mut self, text: String) {
127        // Check if this is a snippet and expand it
128        let (insert_text, cursor_offset) = if is_snippet(&text) {
129            let expanded = expand_snippet(&text);
130            (expanded.text, Some(expanded.cursor_offset))
131        } else {
132            (text, None)
133        };
134
135        let (cursor_id, cursor_pos, word_start) = {
136            let cursors = self.active_cursors();
137            let cursor_id = cursors.primary_id();
138            let cursor_pos = cursors.primary().position;
139            let state = self.active_state();
140            let word_start = find_completion_word_start(&state.buffer, cursor_pos);
141            (cursor_id, cursor_pos, word_start)
142        };
143
144        let deleted_text = if word_start < cursor_pos {
145            self.active_state_mut()
146                .get_text_range(word_start, cursor_pos)
147        } else {
148            String::new()
149        };
150
151        let insert_pos = if word_start < cursor_pos {
152            let delete_event = Event::Delete {
153                range: word_start..cursor_pos,
154                deleted_text,
155                cursor_id,
156            };
157
158            self.log_and_apply_event(&delete_event);
159
160            let buffer_len = self.active_state().buffer.len();
161            word_start.min(buffer_len)
162        } else {
163            cursor_pos
164        };
165
166        let insert_event = Event::Insert {
167            position: insert_pos,
168            text: insert_text.clone(),
169            cursor_id,
170        };
171
172        self.log_and_apply_event(&insert_event);
173
174        // If this was a snippet, position cursor at the snippet's $0 location
175        if let Some(offset) = cursor_offset {
176            let new_cursor_pos = insert_pos + offset;
177            // Get current cursor position after the insert
178            let current_pos = self.active_cursors().primary().position;
179            if current_pos != new_cursor_pos {
180                let move_event = Event::MoveCursor {
181                    cursor_id,
182                    old_position: current_pos,
183                    new_position: new_cursor_pos,
184                    old_anchor: None,
185                    new_anchor: None,
186                    old_sticky_column: 0,
187                    new_sticky_column: 0,
188                };
189                let split_id = self.split_manager.active_split();
190                let buffer_id = self.active_buffer();
191                let state = self.buffers.get_mut(&buffer_id).unwrap();
192                let cursors = &mut self.split_view_states.get_mut(&split_id).unwrap().cursors;
193                state.apply(cursors, &move_event);
194            }
195        }
196    }
197
198    /// Apply additional_text_edits from the accepted completion item (e.g. auto-imports).
199    /// If the item already has additional_text_edits, apply them directly.
200    /// If not and the server supports completionItem/resolve, send a resolve request
201    /// so the server can fill them in (the response is handled asynchronously).
202    fn apply_completion_additional_edits(&mut self, label: &str) {
203        // Find the matching CompletionItem from stored items
204        let item = self
205            .completion_items
206            .as_ref()
207            .and_then(|items| items.iter().find(|item| item.label == label).cloned());
208
209        let Some(item) = item else { return };
210
211        if let Some(edits) = &item.additional_text_edits {
212            if !edits.is_empty() {
213                tracing::info!(
214                    "Applying {} additional text edits from completion '{}'",
215                    edits.len(),
216                    label
217                );
218                let buffer_id = self.active_buffer();
219                if let Err(e) = self.apply_lsp_text_edits(buffer_id, edits.clone()) {
220                    tracing::error!("Failed to apply completion additional_text_edits: {}", e);
221                }
222                return;
223            }
224        }
225
226        // No additional_text_edits present — try resolve if server supports it
227        if self.server_supports_completion_resolve() {
228            tracing::info!(
229                "Completion '{}' has no additional_text_edits, sending completionItem/resolve",
230                label
231            );
232            self.send_completion_resolve(item);
233        }
234    }
235
236    /// Handle PopupCancel action.
237    pub fn handle_popup_cancel(&mut self) {
238        tracing::info!(
239            "handle_popup_cancel: active_action_popup={:?}",
240            self.active_action_popup.as_ref().map(|(id, _)| id)
241        );
242
243        // Check if this is an LSP status details popup
244        if self.pending_lsp_status_popup.take().is_some() {
245            self.hide_popup();
246            return;
247        }
248
249        // Check if this is an action popup (from plugin showActionPopup)
250        if let Some((popup_id, _actions)) = self.active_action_popup.take() {
251            tracing::info!(
252                "handle_popup_cancel: dismissing action popup id={}",
253                popup_id
254            );
255            self.hide_popup();
256
257            // Fire the ActionPopupResult hook with "dismissed"
258            self.plugin_manager.run_hook(
259                "action_popup_result",
260                crate::services::plugins::hooks::HookArgs::ActionPopupResult {
261                    popup_id,
262                    action_id: "dismissed".to_string(),
263                },
264            );
265            tracing::info!("handle_popup_cancel: action_popup_result hook fired");
266            return;
267        }
268
269        if self.pending_code_actions.is_some() {
270            self.pending_code_actions = None;
271            self.hide_popup();
272            return;
273        }
274
275        if self.pending_lsp_confirmation.is_some() {
276            self.pending_lsp_confirmation = None;
277            self.set_status_message(t!("lsp.startup_cancelled_msg").to_string());
278        }
279        self.hide_popup();
280        // Clear completion items when popup is closed
281        self.completion_items = None;
282    }
283
284    /// Get the formatted key hint for the completion accept action (e.g. "Tab").
285    /// Looks up the keybinding for the ConfirmPopup/Tab action in completion context.
286    pub(crate) fn completion_accept_key_hint(&self) -> Option<String> {
287        // Tab is hardcoded in the completion input handler, so default to "Tab"
288        Some("Tab".to_string())
289    }
290
291    /// Handle typing a character while completion popup is open.
292    /// Inserts the character into the buffer and re-filters the completion list.
293    pub fn handle_popup_type_char(&mut self, c: char) {
294        // First, insert the character into the buffer
295        let (cursor_id, cursor_pos) = {
296            let cursors = self.active_cursors();
297            (cursors.primary_id(), cursors.primary().position)
298        };
299
300        let insert_event = Event::Insert {
301            position: cursor_pos,
302            text: c.to_string(),
303            cursor_id,
304        };
305
306        self.log_and_apply_event(&insert_event);
307
308        // Now re-filter the completion list
309        self.refilter_completion_popup();
310    }
311
312    /// Handle backspace while completion popup is open.
313    /// Deletes a character and re-filters the completion list.
314    pub fn handle_popup_backspace(&mut self) {
315        let (cursor_id, cursor_pos) = {
316            let cursors = self.active_cursors();
317            (cursors.primary_id(), cursors.primary().position)
318        };
319
320        // Don't do anything if at start of buffer
321        if cursor_pos == 0 {
322            return;
323        }
324
325        // Find the previous character boundary
326        let prev_pos = {
327            let state = self.active_state();
328            let text = match state.buffer.to_string() {
329                Some(t) => t,
330                None => return,
331            };
332            // Find the previous character
333            text[..cursor_pos]
334                .char_indices()
335                .last()
336                .map(|(i, _)| i)
337                .unwrap_or(0)
338        };
339
340        let deleted_text = self.active_state_mut().get_text_range(prev_pos, cursor_pos);
341
342        let delete_event = Event::Delete {
343            range: prev_pos..cursor_pos,
344            deleted_text,
345            cursor_id,
346        };
347
348        self.log_and_apply_event(&delete_event);
349
350        // Now re-filter the completion list
351        self.refilter_completion_popup();
352    }
353
354    /// Re-filter the completion popup based on current prefix.
355    /// If no items match, dismiss the popup.
356    fn refilter_completion_popup(&mut self) {
357        // Get stored LSP completion items (may be empty if no LSP).
358        let lsp_items = self.completion_items.clone().unwrap_or_default();
359
360        // Get current prefix
361        let (word_start, cursor_pos) = {
362            let cursor_pos = self.active_cursors().primary().position;
363            let state = self.active_state();
364            let word_start = find_completion_word_start(&state.buffer, cursor_pos);
365            (word_start, cursor_pos)
366        };
367
368        let prefix = if word_start < cursor_pos {
369            self.active_state_mut()
370                .get_text_range(word_start, cursor_pos)
371                .to_lowercase()
372        } else {
373            String::new()
374        };
375
376        // Filter LSP items
377        let filtered_lsp: Vec<&lsp_types::CompletionItem> = if prefix.is_empty() {
378            lsp_items.iter().collect()
379        } else {
380            lsp_items
381                .iter()
382                .filter(|item| {
383                    item.label.to_lowercase().starts_with(&prefix)
384                        || item
385                            .filter_text
386                            .as_ref()
387                            .map(|ft| ft.to_lowercase().starts_with(&prefix))
388                            .unwrap_or(false)
389                })
390                .collect()
391        };
392
393        // Build combined items: LSP first, then buffer-word results.
394        let mut all_popup_items = lsp_items_to_popup_items(&filtered_lsp);
395        let buffer_word_items = self.get_buffer_completion_popup_items();
396        let lsp_labels: std::collections::HashSet<String> = all_popup_items
397            .iter()
398            .map(|i| i.text.to_lowercase())
399            .collect();
400        all_popup_items.extend(
401            buffer_word_items
402                .into_iter()
403                .filter(|item| !lsp_labels.contains(&item.text.to_lowercase())),
404        );
405
406        // If no items match from either source, dismiss popup.
407        if all_popup_items.is_empty() {
408            self.hide_popup();
409            self.completion_items = None;
410            return;
411        }
412
413        // Get current selection to try preserving it
414        let current_selection = self
415            .active_state()
416            .popups
417            .top()
418            .and_then(|p| p.selected_item())
419            .map(|item| item.text.clone());
420
421        // Try to preserve selection
422        let selected = current_selection
423            .and_then(|sel| all_popup_items.iter().position(|item| item.text == sel))
424            .unwrap_or(0);
425
426        let popup_data = build_completion_popup_from_items(all_popup_items, selected);
427        let accept_hint = self.completion_accept_key_hint();
428
429        // Close old popup and show new one
430        self.hide_popup();
431        let buffer_id = self.active_buffer();
432        let state = self.buffers.get_mut(&buffer_id).unwrap();
433        let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
434        popup_obj.accept_key_hint = accept_hint;
435        state.popups.show_or_replace(popup_obj);
436    }
437}
438
439/// Build a completion popup from a combined list of already-converted items.
440///
441/// Used when merging LSP results + buffer-word results into a single popup.
442pub(crate) fn build_completion_popup_from_items(
443    items: Vec<crate::model::event::PopupListItemData>,
444    selected: usize,
445) -> crate::model::event::PopupData {
446    use crate::model::event::{PopupContentData, PopupKindHint, PopupPositionData};
447
448    crate::model::event::PopupData {
449        kind: PopupKindHint::Completion,
450        title: None,
451        description: None,
452        transient: false,
453        content: PopupContentData::List { items, selected },
454        position: PopupPositionData::BelowCursor,
455        width: 50,
456        max_height: 15,
457        bordered: true,
458    }
459}
460
461/// Convert LSP `CompletionItem`s to `PopupListItemData`s.
462pub(crate) fn lsp_items_to_popup_items(
463    items: &[&lsp_types::CompletionItem],
464) -> Vec<crate::model::event::PopupListItemData> {
465    use crate::model::event::PopupListItemData;
466
467    items
468        .iter()
469        .map(|item| {
470            let icon = match item.kind {
471                Some(lsp_types::CompletionItemKind::FUNCTION)
472                | Some(lsp_types::CompletionItemKind::METHOD) => Some("λ".to_string()),
473                Some(lsp_types::CompletionItemKind::VARIABLE) => Some("v".to_string()),
474                Some(lsp_types::CompletionItemKind::STRUCT)
475                | Some(lsp_types::CompletionItemKind::CLASS) => Some("S".to_string()),
476                Some(lsp_types::CompletionItemKind::CONSTANT) => Some("c".to_string()),
477                Some(lsp_types::CompletionItemKind::KEYWORD) => Some("k".to_string()),
478                _ => None,
479            };
480
481            PopupListItemData {
482                text: item.label.clone(),
483                detail: item.detail.clone(),
484                icon,
485                data: item
486                    .insert_text
487                    .clone()
488                    .or_else(|| Some(item.label.clone())),
489            }
490        })
491        .collect()
492}