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}