Skip to main content

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}