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 an LSP confirmation popup
51        if self.pending_lsp_confirmation.is_some() {
52            let action = self
53                .active_state()
54                .popups
55                .top()
56                .and_then(|p| p.selected_item())
57                .and_then(|item| item.data.clone());
58            if let Some(action) = action {
59                self.hide_popup();
60                self.handle_lsp_confirmation_response(&action);
61                return PopupConfirmResult::EarlyReturn;
62            }
63        }
64
65        // If it's a completion popup, insert the selected item
66        let completion_text = self
67            .active_state()
68            .popups
69            .top()
70            .filter(|p| p.kind == crate::view::popup::PopupKind::Completion)
71            .and_then(|p| p.selected_item())
72            .and_then(|item| item.data.clone());
73
74        // Perform the completion if we have text
75        if let Some(text) = completion_text {
76            self.insert_completion_text(text);
77        }
78
79        self.hide_popup();
80        PopupConfirmResult::Done
81    }
82
83    /// Insert completion text, replacing the word prefix at cursor.
84    /// If the text contains LSP snippet syntax, it will be expanded.
85    fn insert_completion_text(&mut self, text: String) {
86        // Check if this is a snippet and expand it
87        let (insert_text, cursor_offset) = if is_snippet(&text) {
88            let expanded = expand_snippet(&text);
89            (expanded.text, Some(expanded.cursor_offset))
90        } else {
91            (text, None)
92        };
93
94        let (cursor_id, cursor_pos, word_start) = {
95            let cursors = self.active_cursors();
96            let cursor_id = cursors.primary_id();
97            let cursor_pos = cursors.primary().position;
98            let state = self.active_state();
99            let word_start = find_completion_word_start(&state.buffer, cursor_pos);
100            (cursor_id, cursor_pos, word_start)
101        };
102
103        let deleted_text = if word_start < cursor_pos {
104            self.active_state_mut()
105                .get_text_range(word_start, cursor_pos)
106        } else {
107            String::new()
108        };
109
110        let insert_pos = if word_start < cursor_pos {
111            let delete_event = Event::Delete {
112                range: word_start..cursor_pos,
113                deleted_text,
114                cursor_id,
115            };
116
117            self.active_event_log_mut().append(delete_event.clone());
118            self.apply_event_to_active_buffer(&delete_event);
119
120            let buffer_len = self.active_state().buffer.len();
121            word_start.min(buffer_len)
122        } else {
123            cursor_pos
124        };
125
126        let insert_event = Event::Insert {
127            position: insert_pos,
128            text: insert_text.clone(),
129            cursor_id,
130        };
131
132        self.active_event_log_mut().append(insert_event.clone());
133        self.apply_event_to_active_buffer(&insert_event);
134
135        // If this was a snippet, position cursor at the snippet's $0 location
136        if let Some(offset) = cursor_offset {
137            let new_cursor_pos = insert_pos + offset;
138            // Get current cursor position after the insert
139            let current_pos = self.active_cursors().primary().position;
140            if current_pos != new_cursor_pos {
141                let move_event = Event::MoveCursor {
142                    cursor_id,
143                    old_position: current_pos,
144                    new_position: new_cursor_pos,
145                    old_anchor: None,
146                    new_anchor: None,
147                    old_sticky_column: 0,
148                    new_sticky_column: 0,
149                };
150                let split_id = self.split_manager.active_split();
151                let buffer_id = self.active_buffer();
152                let state = self.buffers.get_mut(&buffer_id).unwrap();
153                let cursors = &mut self.split_view_states.get_mut(&split_id).unwrap().cursors;
154                state.apply(cursors, &move_event);
155            }
156        }
157    }
158
159    /// Handle PopupCancel action.
160    pub fn handle_popup_cancel(&mut self) {
161        tracing::info!(
162            "handle_popup_cancel: active_action_popup={:?}",
163            self.active_action_popup.as_ref().map(|(id, _)| id)
164        );
165
166        // Check if this is an action popup (from plugin showActionPopup)
167        if let Some((popup_id, _actions)) = self.active_action_popup.take() {
168            tracing::info!(
169                "handle_popup_cancel: dismissing action popup id={}",
170                popup_id
171            );
172            self.hide_popup();
173
174            // Fire the ActionPopupResult hook with "dismissed"
175            self.plugin_manager.run_hook(
176                "action_popup_result",
177                crate::services::plugins::hooks::HookArgs::ActionPopupResult {
178                    popup_id,
179                    action_id: "dismissed".to_string(),
180                },
181            );
182            tracing::info!("handle_popup_cancel: action_popup_result hook fired");
183            return;
184        }
185
186        if self.pending_lsp_confirmation.is_some() {
187            self.pending_lsp_confirmation = None;
188            self.set_status_message(t!("lsp.startup_cancelled_msg").to_string());
189        }
190        self.hide_popup();
191        // Clear completion items when popup is closed
192        self.completion_items = None;
193    }
194
195    /// Handle typing a character while completion popup is open.
196    /// Inserts the character into the buffer and re-filters the completion list.
197    pub fn handle_popup_type_char(&mut self, c: char) {
198        // First, insert the character into the buffer
199        let (cursor_id, cursor_pos) = {
200            let cursors = self.active_cursors();
201            (cursors.primary_id(), cursors.primary().position)
202        };
203
204        let insert_event = Event::Insert {
205            position: cursor_pos,
206            text: c.to_string(),
207            cursor_id,
208        };
209
210        self.active_event_log_mut().append(insert_event.clone());
211        self.apply_event_to_active_buffer(&insert_event);
212
213        // Now re-filter the completion list
214        self.refilter_completion_popup();
215    }
216
217    /// Handle backspace while completion popup is open.
218    /// Deletes a character and re-filters the completion list.
219    pub fn handle_popup_backspace(&mut self) {
220        let (cursor_id, cursor_pos) = {
221            let cursors = self.active_cursors();
222            (cursors.primary_id(), cursors.primary().position)
223        };
224
225        // Don't do anything if at start of buffer
226        if cursor_pos == 0 {
227            return;
228        }
229
230        // Find the previous character boundary
231        let prev_pos = {
232            let state = self.active_state();
233            let text = match state.buffer.to_string() {
234                Some(t) => t,
235                None => return,
236            };
237            // Find the previous character
238            text[..cursor_pos]
239                .char_indices()
240                .last()
241                .map(|(i, _)| i)
242                .unwrap_or(0)
243        };
244
245        let deleted_text = self.active_state_mut().get_text_range(prev_pos, cursor_pos);
246
247        let delete_event = Event::Delete {
248            range: prev_pos..cursor_pos,
249            deleted_text,
250            cursor_id,
251        };
252
253        self.active_event_log_mut().append(delete_event.clone());
254        self.apply_event_to_active_buffer(&delete_event);
255
256        // Now re-filter the completion list
257        self.refilter_completion_popup();
258    }
259
260    /// Re-filter the completion popup based on current prefix.
261    /// If no items match, dismiss the popup.
262    fn refilter_completion_popup(&mut self) {
263        // Get stored completion items
264        let items = match &self.completion_items {
265            Some(items) if !items.is_empty() => items.clone(),
266            _ => {
267                self.hide_popup();
268                return;
269            }
270        };
271
272        // Get current prefix
273        let (word_start, cursor_pos) = {
274            let cursor_pos = self.active_cursors().primary().position;
275            let state = self.active_state();
276            let word_start = find_completion_word_start(&state.buffer, cursor_pos);
277            (word_start, cursor_pos)
278        };
279
280        let prefix = if word_start < cursor_pos {
281            self.active_state_mut()
282                .get_text_range(word_start, cursor_pos)
283                .to_lowercase()
284        } else {
285            String::new()
286        };
287
288        // Filter items
289        let filtered_items: Vec<&lsp_types::CompletionItem> = if prefix.is_empty() {
290            items.iter().collect()
291        } else {
292            items
293                .iter()
294                .filter(|item| {
295                    item.label.to_lowercase().starts_with(&prefix)
296                        || item
297                            .filter_text
298                            .as_ref()
299                            .map(|ft| ft.to_lowercase().starts_with(&prefix))
300                            .unwrap_or(false)
301                })
302                .collect()
303        };
304
305        // If no items match, dismiss popup
306        if filtered_items.is_empty() {
307            self.hide_popup();
308            self.completion_items = None;
309            return;
310        }
311
312        // Get current selection to try preserving it
313        let current_selection = self
314            .active_state()
315            .popups
316            .top()
317            .and_then(|p| p.selected_item())
318            .map(|item| item.text.clone());
319
320        // Try to preserve selection
321        let selected = current_selection
322            .and_then(|sel| filtered_items.iter().position(|item| item.label == sel))
323            .unwrap_or(0);
324
325        let popup_data = build_completion_popup(&filtered_items, selected);
326
327        // Close old popup and show new one
328        self.hide_popup();
329        let split_id = self.split_manager.active_split();
330        let buffer_id = self.active_buffer();
331        let state = self.buffers.get_mut(&buffer_id).unwrap();
332        let cursors = &mut self.split_view_states.get_mut(&split_id).unwrap().cursors;
333        state.apply(
334            cursors,
335            &crate::model::event::Event::ShowPopup { popup: popup_data },
336        );
337    }
338}
339
340/// Build a completion `PopupData` from a list of LSP `CompletionItem`s.
341///
342/// This is the single code path for creating completion popups, used both for
343/// the initial LSP completion response and for re-filtering during type-to-filter.
344pub(crate) fn build_completion_popup(
345    items: &[&lsp_types::CompletionItem],
346    selected: usize,
347) -> crate::model::event::PopupData {
348    use crate::model::event::{
349        PopupContentData, PopupKindHint, PopupListItemData, PopupPositionData,
350    };
351
352    let list_items: Vec<PopupListItemData> = items
353        .iter()
354        .map(|item| {
355            let icon = match item.kind {
356                Some(lsp_types::CompletionItemKind::FUNCTION)
357                | Some(lsp_types::CompletionItemKind::METHOD) => Some("λ".to_string()),
358                Some(lsp_types::CompletionItemKind::VARIABLE) => Some("v".to_string()),
359                Some(lsp_types::CompletionItemKind::STRUCT)
360                | Some(lsp_types::CompletionItemKind::CLASS) => Some("S".to_string()),
361                Some(lsp_types::CompletionItemKind::CONSTANT) => Some("c".to_string()),
362                Some(lsp_types::CompletionItemKind::KEYWORD) => Some("k".to_string()),
363                _ => None,
364            };
365
366            PopupListItemData {
367                text: item.label.clone(),
368                detail: item.detail.clone(),
369                icon,
370                data: item
371                    .insert_text
372                    .clone()
373                    .or_else(|| Some(item.label.clone())),
374            }
375        })
376        .collect();
377
378    crate::model::event::PopupData {
379        kind: PopupKindHint::Completion,
380        title: None,
381        description: None,
382        transient: false,
383        content: PopupContentData::List {
384            items: list_items,
385            selected,
386        },
387        position: PopupPositionData::BelowCursor,
388        width: 50,
389        max_height: 15,
390        bordered: true,
391    }
392}