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