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
345 .windows
346 .get_mut(&__active_id)
347 .and_then(|w| w.lsp.as_mut())
348 {
349 // Temporarily allow this language for spawning
350 lsp.allow_language(&language);
351 // Use force_spawn since user explicitly confirmed
352 if lsp.force_spawn(&language, file_path.as_deref()).is_some() {
353 tracing::info!("LSP server for {} started (allowed once)", language);
354 self.set_status_message(
355 t!("lsp.server_started", language = language).to_string(),
356 );
357 } else {
358 self.set_status_message(
359 t!("lsp.failed_to_start", language = language).to_string(),
360 );
361 }
362 }
363 // Notify LSP about the current file
364 self.notify_lsp_current_file_opened(&language);
365 }
366 "allow_always" => {
367 // Spawn the LSP server and remember the preference
368 let __active_id = self.active_window;
369 if let Some(lsp) = self
370 .windows
371 .get_mut(&__active_id)
372 .and_then(|w| w.lsp.as_mut())
373 {
374 lsp.allow_language(&language);
375 // Use force_spawn since user explicitly confirmed
376 if lsp.force_spawn(&language, file_path.as_deref()).is_some() {
377 tracing::info!("LSP server for {} started (always allowed)", language);
378 self.set_status_message(
379 t!("lsp.server_started_auto", language = language).to_string(),
380 );
381 } else {
382 self.set_status_message(
383 t!("lsp.failed_to_start", language = language).to_string(),
384 );
385 }
386 }
387 // Notify LSP about the current file
388 self.notify_lsp_current_file_opened(&language);
389 }
390 _ => {
391 // User declined - don't start the server
392 tracing::info!("LSP server for {} startup declined by user", language);
393 self.set_status_message(
394 t!("lsp.startup_cancelled", language = language).to_string(),
395 );
396 }
397 }
398
399 true
400 }
401
402 /// Notify LSP about the currently open file
403 ///
404 /// This is called after an LSP server is started to notify it about
405 /// the current file so it can provide features like diagnostics.
406 fn notify_lsp_current_file_opened(&mut self, language: &str) {
407 // Get buffer metadata for the active buffer
408 let metadata = match self
409 .active_window()
410 .buffer_metadata
411 .get(&self.active_buffer())
412 {
413 Some(m) => m,
414 None => {
415 tracing::debug!(
416 "notify_lsp_current_file_opened: no metadata for buffer {:?}",
417 self.active_buffer()
418 );
419 return;
420 }
421 };
422
423 if !metadata.lsp_enabled {
424 tracing::debug!("notify_lsp_current_file_opened: LSP disabled for this buffer");
425 return;
426 }
427
428 // Get file path for LSP spawn
429 let file_path = metadata.file_path().cloned();
430
431 // Get the URI (computed once in with_file)
432 let uri = match metadata.file_uri() {
433 Some(u) => u.clone(),
434 None => {
435 tracing::debug!(
436 "notify_lsp_current_file_opened: no URI for buffer (not a file or URI creation failed)"
437 );
438 return;
439 }
440 };
441
442 // Get the buffer text and line count before borrowing lsp
443 let active_buffer = self.active_buffer();
444
445 // Use buffer's stored language to verify it matches the LSP server
446 let file_language = match self
447 .windows
448 .get(&self.active_window)
449 .map(|w| &w.buffers)
450 .expect("active window present")
451 .get(&active_buffer)
452 .map(|s| s.language.clone())
453 {
454 Some(l) => l,
455 None => {
456 tracing::debug!("notify_lsp_current_file_opened: no buffer state");
457 return;
458 }
459 };
460
461 // Only notify if the file's language matches the LSP server we just started
462 if file_language != language {
463 tracing::debug!(
464 "notify_lsp_current_file_opened: file language {} doesn't match server {}",
465 file_language,
466 language
467 );
468 return;
469 }
470 let (text, line_count, buffer_version) = if let Some(state) = self
471 .windows
472 .get(&self.active_window)
473 .map(|w| &w.buffers)
474 .expect("active window present")
475 .get(&active_buffer)
476 {
477 let text = match state.buffer.to_string() {
478 Some(t) => t,
479 None => {
480 tracing::debug!("notify_lsp_current_file_opened: buffer not fully loaded");
481 return;
482 }
483 };
484 let line_count = state.buffer.line_count().unwrap_or(1000);
485 (text, line_count, state.buffer.version())
486 } else {
487 tracing::debug!("notify_lsp_current_file_opened: no buffer state");
488 return;
489 };
490
491 // Send didOpen to all LSP handles (use force_spawn to ensure they're started)
492 let __active_id = self.active_window;
493 let enable_inlay_hints = self.config.editor.enable_inlay_hints;
494 let __win = self
495 .windows
496 .get_mut(&__active_id)
497 .expect("active window must exist");
498 let diagnostic_result_ids = &__win.diagnostic_result_ids;
499 let __next_id = &mut __win.next_lsp_request_id;
500 if let Some(lsp) = __win.lsp.as_mut() {
501 // force_spawn starts all servers for this language
502 if lsp.force_spawn(language, file_path.as_deref()).is_some() {
503 tracing::info!("Sending didOpen to LSP servers for: {}", uri.as_str());
504 let mut any_opened = false;
505 for sh in lsp.get_handles_mut(language) {
506 if let Err(e) = sh.handle.did_open(
507 uri.as_uri().clone(),
508 text.clone(),
509 file_language.clone(),
510 ) {
511 tracing::warn!("Failed to send didOpen to '{}': {}", sh.name, e);
512 } else {
513 any_opened = true;
514 }
515 }
516
517 if any_opened {
518 tracing::info!("Successfully sent didOpen to LSP after confirmation");
519
520 // Request pull diagnostics from primary handle
521 if let Some(handle) = lsp.get_handle_mut(language) {
522 let previous_result_id = diagnostic_result_ids.get(uri.as_str()).cloned();
523 let request_id = {
524 let id = *__next_id;
525 *__next_id += 1;
526 id
527 };
528
529 if let Err(e) = handle.document_diagnostic(
530 request_id,
531 uri.as_uri().clone(),
532 previous_result_id,
533 ) {
534 tracing::debug!(
535 "Failed to request pull diagnostics (server may not support): {}",
536 e
537 );
538 }
539
540 // Request inlay hints if enabled
541 if enable_inlay_hints {
542 let request_id = {
543 let id = *__next_id;
544 *__next_id += 1;
545 id
546 };
547
548 let last_line = line_count.saturating_sub(1) as u32;
549 let last_char = 10000u32;
550
551 if let Err(e) = handle.inlay_hints(
552 request_id,
553 uri.as_uri().clone(),
554 0,
555 0,
556 last_line,
557 last_char,
558 ) {
559 tracing::debug!(
560 "Failed to request inlay hints (server may not support): {}",
561 e
562 );
563 } else {
564 self.active_window_mut()
565 .pending_inlay_hints_requests
566 .insert(
567 request_id,
568 super::InlayHintsRequest {
569 buffer_id: active_buffer,
570 version: buffer_version,
571 },
572 );
573 }
574 }
575 }
576 }
577 }
578 }
579 }
580
581 /// Check if the topmost visible popup is the LSP confirmation
582 /// popup. Used by callers that need to know "is an LSP confirm
583 /// prompt currently in front of the user?" — e.g. the file-open
584 /// queue waits on this instead of racing past the prompt.
585 pub fn has_pending_lsp_confirmation(&self) -> bool {
586 use crate::view::popup::PopupResolver;
587 let matches_lsp_confirm = |p: &crate::view::popup::Popup| -> bool {
588 matches!(p.resolver, PopupResolver::LspConfirm { .. })
589 };
590 self.global_popups.top().is_some_and(matches_lsp_confirm)
591 || self
592 .active_state()
593 .popups
594 .top()
595 .is_some_and(matches_lsp_confirm)
596 }
597
598 /// Navigate popup selection (next item)
599 pub fn popup_select_next(&mut self) {
600 if let Some(popup) = self.global_popups.top_mut() {
601 popup.select_next();
602 return;
603 }
604 let event = Event::PopupSelectNext;
605 self.active_event_log_mut().append(event.clone());
606 self.apply_event_to_active_buffer(&event);
607 }
608
609 /// Navigate popup selection (previous item)
610 pub fn popup_select_prev(&mut self) {
611 if let Some(popup) = self.global_popups.top_mut() {
612 popup.select_prev();
613 return;
614 }
615 let event = Event::PopupSelectPrev;
616 self.active_event_log_mut().append(event.clone());
617 self.apply_event_to_active_buffer(&event);
618 }
619
620 /// Navigate popup (page down)
621 pub fn popup_page_down(&mut self) {
622 if let Some(popup) = self.global_popups.top_mut() {
623 popup.page_down();
624 return;
625 }
626 let event = Event::PopupPageDown;
627 self.active_event_log_mut().append(event.clone());
628 self.apply_event_to_active_buffer(&event);
629 }
630
631 /// Navigate popup (page up)
632 pub fn popup_page_up(&mut self) {
633 if let Some(popup) = self.global_popups.top_mut() {
634 popup.page_up();
635 return;
636 }
637 let event = Event::PopupPageUp;
638 self.active_event_log_mut().append(event.clone());
639 self.apply_event_to_active_buffer(&event);
640 }
641}
642
643impl Window {
644 /// Called when the editor buffer loses focus (e.g., switching buffers,
645 /// opening prompts/menus, focusing file explorer, etc.)
646 ///
647 /// Dismisses transient popups, clears LSP hover state and pending requests,
648 /// and removes hover symbol highlighting.
649 pub(crate) fn on_editor_focus_lost(&mut self) {
650 // Dismiss transient popups via EditorState
651 self.active_state_mut().on_focus_lost();
652
653 // Clear hover state
654 self.mouse_state.lsp_hover_state = None;
655 self.mouse_state.lsp_hover_request_sent = false;
656 self.hover.clear_pending();
657
658 // Clear hover symbol highlight if present. Inlined from
659 // `Event::RemoveOverlay` handling (state.rs) so we don't have to
660 // reach back through `Editor::apply_event_to_active_buffer`.
661 if let Some(handle) = self.hover.take_symbol_overlay() {
662 let state = self.active_state_mut();
663 state
664 .overlays
665 .remove_by_handle(&handle, &mut state.marker_list);
666 }
667 self.hover.set_symbol_range(None);
668
669 // Any focus change (buffer switch, file explorer, menus, …) ends the
670 // goto-line preview flow. Drop the snapshot so a later Esc cannot
671 // rubber-band the cursor over state the user has moved past.
672 self.goto_line_preview = None;
673 }
674}