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