fresh/app/popup_dialogs.rs
1//! Popup-dialog orchestrators on `Editor`.
2//!
3//! These build and show various popups as buffer-level events:
4//! warnings popup, LSP status popup (with refresh hook), file-message
5//! popup, and a small text-properties query helper. The biggest of
6//! these — build_and_show_lsp_status_popup — is ~315 lines of popup
7//! construction that has nothing to do with buffer management proper;
8//! it just needed access to the buffer to dispatch the ShowPopup event.
9
10use rust_i18n::t;
11
12use crate::app::warning_domains::WarningDomain;
13
14use super::Editor;
15
16impl Editor {
17 /// Show warnings by opening the warning log file directly
18 ///
19 /// If there are no warnings, shows a brief status message.
20 /// Otherwise, opens the warning log file for the user to view.
21 pub fn show_warnings_popup(&mut self) {
22 if !self.warning_domains.has_any_warnings() {
23 self.status_message = Some(t!("warnings.none").to_string());
24 return;
25 }
26
27 // Open the warning log file directly
28 self.open_warning_log();
29 }
30
31 /// Show LSP status popup with details about servers active for the current buffer.
32 /// Lists each server with its status and provides actions: restart, stop, view log.
33 pub fn show_lsp_status_popup(&mut self) {
34 // Toggle behavior: if the LSP popup is already showing, close it
35 // instead of rebuilding and re-showing it. This lets clicking the
36 // status-bar LSP indicator a second time dismiss the popup, matching
37 // the common affordance for status-bar menus.
38 if self.pending_lsp_status_popup.is_some() {
39 self.hide_popup();
40 self.pending_lsp_status_popup = None;
41 return;
42 }
43
44 let has_error = self.warning_domains.lsp.level() == crate::app::WarningLevel::Error;
45 let language = self
46 .buffers
47 .get(&self.active_buffer())
48 .map(|s| s.language.clone())
49 .unwrap_or_else(|| "unknown".to_string());
50
51 // Compute the set of configured servers whose binaries are not
52 // resolvable — plugins and the popup itself both need this to
53 // decide between "offer to start" and "offer install help".
54 let missing_servers: Vec<String> = self
55 .config
56 .lsp
57 .get(&language)
58 .map(|cfg| {
59 cfg.as_slice()
60 .iter()
61 .filter(|c| c.enabled && !c.command.is_empty())
62 .filter(|c| !crate::services::lsp::command_exists(&c.command))
63 .map(|c| c.command.clone())
64 .collect()
65 })
66 .unwrap_or_default();
67 let user_dismissed = self.is_lsp_language_user_dismissed(&language);
68
69 // Fire the LspStatusClicked hook for plugins
70 self.plugin_manager.run_hook(
71 "lsp_status_clicked",
72 crate::services::plugins::hooks::HookArgs::LspStatusClicked {
73 language: language.clone(),
74 has_error,
75 missing_servers,
76 user_dismissed,
77 },
78 );
79
80 self.build_and_show_lsp_status_popup(&language);
81 }
82
83 /// Rebuild the LSP-status popup in place if it's currently open.
84 ///
85 /// Used when an async event (progress update, server state change) might
86 /// change the popup's contents — notably while rust-analyzer is indexing
87 /// and emits `$/progress` every few hundred ms. Without this, the popup
88 /// would freeze on the snapshot taken at open time while the status-bar
89 /// spinner keeps moving, making them look disconnected.
90 pub fn refresh_lsp_status_popup_if_open(&mut self) {
91 if self.pending_lsp_status_popup.is_none() {
92 return;
93 }
94 let language = self
95 .buffers
96 .get(&self.active_buffer())
97 .map(|s| s.language.clone())
98 .unwrap_or_else(|| "unknown".to_string());
99 // Replace contents: hide then rebuild. hide_popup() clears
100 // pending_lsp_status_popup via handle_popup_cancel pathways, but
101 // here we're calling it directly without routing through a cancel,
102 // so stash and restore the marker so the rebuild sees "already
103 // open" and doesn't fall through the toggle branch.
104 let was_pending = self.pending_lsp_status_popup.take();
105 self.hide_popup();
106 drop(was_pending);
107 self.build_and_show_lsp_status_popup(&language);
108 }
109
110 fn build_and_show_lsp_status_popup(&mut self, language: &str) {
111 use crate::services::async_bridge::LspServerStatus;
112
113 // Build a unified list of all configured servers for this language,
114 // merged with their runtime status (if running).
115 let running_statuses: std::collections::HashMap<String, LspServerStatus> = self
116 .lsp_server_statuses
117 .iter()
118 .filter(|((lang, _), _)| lang == language)
119 .map(|((_, name), status)| (name.clone(), *status))
120 .collect();
121
122 let configured_servers: Vec<String> = self
123 .config
124 .lsp
125 .get(language)
126 .map(|cfg| {
127 cfg.as_slice()
128 .iter()
129 .filter(|c| !c.command.is_empty())
130 .map(|c| c.display_name())
131 .collect()
132 })
133 .unwrap_or_default();
134
135 // Per-server binary availability map (display_name → bool).
136 // `command_exists` is cached, so repeated popup opens or a
137 // refresh-while-open are cheap. We look up by display name
138 // because `all_servers` below is built from display names;
139 // LspServerConfig::display_name() falls back to the command
140 // basename when no explicit `name` is set.
141 let missing_by_server: std::collections::HashMap<String, bool> = self
142 .config
143 .lsp
144 .get(language)
145 .map(|cfg| {
146 cfg.as_slice()
147 .iter()
148 .filter(|c| !c.command.is_empty())
149 .map(|c| {
150 (
151 c.display_name(),
152 !crate::services::lsp::command_exists(&c.command),
153 )
154 })
155 .collect()
156 })
157 .unwrap_or_default();
158 let user_dismissed = self.is_lsp_language_user_dismissed(language);
159
160 if configured_servers.is_empty() && running_statuses.is_empty() {
161 self.status_message = Some(t!("lsp.no_server_active").to_string());
162 return;
163 }
164
165 // Merge: start with configured servers, then add any running servers
166 // not in the config (shouldn't happen, but be safe).
167 let mut all_servers: Vec<String> = configured_servers;
168 for name in running_statuses.keys() {
169 if !all_servers.contains(name) {
170 all_servers.push(name.clone());
171 }
172 }
173 all_servers.sort();
174
175 // Build the popup's items as view-level `PopupListItem`s directly.
176 // We bypass the `PopupListItemData` event type here because we need
177 // the `disabled` field (for "View Log" when no log exists), which
178 // is a view-only concern and plumbing it through the event boundary
179 // would require touching ~40 existing literals across the test
180 // suite.
181 let mut items: Vec<crate::view::popup::PopupListItem> = Vec::new();
182 let mut action_keys: Vec<(String, String)> = Vec::new();
183
184 /// Truncate `s` to at most `max_cells` display cells, appending an
185 /// ellipsis if truncation happened (the ellipsis is included in the
186 /// budget, so the result is ≤ `max_cells` wide regardless of input).
187 fn truncate(s: &str, max_cells: usize) -> String {
188 use unicode_width::UnicodeWidthChar;
189 let w = unicode_width::UnicodeWidthStr::width(s);
190 if w <= max_cells {
191 return s.to_string();
192 }
193 let budget = max_cells.saturating_sub(1);
194 let mut used = 0;
195 let mut out = String::new();
196 for ch in s.chars() {
197 let cw = ch.width().unwrap_or(0);
198 if used + cw > budget {
199 break;
200 }
201 used += cw;
202 out.push(ch);
203 }
204 out.push('…');
205 out
206 }
207 const PROGRESS_FIELD_MAX: usize = 14;
208 const POPUP_WIDTH_MAX: u16 = 50;
209
210 for name in &all_servers {
211 let status = running_statuses.get(name).copied();
212 let is_active = status
213 .map(|s| !matches!(s, LspServerStatus::Shutdown))
214 .unwrap_or(false);
215 // A server is "missing" only when it's NOT currently running
216 // (an absolute-path binary could have been removed mid-session,
217 // but the live server is still talking to us).
218 let binary_missing =
219 !is_active && missing_by_server.get(name).copied().unwrap_or(false);
220
221 // Header: server name + status (data = None → not clickable,
222 // not underlined). Swap the "not running" label for a more
223 // actionable "binary not found" when we can see up-front that
224 // a start attempt would fail — this is the user-visible half
225 // of the pre-click probe.
226 let (icon, label) = match status {
227 Some(LspServerStatus::Running) => ("●", "ready"),
228 Some(LspServerStatus::Error) => ("✗", "error"),
229 Some(LspServerStatus::Starting) => ("◌", "starting"),
230 Some(LspServerStatus::Initializing) => ("◌", "initializing"),
231 Some(LspServerStatus::Shutdown) | None => {
232 if binary_missing {
233 ("○", "binary not in PATH")
234 } else {
235 ("○", "not running")
236 }
237 }
238 };
239 items.push(crate::view::popup::PopupListItem::new(format!(
240 "{} {} ({})",
241 icon, name, label
242 )));
243
244 // Progress row immediately UNDER the server's name row, if
245 // there's an active `$/progress` notification for this
246 // language. Indented to match the action rows below, and the
247 // title + message fields are individually truncated so a
248 // runaway progress path can't stretch the popup. The popup
249 // width is pinned in advance (see below) so the row's content
250 // changing never reshapes the popup.
251 if let Some(info) = self
252 .lsp_progress
253 .values()
254 .find(|info| info.language == language)
255 {
256 let mut line = format!(" ⏳ {}", truncate(&info.title, PROGRESS_FIELD_MAX));
257 if let Some(ref msg) = info.message {
258 line.push_str(&format!(" · {}", truncate(msg, PROGRESS_FIELD_MAX)));
259 }
260 if let Some(pct) = info.percentage {
261 line.push_str(&format!(" ({}%)", pct));
262 }
263 items.push(crate::view::popup::PopupListItem::new(line));
264 }
265
266 if is_active {
267 // Restart
268 let restart_key = format!("restart:{}/{}", language, name);
269 items.push(
270 crate::view::popup::PopupListItem::new(format!(" Restart {}", name))
271 .with_data(restart_key.clone()),
272 );
273 action_keys.push((restart_key, format!("Restart {}", name)));
274
275 // Stop
276 let stop_key = format!("stop:{}/{}", language, name);
277 items.push(
278 crate::view::popup::PopupListItem::new(format!(" Stop {}", name))
279 .with_data(stop_key.clone()),
280 );
281 action_keys.push((stop_key, format!("Stop {}", name)));
282 } else if binary_missing {
283 // Show a disabled advisory row instead of an actionable
284 // "Start" — clicking Start here would spawn, fail, and
285 // noise up the status area. The per-language
286 // Install/Dismiss actions are added once at the end of
287 // the popup, below.
288 items.push(
289 crate::view::popup::PopupListItem::new(format!(
290 " Install {} to enable",
291 name
292 ))
293 .disabled(),
294 );
295 } else {
296 // Start
297 let start_key = format!("start:{}", language);
298 if !action_keys.iter().any(|(k, _)| k == &start_key) {
299 items.push(
300 crate::view::popup::PopupListItem::new(format!(" Start {}", name))
301 .with_data(start_key.clone()),
302 );
303 action_keys.push((start_key, format!("Start {}", name)));
304 }
305 }
306 }
307
308 // Dismiss / Enable row — shown whenever the language has at
309 // least one configured server. Gives the user a surface to
310 // mute the pill (dim style) and, later, to restore it. We
311 // reuse `all_servers.is_empty()` as the "nothing here" signal
312 // since languages with zero configured-or-running servers
313 // already bailed out above.
314 if user_dismissed {
315 let enable_key = format!("enable:{}", language);
316 items.push(
317 crate::view::popup::PopupListItem::new(format!(
318 " Enable LSP pill for {}",
319 language
320 ))
321 .with_data(enable_key.clone()),
322 );
323 action_keys.push((enable_key, format!("Enable LSP for {}", language)));
324 } else {
325 let dismiss_key = format!("dismiss:{}", language);
326 items.push(
327 crate::view::popup::PopupListItem::new(format!(
328 " Disable LSP pill for {}",
329 language
330 ))
331 .with_data(dismiss_key.clone()),
332 );
333 action_keys.push((dismiss_key, format!("Disable LSP for {}", language)));
334 }
335
336 // View log action (always, at the end) — grayed out and
337 // non-actionable when no log file exists yet for this language
338 // (e.g. the server was never started, or has been rotated away).
339 let log_path = crate::services::log_dirs::lsp_log_path(language);
340 let log_exists = log_path.exists();
341 let log_key = format!("log:{}", language);
342 let mut log_item = crate::view::popup::PopupListItem::new(" View Log".to_string());
343 if log_exists {
344 log_item = log_item.with_data(log_key.clone());
345 action_keys.push((log_key, "View Log".to_string()));
346 } else {
347 log_item = log_item.disabled();
348 }
349 items.push(log_item);
350
351 // Store action keys for handling confirmation
352 self.pending_lsp_status_popup = Some(action_keys);
353
354 // Pin the popup width up-front, using the *worst-case* widths for
355 // any row that varies at runtime (the progress line). This keeps
356 // the popup from jittering when progress messages come and go or
357 // change length — the whole point of the spinner + live-refresh
358 // pair is that the UI should look stable while the LSP churns.
359 //
360 // worst-case progress line =
361 // " ⏳ " (4-space indent + ⏳ (2 cells) + space = 7 cells)
362 // + PROGRESS_FIELD_MAX (title)
363 // + " · " (3 cells)
364 // + PROGRESS_FIELD_MAX (message)
365 // + " (100%)" (7 cells)
366 // = 7 + 14 + 3 + 14 + 7 = 45 cells
367 const PROGRESS_LINE_MAX: usize = 7 + PROGRESS_FIELD_MAX + 3 + PROGRESS_FIELD_MAX + 7;
368 let max_static_item_width = items
369 .iter()
370 .map(|i| unicode_width::UnicodeWidthStr::width(i.text.as_str()))
371 .max()
372 .unwrap_or(20);
373 let popup_width =
374 (max_static_item_width.max(PROGRESS_LINE_MAX) as u16 + 4).clamp(30, POPUP_WIDTH_MAX);
375
376 // Pre-select the first actionable item (skip header items with no
377 // data and disabled items like a non-existent View Log).
378 let first_actionable = items
379 .iter()
380 .position(|i| i.data.is_some() && !i.disabled)
381 .unwrap_or(0);
382
383 // Left-align the popup's column with the LSP indicator on the
384 // status bar, if we know where it was drawn in the last frame.
385 // Falls back to the previous BottomRight anchor when the LSP
386 // segment isn't visible (e.g. first render).
387 let position = self
388 .cached_layout
389 .status_bar_lsp_area
390 .map(
391 |(_, col_start, _)| crate::view::popup::PopupPosition::AboveStatusBarAt {
392 x: col_start,
393 },
394 )
395 .unwrap_or(crate::view::popup::PopupPosition::BottomRight);
396
397 use crate::view::popup::{Popup, PopupContent, PopupKind};
398 use ratatui::style::Style;
399
400 let popup = Popup {
401 kind: PopupKind::List,
402 title: Some(format!("LSP Servers ({})", language)),
403 description: None,
404 transient: false,
405 content: PopupContent::List {
406 items,
407 selected: first_actionable,
408 },
409 position,
410 width: popup_width,
411 max_height: 15,
412 bordered: true,
413 border_style: Style::default().fg(self.theme.popup_border_fg),
414 background_style: Style::default().bg(self.theme.popup_bg),
415 scroll_offset: 0,
416 text_selection: None,
417 accept_key_hint: None,
418 };
419
420 let buffer_id = self.active_buffer();
421 if let Some(state) = self.buffers.get_mut(&buffer_id) {
422 state.popups.show(popup);
423 }
424 }
425
426 /// Show a transient hover popup with the given message text, positioned below the cursor.
427 /// Used for file-open messages (e.g. `file.txt:10@"Look at this"`).
428 pub fn show_file_message_popup(&mut self, message: &str) {
429 use crate::view::popup::{Popup, PopupPosition};
430 use ratatui::style::Style;
431
432 // Build markdown: message text + blank line + italic hint
433 let md = format!("{}\n\n*esc to dismiss*", message);
434 // Size popup width to content: longest line + border padding, clamped to reasonable bounds
435 let content_width = message.lines().map(|l| l.len()).max().unwrap_or(0) as u16;
436 let hint_width = 16u16; // "*esc to dismiss*"
437 let popup_width = (content_width.max(hint_width) + 4).clamp(20, 60);
438
439 let mut popup = Popup::markdown(&md, &self.theme, Some(&self.grammar_registry));
440 popup.transient = false;
441 popup.position = PopupPosition::BelowCursor;
442 popup.width = popup_width;
443 popup.max_height = 15;
444 popup.border_style = Style::default().fg(self.theme.popup_border_fg);
445 popup.background_style = Style::default().bg(self.theme.popup_bg);
446
447 let buffer_id = self.active_buffer();
448 if let Some(state) = self.buffers.get_mut(&buffer_id) {
449 state.popups.show(popup);
450 }
451 }
452
453 /// Get text properties at the cursor position in the active buffer
454 pub fn get_text_properties_at_cursor(
455 &self,
456 ) -> Option<Vec<&crate::primitives::text_property::TextProperty>> {
457 let state = self.buffers.get(&self.active_buffer())?;
458 let cursor_pos = self.active_cursors().primary().position;
459 Some(state.text_properties.get_at(cursor_pos))
460 }
461}