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}