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