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