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