Skip to main content

fresh/app/
popup_overlay_actions.rs

1//! Popup, overlay, and LSP-confirmation orchestrators on `Editor`.
2//!
3//! Three loosely-related clusters that all manipulate the active buffer's
4//! popup stack and overlay list via Event dispatch:
5//!
6//!   - Overlay management (add_overlay, remove_overlay,
7//!     remove_overlays_in_range, clear_overlays)
8//!   - Popup lifecycle (show_popup, hide_popup, dismiss_transient_popups,
9//!     scroll_popup, on_editor_focus_lost, clear_popups, popup nav)
10//!   - LSP confirmation popup (show_lsp_confirmation_popup,
11//!     handle_lsp_confirmation_response, notify_lsp_current_file_opened,
12//!     has_pending_lsp_confirmation)
13
14use std::ops::Range;
15
16use rust_i18n::t;
17
18use crate::model::event::Event;
19
20use super::Editor;
21
22impl Editor {
23    // === Overlay Management (Event-Driven) ===
24
25    /// Add an overlay for decorations (underlines, highlights, etc.)
26    pub fn add_overlay(
27        &mut self,
28        namespace: Option<crate::view::overlay::OverlayNamespace>,
29        range: Range<usize>,
30        face: crate::model::event::OverlayFace,
31        priority: i32,
32        message: Option<String>,
33    ) -> crate::view::overlay::OverlayHandle {
34        let event = Event::AddOverlay {
35            namespace,
36            range,
37            face,
38            priority,
39            message,
40            extend_to_line_end: false,
41            url: None,
42        };
43        self.apply_event_to_active_buffer(&event);
44        // Return the handle of the last added overlay
45        let state = self.active_state();
46        state
47            .overlays
48            .all()
49            .last()
50            .map(|o| o.handle.clone())
51            .unwrap_or_default()
52    }
53
54    /// Remove an overlay by handle
55    pub fn remove_overlay(&mut self, handle: crate::view::overlay::OverlayHandle) {
56        let event = Event::RemoveOverlay { handle };
57        self.apply_event_to_active_buffer(&event);
58    }
59
60    /// Remove all overlays in a range
61    pub fn remove_overlays_in_range(&mut self, range: Range<usize>) {
62        let event = Event::RemoveOverlaysInRange { range };
63        self.active_event_log_mut().append(event.clone());
64        self.apply_event_to_active_buffer(&event);
65    }
66
67    /// Clear all overlays
68    pub fn clear_overlays(&mut self) {
69        let event = Event::ClearOverlays;
70        self.active_event_log_mut().append(event.clone());
71        self.apply_event_to_active_buffer(&event);
72    }
73
74    // === Popup Management (Event-Driven) ===
75
76    /// Show a popup window
77    pub fn show_popup(&mut self, popup: crate::model::event::PopupData) {
78        let event = Event::ShowPopup { popup };
79        self.active_event_log_mut().append(event.clone());
80        self.apply_event_to_active_buffer(&event);
81        // Stamp the freshly-pushed popup with the user's actual
82        // focus-popup keybinding so the title hint reflects the
83        // configured key (default `Alt+T`). The PopupData event itself
84        // doesn't carry this — it's a view-layer concern set after the
85        // converter pushes the Popup onto the active buffer's stack.
86        let hint = self.popup_focus_key_hint();
87        if let Some(top) = self.active_state_mut().popups.top_mut() {
88            top.focus_key_hint = hint;
89        }
90    }
91
92    /// Show a popup and attach a confirm/cancel resolver to it. The
93    /// `PopupData` event doesn't carry the resolver (it's a view-layer
94    /// concern that doesn't need event-log replay); we set it on the
95    /// resulting `Popup` immediately after `show_popup` pushes it.
96    pub fn show_popup_with_resolver(
97        &mut self,
98        popup: crate::model::event::PopupData,
99        resolver: crate::view::popup::PopupResolver,
100    ) {
101        self.show_popup(popup);
102        if let Some(top) = self.active_state_mut().popups.top_mut() {
103            top.resolver = resolver;
104        }
105    }
106
107    /// Hide the topmost popup
108    pub fn hide_popup(&mut self) {
109        // Editor-level popups take precedence: dismiss them first if any are
110        // visible. This avoids leaking a popup-stack pop event into the
111        // active buffer's event log when the popup we're closing is global.
112        if self.global_popups.is_visible() {
113            self.global_popups.hide();
114
115            // Clear hover symbol highlight if present (kept for parity with
116            // the buffer-popup branch even though global popups don't use it
117            // today — cheap no-op when nothing is set).
118            if let Some(handle) = self.hover.take_symbol_overlay() {
119                let remove_overlay_event = crate::model::event::Event::RemoveOverlay { handle };
120                self.apply_event_to_active_buffer(&remove_overlay_event);
121            }
122            self.hover.set_symbol_range(None);
123            return;
124        }
125
126        let event = Event::HidePopup;
127        self.active_event_log_mut().append(event.clone());
128        self.apply_event_to_active_buffer(&event);
129
130        // Complete --wait tracking if this buffer had a popup-based wait
131        let active = self.active_buffer();
132        if let Some((wait_id, true)) = self.wait_tracking.remove(&active) {
133            self.completed_waits.push(wait_id);
134        }
135
136        // Clear hover symbol highlight if present
137        if let Some(handle) = self.hover.take_symbol_overlay() {
138            let remove_overlay_event = crate::model::event::Event::RemoveOverlay { handle };
139            self.apply_event_to_active_buffer(&remove_overlay_event);
140        }
141        self.hover.set_symbol_range(None);
142    }
143
144    /// Dismiss transient popups if present
145    /// These popups should be dismissed on scroll or other user actions
146    pub(super) fn dismiss_transient_popups(&mut self) {
147        // Action popups are persistent by design — only buffer-level transient
148        // popups (Hover, Signature Help) get auto-dismissed here.
149        let is_transient_popup = self
150            .active_state()
151            .popups
152            .top()
153            .is_some_and(|p| p.transient);
154
155        if is_transient_popup {
156            self.hide_popup();
157            tracing::trace!("Dismissed transient popup");
158        }
159    }
160
161    /// Scroll any popup content by delta lines
162    /// Positive delta scrolls down, negative scrolls up
163    pub(super) fn scroll_popup(&mut self, delta: i32) {
164        if let Some(popup) = self.global_popups.top_mut() {
165            popup.scroll_by(delta);
166            return;
167        }
168        if let Some(popup) = self.active_state_mut().popups.top_mut() {
169            popup.scroll_by(delta);
170            tracing::debug!(
171                "Scrolled popup by {}, new offset: {}",
172                delta,
173                popup.scroll_offset
174            );
175        }
176    }
177
178    /// Called when the editor buffer loses focus (e.g., switching buffers,
179    /// opening prompts/menus, focusing file explorer, etc.)
180    ///
181    /// This is the central handler for focus loss that:
182    /// - Dismisses transient popups (Hover, Signature Help)
183    /// - Clears LSP hover state and pending requests
184    /// - Removes hover symbol highlighting
185    pub(super) fn on_editor_focus_lost(&mut self) {
186        // Dismiss transient popups via EditorState
187        self.active_state_mut().on_focus_lost();
188
189        // Clear hover state
190        self.mouse_state.lsp_hover_state = None;
191        self.mouse_state.lsp_hover_request_sent = false;
192        self.hover.clear_pending();
193
194        // Clear hover symbol highlight if present
195        if let Some(handle) = self.hover.take_symbol_overlay() {
196            let remove_overlay_event = crate::model::event::Event::RemoveOverlay { handle };
197            self.apply_event_to_active_buffer(&remove_overlay_event);
198        }
199        self.hover.set_symbol_range(None);
200
201        // Any focus change (buffer switch, file explorer, menus, …) ends the
202        // goto-line preview flow. Drop the snapshot so a later Esc cannot
203        // rubber-band the cursor over state the user has moved past.
204        self.goto_line_preview = None;
205    }
206
207    /// Clear all popups
208    pub fn clear_popups(&mut self) {
209        let event = Event::ClearPopups;
210        self.active_event_log_mut().append(event.clone());
211        self.apply_event_to_active_buffer(&event);
212    }
213
214    // === LSP Confirmation Popup ===
215
216    /// Show the LSP confirmation popup for a language server
217    ///
218    /// This displays a centered popup asking the user to confirm whether
219    /// they want to start the LSP server for the given language.
220    pub fn show_lsp_confirmation_popup(&mut self, language: &str) {
221        use crate::model::event::{
222            PopupContentData, PopupData, PopupKindHint, PopupListItemData, PopupPositionData,
223        };
224
225        // Get the server command for display
226        let server_info = if let Some(lsp) = &self.lsp {
227            if let Some(config) = lsp.get_config(language) {
228                if !config.command.is_empty() {
229                    format!("{} ({})", language, config.command)
230                } else {
231                    language.to_string()
232                }
233            } else {
234                language.to_string()
235            }
236        } else {
237            language.to_string()
238        };
239
240        let popup = PopupData {
241            kind: PopupKindHint::List,
242            title: Some(format!("Start LSP Server: {}?", server_info)),
243            description: None,
244            transient: false,
245            content: PopupContentData::List {
246                items: vec![
247                    PopupListItemData {
248                        text: "Allow this time".to_string(),
249                        detail: Some("Start the LSP server for this session".to_string()),
250                        icon: None,
251                        data: Some("allow_once".to_string()),
252                    },
253                    PopupListItemData {
254                        text: "Always allow".to_string(),
255                        detail: Some("Always start this LSP server automatically".to_string()),
256                        icon: None,
257                        data: Some("allow_always".to_string()),
258                    },
259                    PopupListItemData {
260                        text: "Don't start".to_string(),
261                        detail: Some("Cancel LSP server startup".to_string()),
262                        icon: None,
263                        data: Some("deny".to_string()),
264                    },
265                ],
266                selected: 0,
267            },
268            position: PopupPositionData::Centered,
269            width: 50,
270            max_height: 8,
271            bordered: true,
272        };
273
274        // The language travels with the popup via its resolver so
275        // confirm time reads it from the popup itself — no side-channel
276        // Editor field needed, and no coupling between popups.
277        self.show_popup_with_resolver(
278            popup,
279            crate::view::popup::PopupResolver::LspConfirm {
280                language: language.to_string(),
281            },
282        );
283    }
284
285    /// Handle the LSP confirmation popup response
286    ///
287    /// This is called when the user confirms their selection in the LSP
288    /// confirmation popup. It processes the response and starts the LSP
289    /// server if approved.
290    ///
291    /// `language` is read from the confirming popup's `PopupResolver`
292    /// (no side-channel), so `handle_popup_confirm`'s resolver match
293    /// can call us directly with what it destructured out of the popup.
294    pub fn handle_lsp_confirmation_response(&mut self, language: &str, action: &str) -> bool {
295        let language = language.to_string();
296
297        // Get file path from active buffer for workspace root detection
298        let file_path = self
299            .buffer_metadata
300            .get(&self.active_buffer())
301            .and_then(|meta| meta.file_path().cloned());
302
303        match action {
304            "allow_once" => {
305                // Spawn the LSP server just this once (don't add to always-allowed)
306                if let Some(lsp) = &mut self.lsp {
307                    // Temporarily allow this language for spawning
308                    lsp.allow_language(&language);
309                    // Use force_spawn since user explicitly confirmed
310                    if lsp.force_spawn(&language, file_path.as_deref()).is_some() {
311                        tracing::info!("LSP server for {} started (allowed once)", language);
312                        self.set_status_message(
313                            t!("lsp.server_started", language = language).to_string(),
314                        );
315                    } else {
316                        self.set_status_message(
317                            t!("lsp.failed_to_start", language = language).to_string(),
318                        );
319                    }
320                }
321                // Notify LSP about the current file
322                self.notify_lsp_current_file_opened(&language);
323            }
324            "allow_always" => {
325                // Spawn the LSP server and remember the preference
326                if let Some(lsp) = &mut self.lsp {
327                    lsp.allow_language(&language);
328                    // Use force_spawn since user explicitly confirmed
329                    if lsp.force_spawn(&language, file_path.as_deref()).is_some() {
330                        tracing::info!("LSP server for {} started (always allowed)", language);
331                        self.set_status_message(
332                            t!("lsp.server_started_auto", language = language).to_string(),
333                        );
334                    } else {
335                        self.set_status_message(
336                            t!("lsp.failed_to_start", language = language).to_string(),
337                        );
338                    }
339                }
340                // Notify LSP about the current file
341                self.notify_lsp_current_file_opened(&language);
342            }
343            _ => {
344                // User declined - don't start the server
345                tracing::info!("LSP server for {} startup declined by user", language);
346                self.set_status_message(
347                    t!("lsp.startup_cancelled", language = language).to_string(),
348                );
349            }
350        }
351
352        true
353    }
354
355    /// Notify LSP about the currently open file
356    ///
357    /// This is called after an LSP server is started to notify it about
358    /// the current file so it can provide features like diagnostics.
359    fn notify_lsp_current_file_opened(&mut self, language: &str) {
360        // Get buffer metadata for the active buffer
361        let metadata = match self.buffer_metadata.get(&self.active_buffer()) {
362            Some(m) => m,
363            None => {
364                tracing::debug!(
365                    "notify_lsp_current_file_opened: no metadata for buffer {:?}",
366                    self.active_buffer()
367                );
368                return;
369            }
370        };
371
372        if !metadata.lsp_enabled {
373            tracing::debug!("notify_lsp_current_file_opened: LSP disabled for this buffer");
374            return;
375        }
376
377        // Get file path for LSP spawn
378        let file_path = metadata.file_path().cloned();
379
380        // Get the URI (computed once in with_file)
381        let uri = match metadata.file_uri() {
382            Some(u) => u.clone(),
383            None => {
384                tracing::debug!(
385                    "notify_lsp_current_file_opened: no URI for buffer (not a file or URI creation failed)"
386                );
387                return;
388            }
389        };
390
391        // Get the buffer text and line count before borrowing lsp
392        let active_buffer = self.active_buffer();
393
394        // Use buffer's stored language to verify it matches the LSP server
395        let file_language = match self.buffers.get(&active_buffer).map(|s| s.language.clone()) {
396            Some(l) => l,
397            None => {
398                tracing::debug!("notify_lsp_current_file_opened: no buffer state");
399                return;
400            }
401        };
402
403        // Only notify if the file's language matches the LSP server we just started
404        if file_language != language {
405            tracing::debug!(
406                "notify_lsp_current_file_opened: file language {} doesn't match server {}",
407                file_language,
408                language
409            );
410            return;
411        }
412        let (text, line_count, buffer_version) =
413            if let Some(state) = self.buffers.get(&active_buffer) {
414                let text = match state.buffer.to_string() {
415                    Some(t) => t,
416                    None => {
417                        tracing::debug!("notify_lsp_current_file_opened: buffer not fully loaded");
418                        return;
419                    }
420                };
421                let line_count = state.buffer.line_count().unwrap_or(1000);
422                (text, line_count, state.buffer.version())
423            } else {
424                tracing::debug!("notify_lsp_current_file_opened: no buffer state");
425                return;
426            };
427
428        // Send didOpen to all LSP handles (use force_spawn to ensure they're started)
429        if let Some(lsp) = &mut self.lsp {
430            // force_spawn starts all servers for this language
431            if lsp.force_spawn(language, file_path.as_deref()).is_some() {
432                tracing::info!("Sending didOpen to LSP servers for: {}", uri.as_str());
433                let mut any_opened = false;
434                for sh in lsp.get_handles_mut(language) {
435                    if let Err(e) = sh.handle.did_open(
436                        uri.as_uri().clone(),
437                        text.clone(),
438                        file_language.clone(),
439                    ) {
440                        tracing::warn!("Failed to send didOpen to '{}': {}", sh.name, e);
441                    } else {
442                        any_opened = true;
443                    }
444                }
445
446                if any_opened {
447                    tracing::info!("Successfully sent didOpen to LSP after confirmation");
448
449                    // Request pull diagnostics from primary handle
450                    if let Some(handle) = lsp.get_handle_mut(language) {
451                        let previous_result_id =
452                            self.diagnostic_result_ids.get(uri.as_str()).cloned();
453                        let request_id = self.next_lsp_request_id;
454                        self.next_lsp_request_id += 1;
455
456                        if let Err(e) = handle.document_diagnostic(
457                            request_id,
458                            uri.as_uri().clone(),
459                            previous_result_id,
460                        ) {
461                            tracing::debug!(
462                                "Failed to request pull diagnostics (server may not support): {}",
463                                e
464                            );
465                        }
466
467                        // Request inlay hints if enabled
468                        if self.config.editor.enable_inlay_hints {
469                            let request_id = self.next_lsp_request_id;
470                            self.next_lsp_request_id += 1;
471
472                            let last_line = line_count.saturating_sub(1) as u32;
473                            let last_char = 10000u32;
474
475                            if let Err(e) = handle.inlay_hints(
476                                request_id,
477                                uri.as_uri().clone(),
478                                0,
479                                0,
480                                last_line,
481                                last_char,
482                            ) {
483                                tracing::debug!(
484                                    "Failed to request inlay hints (server may not support): {}",
485                                    e
486                                );
487                            } else {
488                                self.pending_inlay_hints_requests.insert(
489                                    request_id,
490                                    super::InlayHintsRequest {
491                                        buffer_id: active_buffer,
492                                        version: buffer_version,
493                                    },
494                                );
495                            }
496                        }
497                    }
498                }
499            }
500        }
501    }
502
503    /// Check if the topmost visible popup is the LSP confirmation
504    /// popup. Used by callers that need to know "is an LSP confirm
505    /// prompt currently in front of the user?" — e.g. the file-open
506    /// queue waits on this instead of racing past the prompt.
507    pub fn has_pending_lsp_confirmation(&self) -> bool {
508        use crate::view::popup::PopupResolver;
509        let matches_lsp_confirm = |p: &crate::view::popup::Popup| -> bool {
510            matches!(p.resolver, PopupResolver::LspConfirm { .. })
511        };
512        self.global_popups.top().is_some_and(matches_lsp_confirm)
513            || self
514                .active_state()
515                .popups
516                .top()
517                .is_some_and(matches_lsp_confirm)
518    }
519
520    /// Navigate popup selection (next item)
521    pub fn popup_select_next(&mut self) {
522        if let Some(popup) = self.global_popups.top_mut() {
523            popup.select_next();
524            return;
525        }
526        let event = Event::PopupSelectNext;
527        self.active_event_log_mut().append(event.clone());
528        self.apply_event_to_active_buffer(&event);
529    }
530
531    /// Navigate popup selection (previous item)
532    pub fn popup_select_prev(&mut self) {
533        if let Some(popup) = self.global_popups.top_mut() {
534            popup.select_prev();
535            return;
536        }
537        let event = Event::PopupSelectPrev;
538        self.active_event_log_mut().append(event.clone());
539        self.apply_event_to_active_buffer(&event);
540    }
541
542    /// Navigate popup (page down)
543    pub fn popup_page_down(&mut self) {
544        if let Some(popup) = self.global_popups.top_mut() {
545            popup.page_down();
546            return;
547        }
548        let event = Event::PopupPageDown;
549        self.active_event_log_mut().append(event.clone());
550        self.apply_event_to_active_buffer(&event);
551    }
552
553    /// Navigate popup (page up)
554    pub fn popup_page_up(&mut self) {
555        if let Some(popup) = self.global_popups.top_mut() {
556            popup.page_up();
557            return;
558        }
559        let event = Event::PopupPageUp;
560        self.active_event_log_mut().append(event.clone());
561        self.apply_event_to_active_buffer(&event);
562    }
563}