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.active_window_mut().warning_domains.has_any_warnings() {
32            self.active_window_mut().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 =
62            self.active_window_mut().warning_domains.lsp.level() == crate::app::WarningLevel::Error;
63        let language = self
64            .buffers()
65            .get(&self.active_buffer())
66            .map(|s| s.language.clone())
67            .unwrap_or_else(|| "unknown".to_string());
68
69        // Compute the set of configured servers whose binaries are not
70        // resolvable — plugins and the popup itself both need this to
71        // decide between "offer to start" and "offer install help".
72        // Probe missing binaries through the active authority. When the
73        // LspManager isn't wired (tests or very early boot), fall
74        // back to the synchronous host-side `which` probe — same path
75        // `command_exists_via_authority` would take after the
76        // long-running spawner bootstrap completes.
77        let missing_servers: Vec<String> = self
78            .config
79            .lsp
80            .get(&language)
81            .map(|cfg| {
82                cfg.as_slice()
83                    .iter()
84                    .filter(|c| c.enabled && !c.command.is_empty())
85                    .filter(|c| match self.lsp() {
86                        Some(mgr) => !mgr.command_exists_via_authority(&c.command),
87                        None => !crate::services::lsp::command_exists(&c.command),
88                    })
89                    .map(|c| c.command.clone())
90                    .collect()
91            })
92            .unwrap_or_default();
93        let user_dismissed = self
94            .active_window()
95            .is_lsp_language_user_dismissed(&language);
96
97        // Fire the LspStatusClicked hook for plugins. A plugin's
98        // handler may itself push a popup (e.g. the embedded
99        // rust-lsp.ts plugin shows install instructions when its
100        // `rustLspError` is set).
101        self.plugin_manager.read().unwrap().run_hook(
102            "lsp_status_clicked",
103            crate::services::plugins::hooks::HookArgs::LspStatusClicked {
104                language: language.clone(),
105                has_error,
106                missing_servers,
107                user_dismissed,
108            },
109        );
110
111        // If something is already on the popup stack at this point
112        // — either pushed by the hook above (the common case: a
113        // plugin's `editor.showActionPopup` in response to
114        // `lsp_status_clicked`) or already showing when the user
115        // clicked the indicator — don't stack the built-in LSP
116        // Servers popup on top. The hook's popup is the more
117        // contextual answer to the click; layering two popups for
118        // one gesture is the user-reported "I had several kinds of
119        // popups" bug.
120        if self.active_state().popups.top().is_some() {
121            return;
122        }
123
124        self.build_and_show_lsp_status_popup(&language, true);
125    }
126
127    /// Rebuild the LSP-status popup in place if it's currently open.
128    ///
129    /// Used when an async event (progress update, server state change) might
130    /// change the popup's contents — notably while rust-analyzer is indexing
131    /// and emits `$/progress` every few hundred ms.  Without this, the popup
132    /// would freeze on the snapshot taken at open time while the status-bar
133    /// spinner keeps moving, making them look disconnected.
134    pub fn refresh_lsp_status_popup_if_open(&mut self) {
135        // Only rebuild if the active buffer's top popup IS an LSP
136        // status popup — otherwise we'd spuriously build one on top of
137        // unrelated state.
138        if !self
139            .active_state()
140            .popups
141            .top()
142            .is_some_and(is_lsp_status_popup)
143        {
144            return;
145        }
146        let language = self
147            .buffers()
148            .get(&self.active_buffer())
149            .map(|s| s.language.clone())
150            .unwrap_or_else(|| "unknown".to_string());
151        // Replace contents: hide then rebuild. Refresh is triggered by
152        // async progress updates while the popup is already on screen,
153        // so we keep its existing focused state — flipping it back to
154        // unfocused on every progress tick would yank focus away from
155        // a user mid-interaction.
156        let was_focused = self
157            .active_state()
158            .popups
159            .top()
160            .map(|p| p.focused)
161            .unwrap_or(true);
162        self.hide_popup();
163        self.build_and_show_lsp_status_popup(&language, was_focused);
164    }
165
166    fn build_and_show_lsp_status_popup(&mut self, language: &str, focused: bool) {
167        use crate::services::async_bridge::LspServerStatus;
168
169        // Build a unified list of all configured servers for this language,
170        // merged with their runtime status (if running).
171        let running_statuses: std::collections::HashMap<String, LspServerStatus> = self
172            .active_window()
173            .lsp_server_statuses
174            .iter()
175            .filter(|((lang, _), _)| lang == language)
176            .map(|((_, name), status)| (name.clone(), *status))
177            .collect();
178
179        let configured_servers: Vec<String> = self
180            .config
181            .lsp
182            .get(language)
183            .map(|cfg| {
184                cfg.as_slice()
185                    .iter()
186                    .filter(|c| !c.command.is_empty())
187                    .map(|c| c.display_name())
188                    .collect()
189            })
190            .unwrap_or_default();
191
192        // Per-server binary availability map (display_name → bool).
193        // `command_exists` is cached, so repeated popup opens or a
194        // refresh-while-open are cheap.  We look up by display name
195        // because `all_servers` below is built from display names;
196        // LspServerConfig::display_name() falls back to the command
197        // basename when no explicit `name` is set.
198        let missing_by_server: std::collections::HashMap<String, bool> = self
199            .config
200            .lsp
201            .get(language)
202            .map(|cfg| {
203                cfg.as_slice()
204                    .iter()
205                    .filter(|c| !c.command.is_empty())
206                    .map(|c| {
207                        let missing = match self.lsp() {
208                            Some(mgr) => !mgr.command_exists_via_authority(&c.command),
209                            None => !crate::services::lsp::command_exists(&c.command),
210                        };
211                        (c.display_name(), missing)
212                    })
213                    .collect()
214            })
215            .unwrap_or_default();
216        // Per-server auto_start flag map (display_name → auto_start).
217        // Used to decide whether to offer an "Enable auto-start for X"
218        // row alongside the "Start X" action — relevant only when the
219        // server is enabled but dormant and the user hasn't opted into
220        // auto-start yet.
221        let auto_start_by_server: std::collections::HashMap<String, bool> = self
222            .config
223            .lsp
224            .get(language)
225            .map(|cfg| {
226                cfg.as_slice()
227                    .iter()
228                    .filter(|c| !c.command.is_empty())
229                    .map(|c| (c.display_name(), c.auto_start))
230                    .collect()
231            })
232            .unwrap_or_default();
233        let user_dismissed = self
234            .active_window()
235            .is_lsp_language_user_dismissed(language);
236
237        if configured_servers.is_empty() && running_statuses.is_empty() {
238            self.active_window_mut().status_message = Some(t!("lsp.no_server_active").to_string());
239            return;
240        }
241
242        // Merge: start with configured servers, then add any running servers
243        // not in the config (shouldn't happen, but be safe).
244        let mut all_servers: Vec<String> = configured_servers;
245        for name in running_statuses.keys() {
246            if !all_servers.contains(name) {
247                all_servers.push(name.clone());
248            }
249        }
250        all_servers.sort();
251
252        // Build the popup's items as view-level `PopupListItem`s directly.
253        // We bypass the `PopupListItemData` event type here because we need
254        // the `disabled` field (for "View Log" when no log exists), which
255        // is a view-only concern and plumbing it through the event boundary
256        // would require touching ~40 existing literals across the test
257        // suite.
258        let mut items: Vec<crate::view::popup::PopupListItem> = Vec::new();
259        let mut action_keys: Vec<(String, String)> = Vec::new();
260
261        /// Truncate `s` to at most `max_cells` display cells, appending an
262        /// ellipsis if truncation happened (the ellipsis is included in the
263        /// budget, so the result is ≤ `max_cells` wide regardless of input).
264        fn truncate(s: &str, max_cells: usize) -> String {
265            use unicode_width::UnicodeWidthChar;
266            let w = unicode_width::UnicodeWidthStr::width(s);
267            if w <= max_cells {
268                return s.to_string();
269            }
270            let budget = max_cells.saturating_sub(1);
271            let mut used = 0;
272            let mut out = String::new();
273            for ch in s.chars() {
274                let cw = ch.width().unwrap_or(0);
275                if used + cw > budget {
276                    break;
277                }
278                used += cw;
279                out.push(ch);
280            }
281            out.push('…');
282            out
283        }
284        const PROGRESS_FIELD_MAX: usize = 14;
285        const POPUP_WIDTH_MAX: u16 = 50;
286
287        for name in &all_servers {
288            let status = running_statuses.get(name).copied();
289            let is_active = status
290                .map(|s| !matches!(s, LspServerStatus::Shutdown))
291                .unwrap_or(false);
292            // A server is "missing" only when it's NOT currently running
293            // (an absolute-path binary could have been removed mid-session,
294            // but the live server is still talking to us).
295            let binary_missing =
296                !is_active && missing_by_server.get(name).copied().unwrap_or(false);
297
298            // Header: server name + status (data = None → not clickable,
299            // not underlined).  Swap the "not running" label for a more
300            // actionable "binary not found" when we can see up-front that
301            // a start attempt would fail — this is the user-visible half
302            // of the pre-click probe. The `binary_missing` signal comes
303            // from the authority-routed `command_exists` (L-3c), so the
304            // "not installed" copy says where it actually isn't: in the
305            // container for container authorities, on the host
306            // otherwise.
307            let authority_is_container = self.authority().display_label.starts_with("Container:");
308            let missing_label = if authority_is_container {
309                "not installed in container"
310            } else {
311                "binary not in PATH"
312            };
313            let (icon, label) = match status {
314                Some(LspServerStatus::Running) => ("●", "ready"),
315                Some(LspServerStatus::Error) => ("✗", "error"),
316                Some(LspServerStatus::Starting) => ("◌", "starting"),
317                Some(LspServerStatus::Initializing) => ("◌", "initializing"),
318                Some(LspServerStatus::Shutdown) | None => {
319                    if binary_missing {
320                        ("○", missing_label)
321                    } else {
322                        ("○", "not running")
323                    }
324                }
325            };
326            items.push(crate::view::popup::PopupListItem::new(format!(
327                "{} {} ({})",
328                icon, name, label
329            )));
330
331            // Progress row immediately UNDER the server's name row, if
332            // there's an active `$/progress` notification for this
333            // language.  Indented to match the action rows below, and the
334            // title + message fields are individually truncated so a
335            // runaway progress path can't stretch the popup.  The popup
336            // width is pinned in advance (see below) so the row's content
337            // changing never reshapes the popup.
338            if let Some(info) = self
339                .active_window()
340                .lsp_progress
341                .values()
342                .find(|info| info.language == language)
343            {
344                let mut line = format!("    ⏳ {}", truncate(&info.title, PROGRESS_FIELD_MAX));
345                if let Some(ref msg) = info.message {
346                    line.push_str(&format!(" · {}", truncate(msg, PROGRESS_FIELD_MAX)));
347                }
348                if let Some(pct) = info.percentage {
349                    line.push_str(&format!(" ({}%)", pct));
350                }
351                items.push(crate::view::popup::PopupListItem::new(line));
352            }
353
354            if is_active {
355                // Restart
356                let restart_key = format!("restart:{}/{}", language, name);
357                items.push(
358                    crate::view::popup::PopupListItem::new(format!("    Restart {}", name))
359                        .with_data(restart_key.clone()),
360                );
361                action_keys.push((restart_key, format!("Restart {}", name)));
362
363                // Stop
364                let stop_key = format!("stop:{}/{}", language, name);
365                items.push(
366                    crate::view::popup::PopupListItem::new(format!("    Stop {}", name))
367                        .with_data(stop_key.clone()),
368                );
369                action_keys.push((stop_key, format!("Stop {}", name)));
370            } else if binary_missing {
371                // Show a disabled advisory row instead of an actionable
372                // "Start" — clicking Start here would spawn, fail, and
373                // noise up the status area. Copy shifts with the
374                // authority so the user is pointed at the right
375                // install surface: `devcontainer.json`'s
376                // `postCreateCommand` for containers, the host's
377                // package manager otherwise.
378                let advisory = if authority_is_container {
379                    format!("    Install {name} in container (postCreateCommand)")
380                } else {
381                    format!("    Install {name} to enable")
382                };
383                items.push(crate::view::popup::PopupListItem::new(advisory).disabled());
384            } else {
385                // Two sibling rows for a dormant server, in the
386                // order the user most likely wants:
387                //
388                //   "Start <name> (always)" — persist auto_start=true
389                //                              AND start the server now.
390                //                              Listed first because
391                //                              persistent-start is the
392                //                              common case, so pre-
393                //                              selecting it lets the
394                //                              user press Enter and
395                //                              move on.
396                //   "Start <name> once"     — start for this session,
397                //                              config stays auto_start=false.
398                //
399                // The "once" suffix is only needed (vs. just "Start")
400                // when the "(always)" sibling is also present — i.e.
401                // when auto_start is currently false. Otherwise there
402                // is nothing to disambiguate it from.
403                let is_manual = !auto_start_by_server.get(name).copied().unwrap_or(true);
404
405                // "(always)" row — first, so it's the default.
406                if is_manual {
407                    let autostart_key = format!("autostart:{}/{}", language, name);
408                    items.push(
409                        crate::view::popup::PopupListItem::new(format!(
410                            "    Start {} (always)",
411                            name
412                        ))
413                        .with_data(autostart_key.clone()),
414                    );
415                    action_keys.push((autostart_key, format!("Start {} (always)", name)));
416                }
417
418                // "once" / plain Start row.
419                let start_label = if is_manual {
420                    format!("    Start {} once", name)
421                } else {
422                    format!("    Start {}", name)
423                };
424                let start_action_label = if is_manual {
425                    format!("Start {} once", name)
426                } else {
427                    format!("Start {}", name)
428                };
429                let start_key = format!("start:{}", language);
430                if !action_keys.iter().any(|(k, _)| k == &start_key) {
431                    items.push(
432                        crate::view::popup::PopupListItem::new(start_label)
433                            .with_data(start_key.clone()),
434                    );
435                    action_keys.push((start_key, start_action_label));
436                }
437            }
438        }
439
440        // Disable / Enable row — shown whenever the language has at
441        // least one configured server. The label flips on either the
442        // session-level dismiss flag OR the persisted `enabled = false`
443        // half: both mean "the language is currently muted from the
444        // user's POV", and showing "Disable" while the config already
445        // has every server disabled would leave the user with no
446        // surface to undo it. Picking the row writes through to the
447        // matching half of the state in `handle_lsp_status_action`
448        // (`dismiss:` flips both, `enable:` flips both) so the two
449        // signals stay in sync after every round-trip.
450        let any_enabled = self
451            .config
452            .lsp
453            .get(language)
454            .is_some_and(|cfg| cfg.as_slice().iter().any(|c| c.enabled));
455        let muted = user_dismissed || !any_enabled;
456        if muted {
457            let enable_key = format!("enable:{}", language);
458            items.push(
459                crate::view::popup::PopupListItem::new(format!("    Enable LSP for {}", language))
460                    .with_data(enable_key.clone()),
461            );
462            action_keys.push((enable_key, format!("Enable LSP for {}", language)));
463        } else {
464            let dismiss_key = format!("dismiss:{}", language);
465            items.push(
466                crate::view::popup::PopupListItem::new(format!("    Disable LSP for {}", language))
467                    .with_data(dismiss_key.clone()),
468            );
469            action_keys.push((dismiss_key, format!("Disable LSP for {}", language)));
470        }
471
472        // View log action — grayed out and non-actionable when no
473        // log file exists yet for this language (e.g. the server was
474        // never started, or has been rotated away).
475        let log_path = crate::services::log_dirs::lsp_log_path(language);
476        let log_exists = log_path.exists();
477        let log_key = format!("log:{}", language);
478        let mut log_item = crate::view::popup::PopupListItem::new("    View Log".to_string());
479        if log_exists {
480            log_item = log_item.with_data(log_key.clone());
481            action_keys.push((log_key, "View Log".to_string()));
482        } else {
483            log_item = log_item.disabled();
484        }
485        items.push(log_item);
486
487        // Plugin-contributed rows — injected between View Log and
488        // Dismiss as an extra "Plugin actions" section. This is the
489        // merge half of "Option B" (#1941 follow-up): instead of
490        // plugins pushing their own separate popup via
491        // `editor.showActionPopup` (which stacked on top of this one
492        // and confused the user), they install rows here via
493        // `PluginCommand::SetLspMenuContributions` and the editor
494        // routes the eventual selection back via
495        // `action_popup_result` with `popup_id = "lsp_status"` and
496        // `action_id = "{plugin_id}|{item_id}"`.
497        //
498        // Sorted by (plugin_id, item index) for stable ordering so
499        // the popup doesn't shuffle rows between renders. A single
500        // header row labels the section when there's at least one
501        // contributed item, so the user can tell the rows below come
502        // from a plugin (vs. built-in actions like Stop/Restart).
503        let mut contributed: Vec<(&String, &Vec<crate::app::LspMenuItem>)> = self
504            .active_window()
505            .lsp_menu_contributions
506            .iter()
507            .filter_map(|((lang, plugin_id), items)| {
508                if lang == language && !items.is_empty() {
509                    Some((plugin_id, items))
510                } else {
511                    None
512                }
513            })
514            .collect();
515        contributed.sort_by(|a, b| a.0.cmp(b.0));
516        if !contributed.is_empty() {
517            // Section header — non-actionable, mimics the language
518            // header at the top (no data → not clickable).
519            items.push(crate::view::popup::PopupListItem::new(
520                "  ─ Plugin actions ─".to_string(),
521            ));
522            for (plugin_id, plugin_items) in contributed {
523                for it in plugin_items {
524                    let key = format!("plugin:{}|{}", plugin_id, it.id);
525                    items.push(
526                        crate::view::popup::PopupListItem::new(format!("    {}", it.label))
527                            .with_data(key.clone()),
528                    );
529                    action_keys.push((key, it.label.clone()));
530                }
531            }
532        }
533
534        // Trailing Dismiss row — gives users an on-screen way out of
535        // the popup without having to know that Esc works. The key
536        // label is looked up from the keybinding resolver so a
537        // rebound PopupCancel stays visible in the row label
538        // ("Dismiss (Q)", etc.). Falls back to "Esc" as the usual
539        // default if the resolver has no binding at all (unusual,
540        // but we don't want an empty parenthetical).
541        let cancel_binding = self
542            .keybindings
543            .read()
544            .ok()
545            .and_then(|kb| {
546                kb.get_keybinding_for_action(
547                    &crate::input::keybindings::Action::PopupCancel,
548                    crate::input::keybindings::KeyContext::Popup,
549                )
550            })
551            .unwrap_or_else(|| "Esc".to_string());
552        let cancel_key = "cancel_popup".to_string();
553        items.push(
554            crate::view::popup::PopupListItem::new(format!("    Dismiss ({})", cancel_binding))
555                .with_data(cancel_key.clone()),
556        );
557        action_keys.push((cancel_key, format!("Dismiss ({})", cancel_binding)));
558        // `action_keys` is no longer kept on the editor — each list
559        // item already carries its action key in its `data` field, and
560        // the `LspStatus` resolver on the popup tells confirm how to
561        // interpret that data. The local binding is retained only to
562        // keep the existing construction logic unchanged; it falls out
563        // of scope with the rest.
564        let _ = action_keys;
565
566        // Pin the popup width up-front, using the *worst-case* widths for
567        // any row that varies at runtime (the progress line).  This keeps
568        // the popup from jittering when progress messages come and go or
569        // change length — the whole point of the spinner + live-refresh
570        // pair is that the UI should look stable while the LSP churns.
571        //
572        //   worst-case progress line =
573        //     "    ⏳ " (4-space indent + ⏳ (2 cells) + space = 7 cells)
574        //     + PROGRESS_FIELD_MAX   (title)
575        //     + " · "                (3 cells)
576        //     + PROGRESS_FIELD_MAX   (message)
577        //     + " (100%)"            (7 cells)
578        //   = 7 + 14 + 3 + 14 + 7 = 45 cells
579        const PROGRESS_LINE_MAX: usize = 7 + PROGRESS_FIELD_MAX + 3 + PROGRESS_FIELD_MAX + 7;
580        let max_static_item_width = items
581            .iter()
582            .map(|i| unicode_width::UnicodeWidthStr::width(i.text.as_str()))
583            .max()
584            .unwrap_or(20);
585        let popup_width =
586            (max_static_item_width.max(PROGRESS_LINE_MAX) as u16 + 4).clamp(30, POPUP_WIDTH_MAX);
587
588        // Pre-select the first actionable item (skip header items with no
589        // data and disabled items like a non-existent View Log).
590        let first_actionable = items
591            .iter()
592            .position(|i| i.data.is_some() && !i.disabled)
593            .unwrap_or(0);
594
595        // Left-align the popup's column with the LSP indicator on the
596        // status bar, if we know where it was drawn in the last frame.
597        // Falls back to the previous BottomRight anchor when the LSP
598        // segment isn't visible (e.g. first render). `status_row` comes
599        // from the same cached layout so the popup hugs the status bar
600        // even in prompt-auto-hide mode.
601        let position = self
602            .active_chrome()
603            .status_bar_lsp_area
604            .map(
605                |(status_row, col_start, _)| crate::view::popup::PopupPosition::AboveStatusBarAt {
606                    x: col_start,
607                    status_row,
608                },
609            )
610            .unwrap_or(crate::view::popup::PopupPosition::BottomRight);
611
612        use crate::view::popup::{Popup, PopupContent, PopupKind, PopupResolver};
613        use ratatui::style::Style;
614
615        let focus_hint = if !focused {
616            self.popup_focus_key_hint()
617        } else {
618            None
619        };
620        let popup = Popup {
621            kind: PopupKind::List,
622            title: Some(format!("LSP Servers ({})", language)),
623            description: None,
624            transient: false,
625            content: PopupContent::List {
626                items,
627                selected: first_actionable,
628            },
629            position,
630            width: popup_width,
631            max_height: 15,
632            bordered: true,
633            border_style: Style::default().fg(self.theme.read().unwrap().popup_border_fg),
634            background_style: Style::default().bg(self.theme.read().unwrap().popup_bg),
635            scroll_offset: 0,
636            text_selection: None,
637            accept_key_hint: None,
638            // This is the LSP status popup — mark it so confirm/cancel
639            // routes through handle_lsp_status_action regardless of what
640            // other popups are on screen.
641            resolver: PopupResolver::LspStatus,
642            focused,
643            focus_key_hint: focus_hint,
644        };
645
646        let buffer_id = self.active_buffer();
647        if let Some(state) = self
648            .windows
649            .get_mut(&self.active_window)
650            .map(|w| &mut w.buffers)
651            .expect("active window present")
652            .get_mut(&buffer_id)
653        {
654            state.popups.show(popup);
655        }
656    }
657
658    /// Show the Remote Indicator context menu popup.
659    ///
660    /// The menu is context-aware based on the current authority state:
661    /// - **Local:** offers "Attach to Dev Container" (when a devcontainer
662    ///   config is detectable) and "Open Dev Container Config".
663    /// - **Connected (container):** offers "Reopen Locally" (detach),
664    ///   "Rebuild Container", and "Show Container Info".
665    /// - **Connected (SSH):** offers "Disconnect Remote" and "Show Info".
666    /// - **Disconnected:** offers "Reconnect" (best-effort) and "Go Local".
667    ///
668    /// Clicking the `{remote}` status-bar element a second time toggles
669    /// the popup closed, matching the LSP-indicator affordance.
670    ///
671    /// # Design note
672    ///
673    /// Plugin-owned actions (attach, rebuild) are dispatched via
674    /// `Action::PluginAction` so core code never names the devcontainer
675    /// plugin directly. If the plugin isn't loaded the action becomes a
676    /// no-op with a status message, which is the same fallback every
677    /// other plugin-command invocation site uses.
678    pub fn show_remote_indicator_popup(&mut self) {
679        use crate::view::popup::{Popup, PopupContent, PopupKind, PopupListItem, PopupResolver};
680        use ratatui::style::Style;
681
682        if self
683            .active_state()
684            .popups
685            .top()
686            .is_some_and(|p| matches!(p.resolver, PopupResolver::RemoteIndicator))
687        {
688            self.hide_popup();
689            return;
690        }
691        // Not a toggle-close: clear any *other* menu popup (a different
692        // status-bar picker left open) before building this one, so the
693        // remote menu never renders over a stale popup (#1941). Done here,
694        // after the toggle check, rather than in the click handler — doing it
695        // there would close our own popup and defeat the toggle.
696        self.dismiss_menu_popups_for_prompt();
697
698        let connection = self.connection_display_string();
699        let is_disconnected = connection
700            .as_deref()
701            .is_some_and(|c| c.contains("(Disconnected)"));
702        let is_container = connection
703            .as_deref()
704            .is_some_and(|c| c.starts_with("Container:"));
705        let is_ssh = connection.is_some() && !is_container;
706
707        let devcontainer_config_path = self.find_devcontainer_config();
708
709        let mut items: Vec<PopupListItem> = Vec::new();
710        let mut title: String = String::new();
711
712        // Plugin-supplied override (Connecting / FailedAttach) takes
713        // precedence over the authority-derived branches. A Connecting
714        // indicator shouldn't render the "Reopen in Container" menu
715        // of the underlying derived state — an attach is in flight;
716        // the user needs Show Logs / Cancel / (after B-3b) Retry.
717        //
718        // Local / Connected / Disconnected overrides are treated as
719        // labelling shortcuts, not menu-shape changes — they fall
720        // through to the derived branches below.
721        use crate::view::ui::status_bar::RemoteIndicatorOverride;
722        let override_handled = matches!(
723            self.remote_indicator_override,
724            Some(RemoteIndicatorOverride::Connecting { .. })
725                | Some(RemoteIndicatorOverride::FailedAttach { .. })
726        );
727        if let Some(over) = self.remote_indicator_override.clone() {
728            match over {
729                RemoteIndicatorOverride::Connecting { label } => {
730                    let suffix = label
731                        .filter(|s| !s.is_empty())
732                        .map(|s| format!(" — {}", s))
733                        .unwrap_or_default();
734                    title = format!("Remote: Connecting{}", suffix);
735                    items.push(
736                        PopupListItem::new("    Cancel Startup".to_string())
737                            .with_data("plugin:devcontainer_cancel_attach".to_string()),
738                    );
739                    items.push(
740                        PopupListItem::new("    Show Logs".to_string())
741                            .with_data("plugin:devcontainer_show_build_logs".to_string()),
742                    );
743                }
744                RemoteIndicatorOverride::FailedAttach { error } => {
745                    let suffix = error
746                        .filter(|s| !s.is_empty())
747                        .map(|s| format!(" — {}", s))
748                        .unwrap_or_default();
749                    title = format!("Remote: Attach failed{}", suffix);
750                    items.push(
751                        PopupListItem::new("    Retry".to_string())
752                            .with_data("plugin:devcontainer_retry_attach".to_string()),
753                    );
754                    items.push(
755                        PopupListItem::new("    Reopen Locally".to_string())
756                            .with_data("clear_override".to_string()),
757                    );
758                    items.push(
759                        PopupListItem::new("    Show Build Logs".to_string())
760                            .with_data("plugin:devcontainer_show_build_logs".to_string()),
761                    );
762                }
763                _ => {
764                    // Fall through to the derived branches.
765                }
766            }
767        }
768
769        if !override_handled {
770            match (connection.as_deref(), is_disconnected) {
771                // Connected authority (container or SSH), not disconnected.
772                (Some(label), false) => {
773                    title = format!("Remote: {}", label);
774                    if is_container {
775                        items.push(
776                            PopupListItem::new("    Reopen Locally".to_string())
777                                .with_data("detach".to_string()),
778                        );
779                        items.push(
780                            PopupListItem::new("    Rebuild Container".to_string())
781                                .with_data("plugin:devcontainer_rebuild".to_string()),
782                        );
783                        items.push(
784                            PopupListItem::new("    Show Container Logs".to_string())
785                                .with_data("plugin:devcontainer_show_logs".to_string()),
786                        );
787                        items.push(
788                            PopupListItem::new("    Show Container Info".to_string())
789                                .with_data("plugin:devcontainer_show_info".to_string()),
790                        );
791                        // The build log file from the most recent
792                        // `devcontainer up` survives the post-attach
793                        // restart (path stashed in plugin global state,
794                        // file lives under the workspace's
795                        // `.fresh-cache/`). Surfacing it here means
796                        // users can revisit "what did the build
797                        // actually do" any time after attach without
798                        // hunting through the file tree.
799                        items.push(
800                            PopupListItem::new("    Show Build Logs".to_string())
801                                .with_data("plugin:devcontainer_show_build_logs".to_string()),
802                        );
803                    } else if is_ssh {
804                        items.push(
805                            PopupListItem::new("    Disconnect Remote".to_string())
806                                .with_data("detach".to_string()),
807                        );
808                    }
809                }
810                // Disconnected — warn and offer fallbacks.
811                (Some(_), true) => {
812                    title = "Remote: Disconnected".to_string();
813                    items.push(
814                        PopupListItem::new("    Go Local".to_string())
815                            .with_data("detach".to_string()),
816                    );
817                }
818                // Local authority.
819                (None, _) => {
820                    title = "Remote: Local".to_string();
821                    if devcontainer_config_path.is_some() {
822                        items.push(
823                            PopupListItem::new("    Reopen in Container".to_string())
824                                .with_data("plugin:devcontainer_attach".to_string()),
825                        );
826                        items.push(
827                            PopupListItem::new("    Open Dev Container Config".to_string())
828                                .with_data("plugin:devcontainer_open_config".to_string()),
829                        );
830                    } else {
831                        // No .devcontainer present — offer the scaffold
832                        // so users can bootstrap a config in one click
833                        // without dropping to a shell. The scaffold
834                        // command is plugin-owned and registered
835                        // unconditionally at plugin load, so this row is
836                        // always actionable.
837                        items.push(
838                            PopupListItem::new("    Create Dev Container Config".to_string())
839                                .with_data("plugin:devcontainer_scaffold_config".to_string()),
840                        );
841                    }
842                }
843            }
844        } // end: if !override_handled
845
846        // Dismiss row — mirrors the LSP popup's terminal Dismiss row so
847        // users have an on-screen way out of the popup.
848        let cancel_binding = self
849            .keybindings
850            .read()
851            .ok()
852            .and_then(|kb| {
853                kb.get_keybinding_for_action(
854                    &crate::input::keybindings::Action::PopupCancel,
855                    crate::input::keybindings::KeyContext::Popup,
856                )
857            })
858            .unwrap_or_else(|| "Esc".to_string());
859        items.push(
860            PopupListItem::new(format!("    Dismiss ({})", cancel_binding))
861                .with_data("cancel_popup".to_string()),
862        );
863
864        let first_actionable = items
865            .iter()
866            .position(|i| i.data.is_some() && !i.disabled)
867            .unwrap_or(0);
868
869        // Anchor the popup to the remote-indicator's left edge if it's
870        // visible in the last frame; otherwise fall back to the bottom-
871        // right corner so the popup still appears. `status_row` comes
872        // from the same cached layout so the popup hugs the status bar
873        // even in prompt-auto-hide mode.
874        let position = self
875            .active_chrome()
876            .status_bar_remote_area
877            .map(
878                |(status_row, col_start, _)| crate::view::popup::PopupPosition::AboveStatusBarAt {
879                    x: col_start,
880                    status_row,
881                },
882            )
883            .unwrap_or(crate::view::popup::PopupPosition::BottomRight);
884
885        let popup_width = (items
886            .iter()
887            .map(|i| unicode_width::UnicodeWidthStr::width(i.text.as_str()))
888            .max()
889            .unwrap_or(24)
890            + 4) as u16;
891
892        let popup = Popup {
893            kind: PopupKind::List,
894            title: Some(title),
895            description: None,
896            transient: false,
897            content: PopupContent::List {
898                items,
899                selected: first_actionable,
900            },
901            position,
902            width: popup_width.clamp(28, 50),
903            max_height: 10,
904            bordered: true,
905            border_style: Style::default().fg(self.theme.read().unwrap().popup_border_fg),
906            background_style: Style::default().bg(self.theme.read().unwrap().popup_bg),
907            scroll_offset: 0,
908            text_selection: None,
909            accept_key_hint: None,
910            resolver: PopupResolver::RemoteIndicator,
911            // Explicitly invoked from the status-bar `{remote}` element,
912            // so this popup wants the keyboard immediately.
913            focused: true,
914            focus_key_hint: None,
915        };
916
917        let buffer_id = self.active_buffer();
918        if let Some(state) = self
919            .windows
920            .get_mut(&self.active_window)
921            .map(|w| &mut w.buffers)
922            .expect("active window present")
923            .get_mut(&buffer_id)
924        {
925            state.popups.show(popup);
926        }
927    }
928
929    /// Dispatch the action selected from the Remote Indicator popup.
930    ///
931    /// - `"detach"` — `clear_authority()` (falls back to local).
932    /// - `"clear_override"` — drop the Remote Indicator override
933    ///   without changing the authority. Used by the FailedAttach
934    ///   "Reopen Locally" row: nothing to detach (no authority was
935    ///   ever installed), but the FailedAttach indicator should
936    ///   clear.
937    /// - `"plugin:<name>"` — forwards to `Action::PluginAction(name)`.
938    /// - `"cancel_popup"` — no-op; the popup framework already
939    ///   closed the popup when the row was confirmed.
940    /// - anything else — logged and ignored.
941    pub fn handle_remote_indicator_action(&mut self, action_key: &str) {
942        if action_key == "detach" {
943            self.remote_indicator_override = None;
944            self.clear_authority();
945            return;
946        }
947        if action_key == "clear_override" {
948            self.remote_indicator_override = None;
949            return;
950        }
951        if action_key == "cancel_popup" {
952            return;
953        }
954        if let Some(plugin_action) = action_key.strip_prefix("plugin:") {
955            // `handle_action` wires this through the plugin manager; if
956            // the plugin isn't loaded it surfaces a status message, which
957            // is the correct no-op behavior for every plugin-command
958            // invocation site in the codebase. We still want to log an
959            // unexpected dispatch error — plugin misbehavior shouldn't
960            // leave the user staring at a silently-failed Retry click.
961            if let Err(e) = self.handle_action(crate::input::keybindings::Action::PluginAction(
962                plugin_action.to_string(),
963            )) {
964                tracing::warn!(
965                    "remote indicator popup: dispatching '{}' failed: {}",
966                    plugin_action,
967                    e
968                );
969            }
970            return;
971        }
972        tracing::warn!(
973            "handle_remote_indicator_action: unknown action key '{}'",
974            action_key
975        );
976    }
977
978    /// Show the trust prompt if this workspace is undecided and contains
979    /// content whose execution trust matters (env files, project manifests,
980    /// `.sln`/`.csproj`, …). No-op once a decision is recorded or when there's
981    /// nothing to gate. Called from every editor-startup path (in-process run
982    /// and the session server) so the prompt fires regardless of launch mode.
983    pub fn maybe_prompt_workspace_trust(&mut self) {
984        // Phase 1 of the trust+env+devcontainer UX plan (see
985        // `docs/internal/trust-env-devcontainer-ux-plan.md`): when the
986        // workspace is undecided AND has executable content, two paths:
987        //
988        // - The folder has env-shell markers (`.envrc`/`mise.toml`/
989        //   `.tool-versions`) — start as Restricted and let the
990        //   env-manager plugin's combined "Trust this folder and
991        //   activate?" popup do the asking, because that prompt is the
992        //   most concrete framing of the decision (it names the
993        //   specific env). The user's "Trust & activate" choice
994        //   dispatches `workspace_trust_trust`, which records the
995        //   decision and raises the level to Trusted.
996        //
997        // - Any other executable content (project manifests, devcontainer-
998        //   only, .NET solution/project files, …) — fire the core trust
999        //   modal here, with concrete framing: the popup names the
1000        //   *specific* markers that triggered it (Cargo.toml, build.rs,
1001        //   App.sln, devcontainer.json…) rather than the abstract
1002        //   "this project can run code on your machine." Start as
1003        //   Restricted while waiting for the user to choose.
1004        //
1005        // A decision the user explicitly recorded is always honored — this
1006        // branch only fires for undecided projects.
1007        let store = crate::services::workspace_trust::TrustStore::for_project_dir(
1008            &self.dir_context.project_state_dir(self.working_dir()),
1009        );
1010        if store.is_decided() {
1011            return; // respect a decision the user already recorded
1012        }
1013
1014        let markers =
1015            crate::services::workspace_trust::executable_content_markers(self.working_dir());
1016
1017        // Categorize the markers so we can route to the right surface.
1018        // Path-only envs (`.venv`, `venv`) are not modeled as a
1019        // "would-run-shell" question — activation is a `PATH` prepend,
1020        // not arbitrary user shell. The North Star treats them as
1021        // "Cheap actions don't ask": auto-activate silently, undo via
1022        // status pill. So path-only env *alone* doesn't trigger any
1023        // prompt; we hand the workspace to env-manager as Trusted.
1024        let path_only_env_markers = [".venv", "venv"];
1025        let env_shell_markers = [".envrc", "mise.toml", ".mise.toml", ".tool-versions"];
1026        let only_path_only_env = !markers.is_empty()
1027            && markers
1028                .iter()
1029                .all(|m| path_only_env_markers.contains(&m.as_str()));
1030        let has_env_shell = markers
1031            .iter()
1032            .any(|m| env_shell_markers.contains(&m.as_str()));
1033
1034        if markers.is_empty() || only_path_only_env {
1035            // Nothing genuinely needs gating. Default to Trusted so the
1036            // restricted chip doesn't appear, env-manager auto-activates
1037            // any path-only env silently, and the user isn't blocked on
1038            // a question that has no real downside. Persist this — it's
1039            // the same decision we'd record if the user had explicitly
1040            // confirmed.
1041            self.authority()
1042                .workspace_trust
1043                .set_level(crate::services::workspace_trust::TrustLevel::Trusted);
1044            return;
1045        }
1046
1047        // For env-shell and other executable content, seed Restricted
1048        // *in memory only* — `set_level_transient` does not write to
1049        // disk. The on-disk store stays undecided until the user picks
1050        // a concrete option in the surfaced prompt. That preserves the
1051        // contract: cancelling (quit) leaves the project undecided so
1052        // the prompt fires again next time, while any deliberate
1053        // choice (env-manager's "Trust & activate" / "Never here" or
1054        // the core modal's three radios) writes the decision through
1055        // via `set_level`.
1056        self.authority()
1057            .workspace_trust
1058            .set_level_transient(crate::services::workspace_trust::TrustLevel::Restricted);
1059
1060        if !has_env_shell {
1061            // Non-cancellable on open: the choice has to be made, but
1062            // any concrete option resolves it. (`Esc` is inert on the
1063            // forced-choice variant; user must pick a row.)
1064            self.show_workspace_trust_popup(false);
1065        }
1066    }
1067
1068    /// Show the workspace-trust prompt: a centered list asking how this
1069    /// project's tooling should be treated. Surfaced on opening an
1070    /// untrusted project that contains executable content (env files,
1071    /// `.csproj`/`.sln`, …). The default-focused choice is the safe
1072    /// "Restricted" — dismissing with Escape leaves the project undecided
1073    /// (and re-asks next open), while selecting any row records the
1074    /// decision so the prompt stops appearing.
1075    pub fn show_workspace_trust_popup(&mut self, cancellable: bool) {
1076        use crate::view::popup::{Popup, PopupContent, PopupKind, PopupResolver};
1077        use ratatui::style::Style;
1078
1079        self.workspace_trust_prompt_cancellable = cancellable;
1080        self.workspace_trust_scroll = 0;
1081        self.workspace_trust_markers =
1082            crate::services::workspace_trust::executable_content_markers(self.working_dir());
1083
1084        // Don't stack a second copy if one is already up. The prompt lives on
1085        // the editor-level (global) stack so it renders regardless of which
1086        // buffer is active — opening a directory makes the file-explorer /
1087        // dashboard the active buffer, which would orphan a buffer-scoped
1088        // popup and leave it unrendered.
1089        if self
1090            .global_popups
1091            .top()
1092            .is_some_and(|p| matches!(p.resolver, PopupResolver::WorkspaceTrust))
1093        {
1094            return;
1095        }
1096
1097        // Seed the radio selection from the project's current level so a
1098        // command-palette invocation shows the active choice; at startup
1099        // (undecided) this is the safe Restricted default.
1100        let selected = match self.authority().workspace_trust.level() {
1101            crate::services::workspace_trust::TrustLevel::Trusted => 0,
1102            crate::services::workspace_trust::TrustLevel::Restricted => 1,
1103            crate::services::workspace_trust::TrustLevel::Blocked => 2,
1104        };
1105
1106        let items = vec![
1107            crate::view::popup::PopupListItem::new("Trust this folder".to_string())
1108                .with_detail("Allow project tooling (LSP, env managers, tasks) to run".to_string())
1109                .with_data("trusted".to_string()),
1110            crate::view::popup::PopupListItem::new("Keep restricted (default)".to_string())
1111                .with_detail("Don't run repo-controlled code; system tools still run".to_string())
1112                .with_data("restricted".to_string()),
1113            crate::view::popup::PopupListItem::new("Block all execution".to_string())
1114                .with_detail("No processes run at all in this workspace".to_string())
1115                .with_data("blocked".to_string()),
1116        ];
1117
1118        let popup_width = (items
1119            .iter()
1120            .map(|i| {
1121                let detail_w = i
1122                    .detail
1123                    .as_deref()
1124                    .map(unicode_width::UnicodeWidthStr::width)
1125                    .unwrap_or(0);
1126                unicode_width::UnicodeWidthStr::width(i.text.as_str()).max(detail_w)
1127            })
1128            .max()
1129            .unwrap_or(40)
1130            + 4) as u16;
1131
1132        let popup = Popup {
1133            kind: PopupKind::List,
1134            title: Some("This project can run code on your machine. Trust it?".to_string()),
1135            description: None,
1136            transient: false,
1137            content: PopupContent::List { items, selected },
1138            position: crate::view::popup::PopupPosition::Centered,
1139            width: popup_width.clamp(40, 70),
1140            max_height: 10,
1141            bordered: true,
1142            border_style: Style::default().fg(self.theme.read().unwrap().popup_border_fg),
1143            background_style: Style::default().bg(self.theme.read().unwrap().popup_bg),
1144            scroll_offset: 0,
1145            text_selection: None,
1146            accept_key_hint: None,
1147            resolver: PopupResolver::WorkspaceTrust,
1148            focused: true,
1149            focus_key_hint: None,
1150        };
1151
1152        self.global_popups.show(popup);
1153    }
1154
1155    /// Dispatch the choice selected from the workspace-trust prompt.
1156    /// `"trusted"` / `"restricted"` / `"blocked"` set the level (persisted);
1157    /// the new policy applies live to the next authority-routed spawn, scoped
1158    /// to this session's window — no editor restart. Anything else is logged
1159    /// and ignored.
1160    pub fn handle_workspace_trust_action(&mut self, action_key: &str) {
1161        use crate::services::workspace_trust::TrustLevel;
1162        let level = match action_key {
1163            "trusted" => TrustLevel::Trusted,
1164            "restricted" => TrustLevel::Restricted,
1165            "blocked" => TrustLevel::Blocked,
1166            other => {
1167                tracing::warn!("handle_workspace_trust_action: unknown action key '{other}'");
1168                return;
1169            }
1170        };
1171        self.set_workspace_trust_level(level);
1172    }
1173
1174    /// Keyboard handling for the workspace-trust modal. Returns `Some(Consumed)`
1175    /// for every key (the modal swallows everything): arrows and the mnemonics
1176    /// `T`/`K`/`B` move the radio selection (two-step — they don't confirm),
1177    /// `Enter`/`O` confirm the current selection, the user's global quit key
1178    /// quits the editor, and `Esc` is inert.
1179    pub(crate) fn handle_workspace_trust_key(
1180        &mut self,
1181        event: &crossterm::event::KeyEvent,
1182    ) -> Option<crate::input::handler::InputResult> {
1183        use crate::input::handler::InputResult;
1184        use crate::input::keybindings::{Action, KeyContext};
1185        use crossterm::event::KeyCode;
1186
1187        let cancellable = self.workspace_trust_prompt_cancellable;
1188
1189        // The mandatory open-time gate (not cancellable) binds its secondary
1190        // action to the user's global quit key (default Ctrl+Q) and quits the
1191        // editor. A voluntarily-opened prompt (cancellable) does not — Escape
1192        // cancels it instead.
1193        if !cancellable {
1194            let resolved = self
1195                .keybindings
1196                .read()
1197                .ok()
1198                .map(|kb| kb.resolve(event, KeyContext::Normal));
1199            if matches!(resolved, Some(Action::Quit) | Some(Action::ForceQuit)) {
1200                self.hide_popup();
1201                self.should_quit = true;
1202                return Some(InputResult::Consumed);
1203            }
1204        }
1205
1206        match event.code {
1207            KeyCode::Up => self.move_workspace_trust_selection(-1),
1208            KeyCode::Down => self.move_workspace_trust_selection(1),
1209            KeyCode::Char('t') | KeyCode::Char('T') => self.set_workspace_trust_selection(0),
1210            KeyCode::Char('k') | KeyCode::Char('K') => self.set_workspace_trust_selection(1),
1211            KeyCode::Char('b') | KeyCode::Char('B') => self.set_workspace_trust_selection(2),
1212            KeyCode::Enter | KeyCode::Char('o') | KeyCode::Char('O') => {
1213                self.confirm_workspace_trust(self.current_workspace_trust_selection());
1214            }
1215            // Escape cancels a voluntarily-opened prompt; on the mandatory gate
1216            // it (and every other key) is inert but still consumed (modal).
1217            KeyCode::Esc if cancellable => self.hide_popup(),
1218            _ => {}
1219        }
1220        Some(InputResult::Consumed)
1221    }
1222
1223    /// Set the radio selection to an absolute index (0=Trust, 1=Restricted,
1224    /// 2=Block) without confirming.
1225    fn set_workspace_trust_selection(&mut self, index: usize) {
1226        if let Some(popup) = self.global_popups.top_mut() {
1227            if let crate::view::popup::PopupContent::List { selected, .. } = &mut popup.content {
1228                *selected = index.min(2);
1229            }
1230        }
1231    }
1232
1233    /// The currently-highlighted radio index (0=Trust, 1=Restricted, 2=Block).
1234    pub(crate) fn current_workspace_trust_selection(&self) -> usize {
1235        self.global_popups
1236            .top()
1237            .and_then(|p| match &p.content {
1238                crate::view::popup::PopupContent::List { selected, .. } => Some(*selected),
1239                _ => None,
1240            })
1241            .unwrap_or(1)
1242    }
1243
1244    /// Move the radio selection by `delta`, wrapping across the three options.
1245    fn move_workspace_trust_selection(&mut self, delta: i32) {
1246        if let Some(popup) = self.global_popups.top_mut() {
1247            if let crate::view::popup::PopupContent::List { selected, .. } = &mut popup.content {
1248                *selected = (((*selected as i32) + delta).rem_euclid(3)) as usize;
1249            }
1250        }
1251    }
1252
1253    /// Record the trust decision for radio `index` and dismiss the modal.
1254    pub(crate) fn confirm_workspace_trust(&mut self, index: usize) {
1255        let key = match index {
1256            0 => "trusted",
1257            2 => "blocked",
1258            _ => "restricted",
1259        };
1260        self.hide_popup();
1261        self.handle_workspace_trust_action(key);
1262    }
1263
1264    /// Probe for a `devcontainer.json` under the current working
1265    /// directory. Mirrors the first two priorities of the devcontainer
1266    /// plugin's `findConfig()` so the Remote Indicator menu can decide
1267    /// whether to offer "Reopen in Container" without actually having to
1268    /// call into the plugin.
1269    ///
1270    /// Routes through `authority.filesystem` per `CONTRIBUTING.md`
1271    /// guideline 4, so an SSH-rooted workspace probes the remote host
1272    /// rather than the local one.
1273    fn find_devcontainer_config(&self) -> Option<std::path::PathBuf> {
1274        let cwd = self.working_dir();
1275        let fs = self.authority().filesystem.as_ref();
1276        let primary = cwd.join(".devcontainer").join("devcontainer.json");
1277        if fs.exists(&primary) {
1278            return Some(primary);
1279        }
1280        let secondary = cwd.join(".devcontainer.json");
1281        if fs.exists(&secondary) {
1282            return Some(secondary);
1283        }
1284        None
1285    }
1286
1287    /// Show a transient hover popup with the given message text, positioned below the cursor.
1288    /// Used for file-open messages (e.g. `file.txt:10@"Look at this"`).
1289    pub fn show_file_message_popup(&mut self, message: &str) {
1290        use crate::view::popup::{Popup, PopupPosition};
1291        use ratatui::style::Style;
1292
1293        // Build markdown: message text + blank line + italic hint
1294        let md = format!("{}\n\n*esc to dismiss*", message);
1295        // Size popup width to content: longest line + border padding, clamped to reasonable bounds
1296        let content_width = message.lines().map(|l| l.len()).max().unwrap_or(0) as u16;
1297        let hint_width = 16u16; // "*esc to dismiss*"
1298        let popup_width = (content_width.max(hint_width) + 4).clamp(20, 60);
1299
1300        let mut popup = Popup::markdown(
1301            &md,
1302            &*self.theme.read().unwrap(),
1303            Some(&self.grammar_registry),
1304        );
1305        popup.transient = false;
1306        popup.position = PopupPosition::BelowCursor;
1307        popup.width = popup_width;
1308        popup.max_height = 15;
1309        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1310        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1311
1312        let buffer_id = self.active_buffer();
1313        if let Some(state) = self
1314            .windows
1315            .get_mut(&self.active_window)
1316            .map(|w| &mut w.buffers)
1317            .expect("active window present")
1318            .get_mut(&buffer_id)
1319        {
1320            state.popups.show(popup);
1321        }
1322    }
1323
1324    /// Get text properties at the cursor position in the active buffer
1325    pub fn get_text_properties_at_cursor(
1326        &self,
1327    ) -> Option<Vec<&crate::primitives::text_property::TextProperty>> {
1328        let state = self
1329            .windows
1330            .get(&self.active_window)
1331            .map(|w| &w.buffers)
1332            .expect("active window present")
1333            .get(&self.active_buffer())?;
1334        let cursor_pos = self.active_cursors().primary().position;
1335        Some(state.text_properties.get_at(cursor_pos))
1336    }
1337}