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