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
16/// True when `popup` is the LSP status popup (as built by
17/// `build_and_show_lsp_status_popup`). Used by the auto-prompt
18/// drain to find and clean up orphan prompts on non-active
19/// buffers without affecting unrelated popups (completion, hover,
20/// etc.) that might be on top.
21fn is_lsp_status_popup(popup: &crate::view::popup::Popup) -> bool {
22    matches!(popup.resolver, crate::view::popup::PopupResolver::LspStatus)
23}
24
25impl Editor {
26    /// Show warnings by opening the warning log file directly
27    ///
28    /// If there are no warnings, shows a brief status message.
29    /// Otherwise, opens the warning log file for the user to view.
30    pub fn show_warnings_popup(&mut self) {
31        if !self.warning_domains.has_any_warnings() {
32            self.status_message = Some(t!("warnings.none").to_string());
33            return;
34        }
35
36        // Open the warning log file directly
37        self.open_warning_log();
38    }
39
40    /// Show LSP status popup with details about servers active for the current buffer.
41    /// Lists each server with its status and provides actions: restart, stop, view log.
42    ///
43    /// User-initiated (status-bar click, `lsp_status` action). The popup
44    /// grabs focus on show because the user explicitly asked for it,
45    /// matching the historical click-to-pick-action affordance.
46    pub fn show_lsp_status_popup(&mut self) {
47        // Toggle behavior: if the LSP popup is already showing, close it
48        // instead of rebuilding and re-showing it.  This lets clicking the
49        // status-bar LSP indicator a second time dismiss the popup, matching
50        // the common affordance for status-bar menus.
51        if self
52            .active_state()
53            .popups
54            .top()
55            .is_some_and(is_lsp_status_popup)
56        {
57            self.hide_popup();
58            return;
59        }
60
61        let has_error = self.warning_domains.lsp.level() == crate::app::WarningLevel::Error;
62        let language = self
63            .buffers
64            .get(&self.active_buffer())
65            .map(|s| s.language.clone())
66            .unwrap_or_else(|| "unknown".to_string());
67
68        // Compute the set of configured servers whose binaries are not
69        // resolvable — plugins and the popup itself both need this to
70        // decide between "offer to start" and "offer install help".
71        // Probe missing binaries through the active authority. When the
72        // LspManager isn't wired (tests or very early boot), fall
73        // back to the synchronous host-side `which` probe — same path
74        // `command_exists_via_authority` would take after the
75        // long-running spawner bootstrap completes.
76        let missing_servers: Vec<String> = self
77            .config
78            .lsp
79            .get(&language)
80            .map(|cfg| {
81                cfg.as_slice()
82                    .iter()
83                    .filter(|c| c.enabled && !c.command.is_empty())
84                    .filter(|c| match self.lsp.as_ref() {
85                        Some(mgr) => !mgr.command_exists_via_authority(&c.command),
86                        None => !crate::services::lsp::command_exists(&c.command),
87                    })
88                    .map(|c| c.command.clone())
89                    .collect()
90            })
91            .unwrap_or_default();
92        let user_dismissed = self.is_lsp_language_user_dismissed(&language);
93
94        // Fire the LspStatusClicked hook for plugins
95        self.plugin_manager.run_hook(
96            "lsp_status_clicked",
97            crate::services::plugins::hooks::HookArgs::LspStatusClicked {
98                language: language.clone(),
99                has_error,
100                missing_servers,
101                user_dismissed,
102            },
103        );
104
105        self.build_and_show_lsp_status_popup(&language, true);
106    }
107
108    /// If an auto-start prompt is queued for the active buffer's
109    /// language, show it now. Called from the render path so the
110    /// popup always attaches to the buffer the user is actually
111    /// looking at — even across a session restore that opens
112    /// several files of the same language in succession and flips
113    /// the active buffer mid-sequence.
114    ///
115    /// The language stays in `pending_auto_start_prompts` after a
116    /// draw: if focus moves to a different buffer of the same
117    /// language before the user has dismissed the popup, we'll
118    /// pop the orphan from the old buffer's stack and re-attach
119    /// the popup to the new active buffer on the next render. The
120    /// user-side lifecycle — pick an action, or press Esc — is
121    /// what flips the language into
122    /// `auto_start_prompted_languages` and ends the prompt cycle
123    /// (see `handle_lsp_status_action` and `handle_popup_cancel`).
124    ///
125    /// Short-circuits aggressively so the steady-state cost on
126    /// every render is a single `HashSet::is_empty` check.
127    pub(crate) fn drain_pending_lsp_prompt_for_active_buffer(&mut self) {
128        if self.pending_auto_start_prompts.is_empty() {
129            return;
130        }
131        let active = self.active_buffer();
132        let Some(language) = self.buffers.get(&active).map(|s| s.language.clone()) else {
133            return;
134        };
135        if !self.pending_auto_start_prompts.contains(&language) {
136            return;
137        }
138        // Recheck the dismissal guard at show time — it may have
139        // flipped between file-open and render.
140        if self.is_lsp_language_user_dismissed(&language) {
141            self.pending_auto_start_prompts.remove(&language);
142            return;
143        }
144        // If the active buffer's top popup is already the LSP
145        // status popup, nothing to do — the previous render landed
146        // it here and the user is presumably looking at it.
147        if self
148            .buffers
149            .get(&active)
150            .and_then(|s| s.popups.top())
151            .is_some_and(is_lsp_status_popup)
152        {
153            return;
154        }
155        // If some OTHER popup owns the active buffer's top (e.g.
156        // a completion popup), don't stomp on it — wait for a
157        // future frame when the user has dismissed it.
158        if self
159            .buffers
160            .get(&active)
161            .and_then(|s| s.popups.top())
162            .is_some()
163        {
164            return;
165        }
166
167        // Pop any orphan LSP status popup off *other* buffers so the
168        // user doesn't find a stale prompt when they later visit those
169        // tabs. Each buffer's popup carries its own `LspStatus`
170        // resolver, so we can identify orphan LSP status popups by
171        // resolver value without a side-channel marker.
172        let active_id = active;
173        for (id, state) in self.buffers.iter_mut() {
174            if *id == active_id {
175                continue;
176            }
177            if state.popups.top().is_some_and(is_lsp_status_popup) {
178                state.popups.hide();
179            }
180        }
181
182        // Auto-prompt on file open: build the popup *unfocused* so it
183        // doesn't silently swallow the user's next keystroke. The user
184        // can grab the keyboard with `popup_focus` (default Alt+T) or
185        // dismiss with Esc. We bypass `show_lsp_status_popup` (which
186        // toggles + grabs focus) and go straight to the builder; the
187        // visibility precondition is already satisfied by the active-
188        // buffer popup-stack check above.
189        let language = self
190            .buffers
191            .get(&self.active_buffer())
192            .map(|s| s.language.clone());
193        if let Some(language) = language {
194            self.build_and_show_lsp_status_popup(&language, false);
195        }
196    }
197
198    /// Rebuild the LSP-status popup in place if it's currently open.
199    ///
200    /// Used when an async event (progress update, server state change) might
201    /// change the popup's contents — notably while rust-analyzer is indexing
202    /// and emits `$/progress` every few hundred ms.  Without this, the popup
203    /// would freeze on the snapshot taken at open time while the status-bar
204    /// spinner keeps moving, making them look disconnected.
205    pub fn refresh_lsp_status_popup_if_open(&mut self) {
206        // Only rebuild if the active buffer's top popup IS an LSP
207        // status popup — otherwise we'd spuriously build one on top of
208        // unrelated state.
209        if !self
210            .active_state()
211            .popups
212            .top()
213            .is_some_and(is_lsp_status_popup)
214        {
215            return;
216        }
217        let language = self
218            .buffers
219            .get(&self.active_buffer())
220            .map(|s| s.language.clone())
221            .unwrap_or_else(|| "unknown".to_string());
222        // Replace contents: hide then rebuild. Refresh is triggered by
223        // async progress updates while the popup is already on screen,
224        // so we keep its existing focused state — flipping it back to
225        // unfocused on every progress tick would yank focus away from
226        // a user mid-interaction.
227        let was_focused = self
228            .active_state()
229            .popups
230            .top()
231            .map(|p| p.focused)
232            .unwrap_or(true);
233        self.hide_popup();
234        self.build_and_show_lsp_status_popup(&language, was_focused);
235    }
236
237    fn build_and_show_lsp_status_popup(&mut self, language: &str, focused: bool) {
238        use crate::services::async_bridge::LspServerStatus;
239
240        // Build a unified list of all configured servers for this language,
241        // merged with their runtime status (if running).
242        let running_statuses: std::collections::HashMap<String, LspServerStatus> = self
243            .lsp_server_statuses
244            .iter()
245            .filter(|((lang, _), _)| lang == language)
246            .map(|((_, name), status)| (name.clone(), *status))
247            .collect();
248
249        let configured_servers: Vec<String> = self
250            .config
251            .lsp
252            .get(language)
253            .map(|cfg| {
254                cfg.as_slice()
255                    .iter()
256                    .filter(|c| !c.command.is_empty())
257                    .map(|c| c.display_name())
258                    .collect()
259            })
260            .unwrap_or_default();
261
262        // Per-server binary availability map (display_name → bool).
263        // `command_exists` is cached, so repeated popup opens or a
264        // refresh-while-open are cheap.  We look up by display name
265        // because `all_servers` below is built from display names;
266        // LspServerConfig::display_name() falls back to the command
267        // basename when no explicit `name` is set.
268        let missing_by_server: std::collections::HashMap<String, bool> = self
269            .config
270            .lsp
271            .get(language)
272            .map(|cfg| {
273                cfg.as_slice()
274                    .iter()
275                    .filter(|c| !c.command.is_empty())
276                    .map(|c| {
277                        let missing = match self.lsp.as_ref() {
278                            Some(mgr) => !mgr.command_exists_via_authority(&c.command),
279                            None => !crate::services::lsp::command_exists(&c.command),
280                        };
281                        (c.display_name(), missing)
282                    })
283                    .collect()
284            })
285            .unwrap_or_default();
286        // Per-server auto_start flag map (display_name → auto_start).
287        // Used to decide whether to offer an "Enable auto-start for X"
288        // row alongside the "Start X" action — relevant only when the
289        // server is enabled but dormant and the user hasn't opted into
290        // auto-start yet.
291        let auto_start_by_server: std::collections::HashMap<String, bool> = self
292            .config
293            .lsp
294            .get(language)
295            .map(|cfg| {
296                cfg.as_slice()
297                    .iter()
298                    .filter(|c| !c.command.is_empty())
299                    .map(|c| (c.display_name(), c.auto_start))
300                    .collect()
301            })
302            .unwrap_or_default();
303        let user_dismissed = self.is_lsp_language_user_dismissed(language);
304
305        if configured_servers.is_empty() && running_statuses.is_empty() {
306            self.status_message = Some(t!("lsp.no_server_active").to_string());
307            return;
308        }
309
310        // Merge: start with configured servers, then add any running servers
311        // not in the config (shouldn't happen, but be safe).
312        let mut all_servers: Vec<String> = configured_servers;
313        for name in running_statuses.keys() {
314            if !all_servers.contains(name) {
315                all_servers.push(name.clone());
316            }
317        }
318        all_servers.sort();
319
320        // Build the popup's items as view-level `PopupListItem`s directly.
321        // We bypass the `PopupListItemData` event type here because we need
322        // the `disabled` field (for "View Log" when no log exists), which
323        // is a view-only concern and plumbing it through the event boundary
324        // would require touching ~40 existing literals across the test
325        // suite.
326        let mut items: Vec<crate::view::popup::PopupListItem> = Vec::new();
327        let mut action_keys: Vec<(String, String)> = Vec::new();
328
329        /// Truncate `s` to at most `max_cells` display cells, appending an
330        /// ellipsis if truncation happened (the ellipsis is included in the
331        /// budget, so the result is ≤ `max_cells` wide regardless of input).
332        fn truncate(s: &str, max_cells: usize) -> String {
333            use unicode_width::UnicodeWidthChar;
334            let w = unicode_width::UnicodeWidthStr::width(s);
335            if w <= max_cells {
336                return s.to_string();
337            }
338            let budget = max_cells.saturating_sub(1);
339            let mut used = 0;
340            let mut out = String::new();
341            for ch in s.chars() {
342                let cw = ch.width().unwrap_or(0);
343                if used + cw > budget {
344                    break;
345                }
346                used += cw;
347                out.push(ch);
348            }
349            out.push('…');
350            out
351        }
352        const PROGRESS_FIELD_MAX: usize = 14;
353        const POPUP_WIDTH_MAX: u16 = 50;
354
355        for name in &all_servers {
356            let status = running_statuses.get(name).copied();
357            let is_active = status
358                .map(|s| !matches!(s, LspServerStatus::Shutdown))
359                .unwrap_or(false);
360            // A server is "missing" only when it's NOT currently running
361            // (an absolute-path binary could have been removed mid-session,
362            // but the live server is still talking to us).
363            let binary_missing =
364                !is_active && missing_by_server.get(name).copied().unwrap_or(false);
365
366            // Header: server name + status (data = None → not clickable,
367            // not underlined).  Swap the "not running" label for a more
368            // actionable "binary not found" when we can see up-front that
369            // a start attempt would fail — this is the user-visible half
370            // of the pre-click probe. The `binary_missing` signal comes
371            // from the authority-routed `command_exists` (L-3c), so the
372            // "not installed" copy says where it actually isn't: in the
373            // container for container authorities, on the host
374            // otherwise.
375            let authority_is_container = self.authority().display_label.starts_with("Container:");
376            let missing_label = if authority_is_container {
377                "not installed in container"
378            } else {
379                "binary not in PATH"
380            };
381            let (icon, label) = match status {
382                Some(LspServerStatus::Running) => ("●", "ready"),
383                Some(LspServerStatus::Error) => ("✗", "error"),
384                Some(LspServerStatus::Starting) => ("◌", "starting"),
385                Some(LspServerStatus::Initializing) => ("◌", "initializing"),
386                Some(LspServerStatus::Shutdown) | None => {
387                    if binary_missing {
388                        ("○", missing_label)
389                    } else {
390                        ("○", "not running")
391                    }
392                }
393            };
394            items.push(crate::view::popup::PopupListItem::new(format!(
395                "{} {} ({})",
396                icon, name, label
397            )));
398
399            // Progress row immediately UNDER the server's name row, if
400            // there's an active `$/progress` notification for this
401            // language.  Indented to match the action rows below, and the
402            // title + message fields are individually truncated so a
403            // runaway progress path can't stretch the popup.  The popup
404            // width is pinned in advance (see below) so the row's content
405            // changing never reshapes the popup.
406            if let Some(info) = self
407                .lsp_progress
408                .values()
409                .find(|info| info.language == language)
410            {
411                let mut line = format!("    ⏳ {}", truncate(&info.title, PROGRESS_FIELD_MAX));
412                if let Some(ref msg) = info.message {
413                    line.push_str(&format!(" · {}", truncate(msg, PROGRESS_FIELD_MAX)));
414                }
415                if let Some(pct) = info.percentage {
416                    line.push_str(&format!(" ({}%)", pct));
417                }
418                items.push(crate::view::popup::PopupListItem::new(line));
419            }
420
421            if is_active {
422                // Restart
423                let restart_key = format!("restart:{}/{}", language, name);
424                items.push(
425                    crate::view::popup::PopupListItem::new(format!("    Restart {}", name))
426                        .with_data(restart_key.clone()),
427                );
428                action_keys.push((restart_key, format!("Restart {}", name)));
429
430                // Stop
431                let stop_key = format!("stop:{}/{}", language, name);
432                items.push(
433                    crate::view::popup::PopupListItem::new(format!("    Stop {}", name))
434                        .with_data(stop_key.clone()),
435                );
436                action_keys.push((stop_key, format!("Stop {}", name)));
437            } else if binary_missing {
438                // Show a disabled advisory row instead of an actionable
439                // "Start" — clicking Start here would spawn, fail, and
440                // noise up the status area. Copy shifts with the
441                // authority so the user is pointed at the right
442                // install surface: `devcontainer.json`'s
443                // `postCreateCommand` for containers, the host's
444                // package manager otherwise.
445                let advisory = if authority_is_container {
446                    format!("    Install {name} in container (postCreateCommand)")
447                } else {
448                    format!("    Install {name} to enable")
449                };
450                items.push(crate::view::popup::PopupListItem::new(advisory).disabled());
451            } else {
452                // Two sibling rows for a dormant server, in the
453                // order the user most likely wants:
454                //
455                //   "Start <name> (always)" — persist auto_start=true
456                //                              AND start the server now.
457                //                              Listed first because
458                //                              persistent-start is the
459                //                              common case, so pre-
460                //                              selecting it lets the
461                //                              user press Enter and
462                //                              move on.
463                //   "Start <name> once"     — start for this session,
464                //                              config stays auto_start=false.
465                //
466                // The "once" suffix is only needed (vs. just "Start")
467                // when the "(always)" sibling is also present — i.e.
468                // when auto_start is currently false. Otherwise there
469                // is nothing to disambiguate it from.
470                let is_manual = !auto_start_by_server.get(name).copied().unwrap_or(true);
471
472                // "(always)" row — first, so it's the default.
473                if is_manual {
474                    let autostart_key = format!("autostart:{}/{}", language, name);
475                    items.push(
476                        crate::view::popup::PopupListItem::new(format!(
477                            "    Start {} (always)",
478                            name
479                        ))
480                        .with_data(autostart_key.clone()),
481                    );
482                    action_keys.push((autostart_key, format!("Start {} (always)", name)));
483                }
484
485                // "once" / plain Start row.
486                let start_label = if is_manual {
487                    format!("    Start {} once", name)
488                } else {
489                    format!("    Start {}", name)
490                };
491                let start_action_label = if is_manual {
492                    format!("Start {} once", name)
493                } else {
494                    format!("Start {}", name)
495                };
496                let start_key = format!("start:{}", language);
497                if !action_keys.iter().any(|(k, _)| k == &start_key) {
498                    items.push(
499                        crate::view::popup::PopupListItem::new(start_label)
500                            .with_data(start_key.clone()),
501                    );
502                    action_keys.push((start_key, start_action_label));
503                }
504            }
505        }
506
507        // Disable / Enable row — shown whenever the language has at
508        // least one configured server. The label flips on either the
509        // session-level dismiss flag OR the persisted `enabled = false`
510        // half: both mean "the language is currently muted from the
511        // user's POV", and showing "Disable" while the config already
512        // has every server disabled would leave the user with no
513        // surface to undo it. Picking the row writes through to the
514        // matching half of the state in `handle_lsp_status_action`
515        // (`dismiss:` flips both, `enable:` flips both) so the two
516        // signals stay in sync after every round-trip.
517        let any_enabled = self
518            .config
519            .lsp
520            .get(language)
521            .is_some_and(|cfg| cfg.as_slice().iter().any(|c| c.enabled));
522        let muted = user_dismissed || !any_enabled;
523        if muted {
524            let enable_key = format!("enable:{}", language);
525            items.push(
526                crate::view::popup::PopupListItem::new(format!("    Enable LSP for {}", language))
527                    .with_data(enable_key.clone()),
528            );
529            action_keys.push((enable_key, format!("Enable LSP for {}", language)));
530        } else {
531            let dismiss_key = format!("dismiss:{}", language);
532            items.push(
533                crate::view::popup::PopupListItem::new(format!("    Disable LSP for {}", language))
534                    .with_data(dismiss_key.clone()),
535            );
536            action_keys.push((dismiss_key, format!("Disable LSP for {}", language)));
537        }
538
539        // View log action — grayed out and non-actionable when no
540        // log file exists yet for this language (e.g. the server was
541        // never started, or has been rotated away).
542        let log_path = crate::services::log_dirs::lsp_log_path(language);
543        let log_exists = log_path.exists();
544        let log_key = format!("log:{}", language);
545        let mut log_item = crate::view::popup::PopupListItem::new("    View Log".to_string());
546        if log_exists {
547            log_item = log_item.with_data(log_key.clone());
548            action_keys.push((log_key, "View Log".to_string()));
549        } else {
550            log_item = log_item.disabled();
551        }
552        items.push(log_item);
553
554        // Trailing Dismiss row — gives users an on-screen way out of
555        // the popup without having to know that Esc works. The key
556        // label is looked up from the keybinding resolver so a
557        // rebound PopupCancel stays visible in the row label
558        // ("Dismiss (Q)", etc.). Falls back to "Esc" as the usual
559        // default if the resolver has no binding at all (unusual,
560        // but we don't want an empty parenthetical).
561        let cancel_binding = self
562            .keybindings
563            .read()
564            .ok()
565            .and_then(|kb| {
566                kb.get_keybinding_for_action(
567                    &crate::input::keybindings::Action::PopupCancel,
568                    crate::input::keybindings::KeyContext::Popup,
569                )
570            })
571            .unwrap_or_else(|| "Esc".to_string());
572        let cancel_key = "cancel_popup".to_string();
573        items.push(
574            crate::view::popup::PopupListItem::new(format!("    Dismiss ({})", cancel_binding))
575                .with_data(cancel_key.clone()),
576        );
577        action_keys.push((cancel_key, format!("Dismiss ({})", cancel_binding)));
578        // `action_keys` is no longer kept on the editor — each list
579        // item already carries its action key in its `data` field, and
580        // the `LspStatus` resolver on the popup tells confirm how to
581        // interpret that data. The local binding is retained only to
582        // keep the existing construction logic unchanged; it falls out
583        // of scope with the rest.
584        let _ = action_keys;
585
586        // Pin the popup width up-front, using the *worst-case* widths for
587        // any row that varies at runtime (the progress line).  This keeps
588        // the popup from jittering when progress messages come and go or
589        // change length — the whole point of the spinner + live-refresh
590        // pair is that the UI should look stable while the LSP churns.
591        //
592        //   worst-case progress line =
593        //     "    ⏳ " (4-space indent + ⏳ (2 cells) + space = 7 cells)
594        //     + PROGRESS_FIELD_MAX   (title)
595        //     + " · "                (3 cells)
596        //     + PROGRESS_FIELD_MAX   (message)
597        //     + " (100%)"            (7 cells)
598        //   = 7 + 14 + 3 + 14 + 7 = 45 cells
599        const PROGRESS_LINE_MAX: usize = 7 + PROGRESS_FIELD_MAX + 3 + PROGRESS_FIELD_MAX + 7;
600        let max_static_item_width = items
601            .iter()
602            .map(|i| unicode_width::UnicodeWidthStr::width(i.text.as_str()))
603            .max()
604            .unwrap_or(20);
605        let popup_width =
606            (max_static_item_width.max(PROGRESS_LINE_MAX) as u16 + 4).clamp(30, POPUP_WIDTH_MAX);
607
608        // Pre-select the first actionable item (skip header items with no
609        // data and disabled items like a non-existent View Log).
610        let first_actionable = items
611            .iter()
612            .position(|i| i.data.is_some() && !i.disabled)
613            .unwrap_or(0);
614
615        // Left-align the popup's column with the LSP indicator on the
616        // status bar, if we know where it was drawn in the last frame.
617        // Falls back to the previous BottomRight anchor when the LSP
618        // segment isn't visible (e.g. first render).
619        let position = self
620            .cached_layout
621            .status_bar_lsp_area
622            .map(
623                |(_, col_start, _)| crate::view::popup::PopupPosition::AboveStatusBarAt {
624                    x: col_start,
625                },
626            )
627            .unwrap_or(crate::view::popup::PopupPosition::BottomRight);
628
629        use crate::view::popup::{Popup, PopupContent, PopupKind, PopupResolver};
630        use ratatui::style::Style;
631
632        let focus_hint = if !focused {
633            self.popup_focus_key_hint()
634        } else {
635            None
636        };
637        let popup = Popup {
638            kind: PopupKind::List,
639            title: Some(format!("LSP Servers ({})", language)),
640            description: None,
641            transient: false,
642            content: PopupContent::List {
643                items,
644                selected: first_actionable,
645            },
646            position,
647            width: popup_width,
648            max_height: 15,
649            bordered: true,
650            border_style: Style::default().fg(self.theme.popup_border_fg),
651            background_style: Style::default().bg(self.theme.popup_bg),
652            scroll_offset: 0,
653            text_selection: None,
654            accept_key_hint: None,
655            // This is the LSP status / auto-prompt popup — mark it so
656            // confirm/cancel routes through handle_lsp_status_action
657            // regardless of what other popups are on screen.
658            resolver: PopupResolver::LspStatus,
659            // Auto-prompt path appears unfocused so an LSP popup that
660            // pops up under the user's cursor does not silently swallow
661            // the next keystroke. User-initiated path (status-bar
662            // click, `lsp_status` action) grabs focus on show.
663            focused,
664            focus_key_hint: focus_hint,
665        };
666
667        let buffer_id = self.active_buffer();
668        if let Some(state) = self.buffers.get_mut(&buffer_id) {
669            state.popups.show(popup);
670        }
671    }
672
673    /// Show the Remote Indicator context menu popup.
674    ///
675    /// The menu is context-aware based on the current authority state:
676    /// - **Local:** offers "Attach to Dev Container" (when a devcontainer
677    ///   config is detectable) and "Open Dev Container Config".
678    /// - **Connected (container):** offers "Reopen Locally" (detach),
679    ///   "Rebuild Container", and "Show Container Info".
680    /// - **Connected (SSH):** offers "Disconnect Remote" and "Show Info".
681    /// - **Disconnected:** offers "Reconnect" (best-effort) and "Go Local".
682    ///
683    /// Clicking the `{remote}` status-bar element a second time toggles
684    /// the popup closed, matching the LSP-indicator affordance.
685    ///
686    /// # Design note
687    ///
688    /// Plugin-owned actions (attach, rebuild) are dispatched via
689    /// `Action::PluginAction` so core code never names the devcontainer
690    /// plugin directly. If the plugin isn't loaded the action becomes a
691    /// no-op with a status message, which is the same fallback every
692    /// other plugin-command invocation site uses.
693    pub fn show_remote_indicator_popup(&mut self) {
694        use crate::view::popup::{Popup, PopupContent, PopupKind, PopupListItem, PopupResolver};
695        use ratatui::style::Style;
696
697        if self
698            .active_state()
699            .popups
700            .top()
701            .is_some_and(|p| matches!(p.resolver, PopupResolver::RemoteIndicator))
702        {
703            self.hide_popup();
704            return;
705        }
706
707        let connection = self.connection_display_string();
708        let is_disconnected = connection
709            .as_deref()
710            .is_some_and(|c| c.contains("(Disconnected)"));
711        let is_container = connection
712            .as_deref()
713            .is_some_and(|c| c.starts_with("Container:"));
714        let is_ssh = connection.is_some() && !is_container;
715
716        let devcontainer_config_path = self.find_devcontainer_config();
717
718        let mut items: Vec<PopupListItem> = Vec::new();
719        let mut title: String = String::new();
720
721        // Plugin-supplied override (Connecting / FailedAttach) takes
722        // precedence over the authority-derived branches. A Connecting
723        // indicator shouldn't render the "Reopen in Container" menu
724        // of the underlying derived state — an attach is in flight;
725        // the user needs Show Logs / Cancel / (after B-3b) Retry.
726        //
727        // Local / Connected / Disconnected overrides are treated as
728        // labelling shortcuts, not menu-shape changes — they fall
729        // through to the derived branches below.
730        use crate::view::ui::status_bar::RemoteIndicatorOverride;
731        let override_handled = matches!(
732            self.remote_indicator_override,
733            Some(RemoteIndicatorOverride::Connecting { .. })
734                | Some(RemoteIndicatorOverride::FailedAttach { .. })
735        );
736        if let Some(over) = self.remote_indicator_override.clone() {
737            match over {
738                RemoteIndicatorOverride::Connecting { label } => {
739                    let suffix = label
740                        .filter(|s| !s.is_empty())
741                        .map(|s| format!(" — {}", s))
742                        .unwrap_or_default();
743                    title = format!("Remote: Connecting{}", suffix);
744                    items.push(
745                        PopupListItem::new("    Cancel Startup".to_string())
746                            .with_data("plugin:devcontainer_cancel_attach".to_string()),
747                    );
748                    items.push(
749                        PopupListItem::new("    Show Logs".to_string())
750                            .with_data("plugin:devcontainer_show_build_logs".to_string()),
751                    );
752                }
753                RemoteIndicatorOverride::FailedAttach { error } => {
754                    let suffix = error
755                        .filter(|s| !s.is_empty())
756                        .map(|s| format!(" — {}", s))
757                        .unwrap_or_default();
758                    title = format!("Remote: Attach failed{}", suffix);
759                    items.push(
760                        PopupListItem::new("    Retry".to_string())
761                            .with_data("plugin:devcontainer_retry_attach".to_string()),
762                    );
763                    items.push(
764                        PopupListItem::new("    Reopen Locally".to_string())
765                            .with_data("clear_override".to_string()),
766                    );
767                    items.push(
768                        PopupListItem::new("    Show Build Logs".to_string())
769                            .with_data("plugin:devcontainer_show_build_logs".to_string()),
770                    );
771                }
772                _ => {
773                    // Fall through to the derived branches.
774                }
775            }
776        }
777
778        if !override_handled {
779            match (connection.as_deref(), is_disconnected) {
780                // Connected authority (container or SSH), not disconnected.
781                (Some(label), false) => {
782                    title = format!("Remote: {}", label);
783                    if is_container {
784                        items.push(
785                            PopupListItem::new("    Reopen Locally".to_string())
786                                .with_data("detach".to_string()),
787                        );
788                        items.push(
789                            PopupListItem::new("    Rebuild Container".to_string())
790                                .with_data("plugin:devcontainer_rebuild".to_string()),
791                        );
792                        items.push(
793                            PopupListItem::new("    Show Container Logs".to_string())
794                                .with_data("plugin:devcontainer_show_logs".to_string()),
795                        );
796                        items.push(
797                            PopupListItem::new("    Show Container Info".to_string())
798                                .with_data("plugin:devcontainer_show_info".to_string()),
799                        );
800                        // The build log file from the most recent
801                        // `devcontainer up` survives the post-attach
802                        // restart (path stashed in plugin global state,
803                        // file lives under the workspace's
804                        // `.fresh-cache/`). Surfacing it here means
805                        // users can revisit "what did the build
806                        // actually do" any time after attach without
807                        // hunting through the file tree.
808                        items.push(
809                            PopupListItem::new("    Show Build Logs".to_string())
810                                .with_data("plugin:devcontainer_show_build_logs".to_string()),
811                        );
812                    } else if is_ssh {
813                        items.push(
814                            PopupListItem::new("    Disconnect Remote".to_string())
815                                .with_data("detach".to_string()),
816                        );
817                    }
818                }
819                // Disconnected — warn and offer fallbacks.
820                (Some(_), true) => {
821                    title = "Remote: Disconnected".to_string();
822                    items.push(
823                        PopupListItem::new("    Go Local".to_string())
824                            .with_data("detach".to_string()),
825                    );
826                }
827                // Local authority.
828                (None, _) => {
829                    title = "Remote: Local".to_string();
830                    if devcontainer_config_path.is_some() {
831                        items.push(
832                            PopupListItem::new("    Reopen in Container".to_string())
833                                .with_data("plugin:devcontainer_attach".to_string()),
834                        );
835                        items.push(
836                            PopupListItem::new("    Open Dev Container Config".to_string())
837                                .with_data("plugin:devcontainer_open_config".to_string()),
838                        );
839                    } else {
840                        // No .devcontainer present — offer the scaffold
841                        // so users can bootstrap a config in one click
842                        // without dropping to a shell. The scaffold
843                        // command is plugin-owned and registered
844                        // unconditionally at plugin load, so this row is
845                        // always actionable.
846                        items.push(
847                            PopupListItem::new("    Create Dev Container Config".to_string())
848                                .with_data("plugin:devcontainer_scaffold_config".to_string()),
849                        );
850                    }
851                }
852            }
853        } // end: if !override_handled
854
855        // Dismiss row — mirrors the LSP popup's terminal Dismiss row so
856        // users have an on-screen way out of the popup.
857        let cancel_binding = self
858            .keybindings
859            .read()
860            .ok()
861            .and_then(|kb| {
862                kb.get_keybinding_for_action(
863                    &crate::input::keybindings::Action::PopupCancel,
864                    crate::input::keybindings::KeyContext::Popup,
865                )
866            })
867            .unwrap_or_else(|| "Esc".to_string());
868        items.push(
869            PopupListItem::new(format!("    Dismiss ({})", cancel_binding))
870                .with_data("cancel_popup".to_string()),
871        );
872
873        let first_actionable = items
874            .iter()
875            .position(|i| i.data.is_some() && !i.disabled)
876            .unwrap_or(0);
877
878        // Anchor the popup to the remote-indicator's left edge if it's
879        // visible in the last frame; otherwise fall back to the bottom-
880        // right corner so the popup still appears.
881        let position = self
882            .cached_layout
883            .status_bar_remote_area
884            .map(
885                |(_, col_start, _)| crate::view::popup::PopupPosition::AboveStatusBarAt {
886                    x: col_start,
887                },
888            )
889            .unwrap_or(crate::view::popup::PopupPosition::BottomRight);
890
891        let popup_width = (items
892            .iter()
893            .map(|i| unicode_width::UnicodeWidthStr::width(i.text.as_str()))
894            .max()
895            .unwrap_or(24)
896            + 4) as u16;
897
898        let popup = Popup {
899            kind: PopupKind::List,
900            title: Some(title),
901            description: None,
902            transient: false,
903            content: PopupContent::List {
904                items,
905                selected: first_actionable,
906            },
907            position,
908            width: popup_width.clamp(28, 50),
909            max_height: 10,
910            bordered: true,
911            border_style: Style::default().fg(self.theme.popup_border_fg),
912            background_style: Style::default().bg(self.theme.popup_bg),
913            scroll_offset: 0,
914            text_selection: None,
915            accept_key_hint: None,
916            resolver: PopupResolver::RemoteIndicator,
917            // Explicitly invoked from the status-bar `{remote}` element,
918            // so this popup wants the keyboard immediately.
919            focused: true,
920            focus_key_hint: None,
921        };
922
923        let buffer_id = self.active_buffer();
924        if let Some(state) = self.buffers.get_mut(&buffer_id) {
925            state.popups.show(popup);
926        }
927    }
928
929    /// Dispatch the action selected from the Remote Indicator popup.
930    ///
931    /// - `"detach"` — `clear_authority()` (falls back to local).
932    /// - `"clear_override"` — drop the Remote Indicator override
933    ///   without changing the authority. Used by the FailedAttach
934    ///   "Reopen Locally" row: nothing to detach (no authority was
935    ///   ever installed), but the FailedAttach indicator should
936    ///   clear.
937    /// - `"plugin:<name>"` — forwards to `Action::PluginAction(name)`.
938    /// - `"cancel_popup"` — no-op; the popup framework already
939    ///   closed the popup when the row was confirmed.
940    /// - anything else — logged and ignored.
941    pub fn handle_remote_indicator_action(&mut self, action_key: &str) {
942        if action_key == "detach" {
943            self.remote_indicator_override = None;
944            self.clear_authority();
945            return;
946        }
947        if action_key == "clear_override" {
948            self.remote_indicator_override = None;
949            return;
950        }
951        if action_key == "cancel_popup" {
952            return;
953        }
954        if let Some(plugin_action) = action_key.strip_prefix("plugin:") {
955            // `handle_action` wires this through the plugin manager; if
956            // the plugin isn't loaded it surfaces a status message, which
957            // is the correct no-op behavior for every plugin-command
958            // invocation site in the codebase. We still want to log an
959            // unexpected dispatch error — plugin misbehavior shouldn't
960            // leave the user staring at a silently-failed Retry click.
961            if let Err(e) = self.handle_action(crate::input::keybindings::Action::PluginAction(
962                plugin_action.to_string(),
963            )) {
964                tracing::warn!(
965                    "remote indicator popup: dispatching '{}' failed: {}",
966                    plugin_action,
967                    e
968                );
969            }
970            return;
971        }
972        tracing::warn!(
973            "handle_remote_indicator_action: unknown action key '{}'",
974            action_key
975        );
976    }
977
978    /// Probe for a `devcontainer.json` under the current working
979    /// directory. Mirrors the first two priorities of the devcontainer
980    /// plugin's `findConfig()` so the Remote Indicator menu can decide
981    /// whether to offer "Reopen in Container" without actually having to
982    /// call into the plugin.
983    ///
984    /// Routes through `authority.filesystem` per `CONTRIBUTING.md`
985    /// guideline 4, so an SSH-rooted workspace probes the remote host
986    /// rather than the local one.
987    fn find_devcontainer_config(&self) -> Option<std::path::PathBuf> {
988        let cwd = self.working_dir();
989        let fs = self.authority.filesystem.as_ref();
990        let primary = cwd.join(".devcontainer").join("devcontainer.json");
991        if fs.exists(&primary) {
992            return Some(primary);
993        }
994        let secondary = cwd.join(".devcontainer.json");
995        if fs.exists(&secondary) {
996            return Some(secondary);
997        }
998        None
999    }
1000
1001    /// Show a transient hover popup with the given message text, positioned below the cursor.
1002    /// Used for file-open messages (e.g. `file.txt:10@"Look at this"`).
1003    pub fn show_file_message_popup(&mut self, message: &str) {
1004        use crate::view::popup::{Popup, PopupPosition};
1005        use ratatui::style::Style;
1006
1007        // Build markdown: message text + blank line + italic hint
1008        let md = format!("{}\n\n*esc to dismiss*", message);
1009        // Size popup width to content: longest line + border padding, clamped to reasonable bounds
1010        let content_width = message.lines().map(|l| l.len()).max().unwrap_or(0) as u16;
1011        let hint_width = 16u16; // "*esc to dismiss*"
1012        let popup_width = (content_width.max(hint_width) + 4).clamp(20, 60);
1013
1014        let mut popup = Popup::markdown(&md, &self.theme, Some(&self.grammar_registry));
1015        popup.transient = false;
1016        popup.position = PopupPosition::BelowCursor;
1017        popup.width = popup_width;
1018        popup.max_height = 15;
1019        popup.border_style = Style::default().fg(self.theme.popup_border_fg);
1020        popup.background_style = Style::default().bg(self.theme.popup_bg);
1021
1022        let buffer_id = self.active_buffer();
1023        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1024            state.popups.show(popup);
1025        }
1026    }
1027
1028    /// Get text properties at the cursor position in the active buffer
1029    pub fn get_text_properties_at_cursor(
1030        &self,
1031    ) -> Option<Vec<&crate::primitives::text_property::TextProperty>> {
1032        let state = self.buffers.get(&self.active_buffer())?;
1033        let cursor_pos = self.active_cursors().primary().position;
1034        Some(state.text_properties.get_at(cursor_pos))
1035    }
1036}