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