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 LSP status popup
6//! is the largest; it is split into `collect_lsp_status_servers` (gather
7//! state), `push_lsp_server_rows` / `push_lsp_footer_rows` (build the list),
8//! and `present_lsp_status_popup` (pin width + show), orchestrated by
9//! `build_and_show_lsp_status_popup`.
10
11use rust_i18n::t;
12
13use crate::app::warning_domains::WarningDomain;
14
15use super::Editor;
16
17/// True when `popup` is the LSP status popup (as built by
18/// `build_and_show_lsp_status_popup`). Used by the auto-prompt
19/// drain to find and clean up orphan prompts on non-active
20/// buffers without affecting unrelated popups (completion, hover,
21/// etc.) that might be on top.
22fn is_lsp_status_popup(popup: &crate::view::popup::Popup) -> bool {
23    matches!(popup.resolver, crate::view::popup::PopupResolver::LspStatus)
24}
25
26/// Hard-wrap `text` to `width` display columns, breaking words that are longer
27/// than `width` (e.g. a long, space-less config-file path) so they never
28/// overflow a popup's border. Whitespace-separated where possible.
29fn hard_wrap(text: &str, width: usize) -> Vec<String> {
30    use unicode_width::UnicodeWidthChar;
31
32    if width == 0 {
33        return vec![text.to_string()];
34    }
35    let ch_width = |c: char| UnicodeWidthChar::width(c).unwrap_or(1);
36
37    let mut lines = Vec::new();
38    let mut cur = String::new();
39    let mut cur_w = 0usize;
40
41    let mut push_word =
42        |word: &str, lines: &mut Vec<String>, cur: &mut String, cur_w: &mut usize| {
43            for c in word.chars() {
44                let w = ch_width(c);
45                if *cur_w + w > width && !cur.is_empty() {
46                    lines.push(std::mem::take(cur));
47                    *cur_w = 0;
48                }
49                cur.push(c);
50                *cur_w += w;
51            }
52        };
53
54    for word in text.split(' ') {
55        let word_w: usize = word.chars().map(ch_width).sum();
56        if cur.is_empty() {
57            push_word(word, &mut lines, &mut cur, &mut cur_w);
58        } else if cur_w + 1 + word_w <= width {
59            cur.push(' ');
60            cur_w += 1;
61            cur.push_str(word);
62            cur_w += word_w;
63        } else {
64            lines.push(std::mem::take(&mut cur));
65            cur_w = 0;
66            push_word(word, &mut lines, &mut cur, &mut cur_w);
67        }
68    }
69    if !cur.is_empty() {
70        lines.push(cur);
71    }
72    if lines.is_empty() {
73        lines.push(String::new());
74    }
75    lines
76}
77
78/// Max display cells for each variable field (title / message) of the LSP
79/// progress line. Used to pin the popup width so it doesn't jitter as live
80/// progress messages come and go.
81const LSP_PROGRESS_FIELD_MAX: usize = 14;
82/// Hard cap on the LSP-status popup width.
83const LSP_POPUP_WIDTH_MAX: u16 = 50;
84/// Worst-case width of the runtime-varying progress line, used when pinning
85/// the popup width:
86///   "    ⏳ " (4-space indent + ⏳ (2 cells) + space = 7 cells)
87///   + field (title) + " · " (3) + field (message) + " (100%)" (7)
88const LSP_PROGRESS_LINE_MAX: usize = 7 + LSP_PROGRESS_FIELD_MAX + 3 + LSP_PROGRESS_FIELD_MAX + 7;
89
90/// Truncate `s` to at most `max_cells` display cells, appending an ellipsis
91/// if truncation happened (the ellipsis is included in the budget, so the
92/// result is ≤ `max_cells` wide regardless of input).
93fn truncate_to_cells(s: &str, max_cells: usize) -> String {
94    use unicode_width::UnicodeWidthChar;
95    let w = unicode_width::UnicodeWidthStr::width(s);
96    if w <= max_cells {
97        return s.to_string();
98    }
99    let budget = max_cells.saturating_sub(1);
100    let mut used = 0;
101    let mut out = String::new();
102    for ch in s.chars() {
103        let cw = ch.width().unwrap_or(0);
104        if used + cw > budget {
105            break;
106        }
107        used += cw;
108        out.push(ch);
109    }
110    out.push('…');
111    out
112}
113
114/// A language's configured + running LSP servers, gathered once up-front so
115/// row-building doesn't have to re-query `self` for every server. Built by
116/// [`Editor::collect_lsp_status_servers`].
117struct LspStatusServers {
118    /// All server display-names for the language (configured ∪ running), sorted.
119    names: Vec<String>,
120    /// display-name → live runtime status, for servers that are running.
121    running: std::collections::HashMap<String, crate::services::async_bridge::LspServerStatus>,
122    /// display-name → binary-missing. Only meaningful when not running.
123    missing: std::collections::HashMap<String, bool>,
124    /// display-name → configured `auto_start`.
125    auto_start: std::collections::HashMap<String, bool>,
126    /// The user dismissed this language for the session.
127    user_dismissed: bool,
128    /// At least one configured server has `enabled = true`.
129    any_enabled: bool,
130}
131
132impl Editor {
133    /// Show warnings by opening the warning log file directly
134    ///
135    /// If there are no warnings, shows a brief status message.
136    /// Otherwise, opens the warning log file for the user to view.
137    pub fn show_warnings_popup(&mut self) {
138        if !self.active_window_mut().warning_domains.has_any_warnings() {
139            self.active_window_mut().status_message = Some(t!("warnings.none").to_string());
140            return;
141        }
142
143        // Open the warning log file directly
144        self.open_warning_log();
145    }
146
147    /// Show LSP status popup with details about servers active for the current buffer.
148    /// Lists each server with its status and provides actions: restart, stop, view log.
149    ///
150    /// User-initiated (status-bar click, `lsp_status` action). The popup
151    /// grabs focus on show because the user explicitly asked for it,
152    /// matching the historical click-to-pick-action affordance.
153    pub fn show_lsp_status_popup(&mut self) {
154        // Toggle behavior: if the LSP popup is already showing, close it
155        // instead of rebuilding and re-showing it.  This lets clicking the
156        // status-bar LSP indicator a second time dismiss the popup, matching
157        // the common affordance for status-bar menus.
158        if self
159            .active_state()
160            .popups
161            .top()
162            .is_some_and(is_lsp_status_popup)
163        {
164            self.hide_popup();
165            return;
166        }
167
168        let has_error =
169            self.active_window_mut().warning_domains.lsp.level() == crate::app::WarningLevel::Error;
170        let language = self
171            .buffers()
172            .get(&self.active_buffer())
173            .map(|s| s.language.clone())
174            .unwrap_or_else(|| "unknown".to_string());
175
176        // Compute the set of configured servers whose binaries are not
177        // resolvable — plugins and the popup itself both need this to
178        // decide between "offer to start" and "offer install help".
179        // Probe missing binaries through the active authority. When the
180        // LspManager isn't wired (tests or very early boot), fall
181        // back to the synchronous host-side `which` probe — same path
182        // `command_exists_via_authority` would take after the
183        // long-running spawner bootstrap completes.
184        let missing_servers: Vec<String> = self
185            .config
186            .lsp
187            .get(&language)
188            .map(|cfg| {
189                cfg.as_slice()
190                    .iter()
191                    .filter(|c| c.enabled && !c.command.is_empty())
192                    .filter(|c| match self.lsp() {
193                        Some(mgr) => !mgr.command_exists_via_authority(&c.command),
194                        None => !crate::services::lsp::command_exists(&c.command),
195                    })
196                    .map(|c| c.command.clone())
197                    .collect()
198            })
199            .unwrap_or_default();
200        let user_dismissed = self
201            .active_window()
202            .is_lsp_language_user_dismissed(&language);
203
204        // Fire the LspStatusClicked hook for plugins. A plugin's
205        // handler may itself push a popup (e.g. the embedded
206        // rust-lsp.ts plugin shows install instructions when its
207        // `rustLspError` is set).
208        self.plugin_manager.read().unwrap().run_hook(
209            "lsp_status_clicked",
210            crate::services::plugins::hooks::HookArgs::LspStatusClicked {
211                language: language.clone(),
212                has_error,
213                missing_servers,
214                user_dismissed,
215            },
216        );
217
218        // If something is already on the popup stack at this point
219        // — either pushed by the hook above (the common case: a
220        // plugin's `editor.showActionPopup` in response to
221        // `lsp_status_clicked`) or already showing when the user
222        // clicked the indicator — don't stack the built-in LSP
223        // Servers popup on top. The hook's popup is the more
224        // contextual answer to the click; layering two popups for
225        // one gesture is the user-reported "I had several kinds of
226        // popups" bug.
227        if self.active_state().popups.top().is_some() {
228            return;
229        }
230
231        self.build_and_show_lsp_status_popup(&language, true);
232    }
233
234    /// Rebuild the LSP-status popup in place if it's currently open.
235    ///
236    /// Used when an async event (progress update, server state change) might
237    /// change the popup's contents — notably while rust-analyzer is indexing
238    /// and emits `$/progress` every few hundred ms.  Without this, the popup
239    /// would freeze on the snapshot taken at open time while the status-bar
240    /// spinner keeps moving, making them look disconnected.
241    pub fn refresh_lsp_status_popup_if_open(&mut self) {
242        // Only rebuild if the active buffer's top popup IS an LSP
243        // status popup — otherwise we'd spuriously build one on top of
244        // unrelated state.
245        if !self
246            .active_state()
247            .popups
248            .top()
249            .is_some_and(is_lsp_status_popup)
250        {
251            return;
252        }
253        let language = self
254            .buffers()
255            .get(&self.active_buffer())
256            .map(|s| s.language.clone())
257            .unwrap_or_else(|| "unknown".to_string());
258        // Replace contents: hide then rebuild. Refresh is triggered by
259        // async progress updates while the popup is already on screen,
260        // so we keep its existing focused state — flipping it back to
261        // unfocused on every progress tick would yank focus away from
262        // a user mid-interaction.
263        let was_focused = self
264            .active_state()
265            .popups
266            .top()
267            .map(|p| p.focused)
268            .unwrap_or(true);
269        self.hide_popup();
270        self.build_and_show_lsp_status_popup(&language, was_focused);
271    }
272
273    /// Build and show the LSP status popup for `language`. Orchestrates three
274    /// cohesive steps: gather the configured/running servers, build the list
275    /// rows, then pin + present the popup.
276    fn build_and_show_lsp_status_popup(&mut self, language: &str, focused: bool) {
277        let servers = self.collect_lsp_status_servers(language);
278        if servers.names.is_empty() {
279            self.active_window_mut().status_message = Some(t!("lsp.no_server_active").to_string());
280            return;
281        }
282
283        // Build the popup's items as view-level `PopupListItem`s directly. We
284        // bypass the `PopupListItemData` event type because we need the
285        // `disabled` field (a view-only concern). Each item carries its own
286        // action key in `data`, and the `LspStatus` resolver tells confirm how
287        // to interpret it, so no separate action-key table is needed.
288        let mut items: Vec<crate::view::popup::PopupListItem> = Vec::new();
289        self.push_lsp_server_rows(language, &servers, &mut items);
290        self.push_lsp_footer_rows(language, &servers, &mut items);
291        self.present_lsp_status_popup(language, items, focused);
292    }
293
294    /// Gather the configured + running LSP servers for `language`, merged with
295    /// their runtime status, binary availability, and auto-start config.
296    fn collect_lsp_status_servers(&self, language: &str) -> LspStatusServers {
297        use crate::services::async_bridge::LspServerStatus;
298
299        let running: std::collections::HashMap<String, LspServerStatus> = self
300            .active_window()
301            .lsp_server_statuses
302            .iter()
303            .filter(|((lang, _), _)| lang == language)
304            .map(|((_, name), status)| (name.clone(), *status))
305            .collect();
306
307        let configured_servers: Vec<String> = self
308            .config
309            .lsp
310            .get(language)
311            .map(|cfg| {
312                cfg.as_slice()
313                    .iter()
314                    .filter(|c| !c.command.is_empty())
315                    .map(|c| c.display_name())
316                    .collect()
317            })
318            .unwrap_or_default();
319
320        // Per-server binary availability map (display_name → missing).
321        // `command_exists` is cached, so repeated popup opens or a
322        // refresh-while-open are cheap. We look up by display name because
323        // `names` below is built from display names; `display_name()` falls
324        // back to the command basename when no explicit `name` is set.
325        let missing: std::collections::HashMap<String, bool> = self
326            .config
327            .lsp
328            .get(language)
329            .map(|cfg| {
330                cfg.as_slice()
331                    .iter()
332                    .filter(|c| !c.command.is_empty())
333                    .map(|c| {
334                        let missing = match self.lsp() {
335                            Some(mgr) => !mgr.command_exists_via_authority(&c.command),
336                            None => !crate::services::lsp::command_exists(&c.command),
337                        };
338                        (c.display_name(), missing)
339                    })
340                    .collect()
341            })
342            .unwrap_or_default();
343
344        // Per-server auto_start flag map — used to decide whether to offer an
345        // "Start X (always)" row alongside the plain "Start X".
346        let auto_start: std::collections::HashMap<String, bool> = self
347            .config
348            .lsp
349            .get(language)
350            .map(|cfg| {
351                cfg.as_slice()
352                    .iter()
353                    .filter(|c| !c.command.is_empty())
354                    .map(|c| (c.display_name(), c.auto_start))
355                    .collect()
356            })
357            .unwrap_or_default();
358
359        let user_dismissed = self
360            .active_window()
361            .is_lsp_language_user_dismissed(language);
362
363        let any_enabled = self
364            .config
365            .lsp
366            .get(language)
367            .is_some_and(|cfg| cfg.as_slice().iter().any(|c| c.enabled));
368
369        // Merge: start with configured servers, then add any running servers
370        // not in the config (shouldn't happen, but be safe).
371        let mut names = configured_servers;
372        for name in running.keys() {
373            if !names.contains(name) {
374                names.push(name.clone());
375            }
376        }
377        names.sort();
378
379        LspStatusServers {
380            names,
381            running,
382            missing,
383            auto_start,
384            user_dismissed,
385            any_enabled,
386        }
387    }
388
389    /// Push one block of rows per server — a status header, an optional live
390    /// progress line, and the per-server action rows (restart/stop, install
391    /// advisory, or start) — into `items`.
392    fn push_lsp_server_rows(
393        &self,
394        language: &str,
395        servers: &LspStatusServers,
396        items: &mut Vec<crate::view::popup::PopupListItem>,
397    ) {
398        use crate::services::async_bridge::LspServerStatus;
399
400        // The "not installed" copy says where it actually isn't: in the
401        // container for container authorities, on the host otherwise.
402        let authority_is_container = self.authority().display_label.starts_with("Container:");
403        let missing_label = if authority_is_container {
404            "not installed in container"
405        } else {
406            "binary not in PATH"
407        };
408
409        for name in &servers.names {
410            let status = servers.running.get(name).copied();
411            let is_active = status
412                .map(|s| !matches!(s, LspServerStatus::Shutdown))
413                .unwrap_or(false);
414            // A server is "missing" only when it's NOT currently running (an
415            // absolute-path binary could have been removed mid-session, but
416            // the live server is still talking to us).
417            let binary_missing = !is_active && servers.missing.get(name).copied().unwrap_or(false);
418
419            // Header: server name + status (no data → not clickable). Swap the
420            // "not running" label for an actionable "binary not found" when a
421            // start attempt would clearly fail.
422            let (icon, label) = match status {
423                Some(LspServerStatus::Running) => ("●", "ready"),
424                Some(LspServerStatus::Error) => ("✗", "error"),
425                Some(LspServerStatus::Starting) => ("◌", "starting"),
426                Some(LspServerStatus::Initializing) => ("◌", "initializing"),
427                Some(LspServerStatus::Shutdown) | None => {
428                    if binary_missing {
429                        ("○", missing_label)
430                    } else {
431                        ("○", "not running")
432                    }
433                }
434            };
435            items.push(crate::view::popup::PopupListItem::new(format!(
436                "{icon} {name} ({label})"
437            )));
438
439            // Progress row immediately UNDER the server's name row, if there's
440            // an active `$/progress` notification for this language. Fields are
441            // individually truncated so a runaway progress path can't stretch
442            // the popup (the width is pinned in advance).
443            if let Some(info) = self
444                .active_window()
445                .lsp_progress
446                .values()
447                .find(|info| info.language == language)
448            {
449                let mut line = format!(
450                    "    ⏳ {}",
451                    truncate_to_cells(&info.title, LSP_PROGRESS_FIELD_MAX)
452                );
453                if let Some(ref msg) = info.message {
454                    line.push_str(&format!(
455                        " · {}",
456                        truncate_to_cells(msg, LSP_PROGRESS_FIELD_MAX)
457                    ));
458                }
459                if let Some(pct) = info.percentage {
460                    line.push_str(&format!(" ({pct}%)"));
461                }
462                items.push(crate::view::popup::PopupListItem::new(line));
463            }
464
465            if is_active {
466                items.push(
467                    crate::view::popup::PopupListItem::new(format!("    Restart {name}"))
468                        .with_data(format!("restart:{language}/{name}")),
469                );
470                items.push(
471                    crate::view::popup::PopupListItem::new(format!("    Stop {name}"))
472                        .with_data(format!("stop:{language}/{name}")),
473                );
474            } else if binary_missing {
475                // A disabled advisory row instead of an actionable "Start" —
476                // clicking Start here would spawn, fail, and noise up the
477                // status area. Copy shifts with the authority so the user is
478                // pointed at the right install surface.
479                let advisory = if authority_is_container {
480                    format!("    Install {name} in container (postCreateCommand)")
481                } else {
482                    format!("    Install {name} to enable")
483                };
484                items.push(crate::view::popup::PopupListItem::new(advisory).disabled());
485            } else {
486                // Two sibling rows for a dormant server, ordered by what the
487                // user most likely wants:
488                //   "Start <name> (always)" — persist auto_start=true AND start
489                //                              now. Listed first (the default)
490                //                              so Enter does the common thing.
491                //   "Start <name> once"     — start for this session only.
492                // The "once" suffix is only needed when the "(always)" sibling
493                // is present (i.e. auto_start is currently false).
494                let is_manual = !servers.auto_start.get(name).copied().unwrap_or(true);
495
496                if is_manual {
497                    items.push(
498                        crate::view::popup::PopupListItem::new(format!(
499                            "    Start {name} (always)"
500                        ))
501                        .with_data(format!("autostart:{language}/{name}")),
502                    );
503                }
504
505                let start_label = if is_manual {
506                    format!("    Start {name} once")
507                } else {
508                    format!("    Start {name}")
509                };
510                // All dormant servers for a language share the same `start:`
511                // key; only emit the row once.
512                let start_key = format!("start:{language}");
513                if !items
514                    .iter()
515                    .any(|i| i.data.as_deref() == Some(start_key.as_str()))
516                {
517                    items.push(
518                        crate::view::popup::PopupListItem::new(start_label).with_data(start_key),
519                    );
520                }
521            }
522        }
523    }
524
525    /// Push the language-level footer rows — enable/disable, view log, plugin
526    /// contributions, and the trailing dismiss row — into `items`.
527    fn push_lsp_footer_rows(
528        &self,
529        language: &str,
530        servers: &LspStatusServers,
531        items: &mut Vec<crate::view::popup::PopupListItem>,
532    ) {
533        // Disable / Enable row. The label flips on either the session-level
534        // dismiss flag OR a fully-`enabled = false` config: both mean "the
535        // language is currently muted", and showing "Disable" while every
536        // server is already disabled would leave no surface to undo it.
537        let muted = servers.user_dismissed || !servers.any_enabled;
538        if muted {
539            items.push(
540                crate::view::popup::PopupListItem::new(format!("    Enable LSP for {language}"))
541                    .with_data(format!("enable:{language}")),
542            );
543        } else {
544            items.push(
545                crate::view::popup::PopupListItem::new(format!("    Disable LSP for {language}"))
546                    .with_data(format!("dismiss:{language}")),
547            );
548        }
549
550        // View log action — grayed out and non-actionable when no log file
551        // exists yet for this language.
552        let log_path = crate::services::log_dirs::lsp_log_path(language);
553        let mut log_item = crate::view::popup::PopupListItem::new("    View Log".to_string());
554        if log_path.exists() {
555            log_item = log_item.with_data(format!("log:{language}"));
556        } else {
557            log_item = log_item.disabled();
558        }
559        items.push(log_item);
560
561        // Plugin-contributed rows — injected as an extra "Plugin actions"
562        // section. Sorted by plugin_id for stable ordering; a single header
563        // labels the section so the user can tell these rows come from a
564        // plugin (vs. built-in actions like Stop/Restart).
565        let mut contributed: Vec<(&String, &Vec<crate::app::LspMenuItem>)> = self
566            .active_window()
567            .lsp_menu_contributions
568            .iter()
569            .filter_map(|((lang, plugin_id), plugin_items)| {
570                if lang == language && !plugin_items.is_empty() {
571                    Some((plugin_id, plugin_items))
572                } else {
573                    None
574                }
575            })
576            .collect();
577        contributed.sort_by(|a, b| a.0.cmp(b.0));
578        if !contributed.is_empty() {
579            items.push(crate::view::popup::PopupListItem::new(
580                "  ─ Plugin actions ─".to_string(),
581            ));
582            for (plugin_id, plugin_items) in contributed {
583                for it in plugin_items {
584                    items.push(
585                        crate::view::popup::PopupListItem::new(format!("    {}", it.label))
586                            .with_data(format!("plugin:{}|{}", plugin_id, it.id)),
587                    );
588                }
589            }
590        }
591
592        // Trailing Dismiss row — an on-screen way out for users who don't know
593        // Esc works. The key label comes from the keybinding resolver so a
594        // rebound PopupCancel stays visible ("Dismiss (Q)", etc.), falling back
595        // to "Esc".
596        let cancel_binding = self
597            .keybindings
598            .read()
599            .ok()
600            .and_then(|kb| {
601                kb.get_keybinding_for_action(
602                    &crate::input::keybindings::Action::PopupCancel,
603                    crate::input::keybindings::KeyContext::Popup,
604                )
605            })
606            .unwrap_or_else(|| "Esc".to_string());
607        items.push(
608            crate::view::popup::PopupListItem::new(format!("    Dismiss ({cancel_binding})"))
609                .with_data("cancel_popup".to_string()),
610        );
611    }
612
613    /// Pin the popup width (using worst-case widths so it doesn't jitter),
614    /// choose the anchor + initial selection, and show the assembled `items`
615    /// as the LSP-status list popup on the active buffer.
616    fn present_lsp_status_popup(
617        &mut self,
618        language: &str,
619        items: Vec<crate::view::popup::PopupListItem>,
620        focused: bool,
621    ) {
622        use crate::view::popup::{Popup, PopupContent, PopupKind, PopupResolver};
623        use ratatui::style::Style;
624
625        let max_static_item_width = items
626            .iter()
627            .map(|i| unicode_width::UnicodeWidthStr::width(i.text.as_str()))
628            .max()
629            .unwrap_or(20);
630        let popup_width = (max_static_item_width.max(LSP_PROGRESS_LINE_MAX) as u16 + 4)
631            .clamp(30, LSP_POPUP_WIDTH_MAX);
632
633        // Pre-select the first actionable item (skip header items with no data
634        // and disabled items like a non-existent View Log).
635        let first_actionable = items
636            .iter()
637            .position(|i| i.data.is_some() && !i.disabled)
638            .unwrap_or(0);
639
640        // Left-align the popup's column with the LSP indicator on the status
641        // bar, if we know where it was drawn in the last frame. Falls back to
642        // the BottomRight anchor when the LSP segment isn't visible.
643        let position = self
644            .active_chrome()
645            .status_bar
646            .clickable_area(crate::view::ui::status_bar::StatusBarClickable::Lsp)
647            .map(
648                |(status_row, col_start, _)| crate::view::popup::PopupPosition::AboveStatusBarAt {
649                    x: col_start,
650                    status_row,
651                },
652            )
653            .unwrap_or(crate::view::popup::PopupPosition::BottomRight);
654
655        let focus_hint = if !focused {
656            self.popup_focus_key_hint()
657        } else {
658            None
659        };
660        let popup = Popup {
661            kind: PopupKind::List,
662            title: Some(format!("LSP Servers ({language})")),
663            description: None,
664            transient: false,
665            content: PopupContent::List {
666                items,
667                selected: first_actionable,
668            },
669            position,
670            width: popup_width,
671            max_height: 15,
672            bordered: true,
673            border_style: Style::default().fg(self.theme.read().unwrap().popup_border_fg),
674            background_style: Style::default().bg(self.theme.read().unwrap().popup_bg),
675            scroll_offset: 0,
676            text_selection: None,
677            accept_key_hint: None,
678            // Mark this as the LSP status popup so confirm/cancel routes through
679            // handle_lsp_status_action regardless of what else is on screen.
680            resolver: PopupResolver::LspStatus,
681            focused,
682            focus_key_hint: focus_hint,
683        };
684
685        let buffer_id = self.active_buffer();
686        if let Some(state) = self
687            .windows
688            .get_mut(&self.active_window)
689            .map(|w| &mut w.buffers)
690            .expect("active window present")
691            .get_mut(&buffer_id)
692        {
693            state.popups.show(popup);
694        }
695    }
696
697    /// Show the Remote Indicator context menu popup.
698    ///
699    /// The menu is context-aware based on the current authority state:
700    /// - **Local:** offers "Attach to Dev Container" (when a devcontainer
701    ///   config is detectable) and "Open Dev Container Config".
702    /// - **Connected (container):** offers "Reopen Locally" (detach),
703    ///   "Rebuild Container", and "Show Container Info".
704    /// - **Connected (SSH):** offers "Disconnect Remote" and "Show Info".
705    /// - **Disconnected:** offers "Reconnect" (best-effort) and "Go Local".
706    ///
707    /// Clicking the `{remote}` status-bar element a second time toggles
708    /// the popup closed, matching the LSP-indicator affordance.
709    ///
710    /// # Design note
711    ///
712    /// Plugin-owned actions (attach, rebuild) are dispatched via
713    /// `Action::PluginAction` so core code never names the devcontainer
714    /// plugin directly. If the plugin isn't loaded the action becomes a
715    /// no-op with a status message, which is the same fallback every
716    /// other plugin-command invocation site uses.
717    pub fn show_remote_indicator_popup(&mut self) {
718        use crate::view::popup::{Popup, PopupContent, PopupKind, PopupListItem, PopupResolver};
719        use ratatui::style::Style;
720
721        if self
722            .active_state()
723            .popups
724            .top()
725            .is_some_and(|p| matches!(p.resolver, PopupResolver::RemoteIndicator))
726        {
727            self.hide_popup();
728            return;
729        }
730        // Not a toggle-close: clear any *other* menu popup (a different
731        // status-bar picker left open) before building this one, so the
732        // remote menu never renders over a stale popup (#1941). Done here,
733        // after the toggle check, rather than in the click handler — doing it
734        // there would close our own popup and defeat the toggle.
735        self.dismiss_menu_popups_for_prompt();
736
737        let connection = self.connection_display_string();
738        let is_disconnected = connection
739            .as_deref()
740            .is_some_and(|c| c.contains("(Disconnected)"));
741        let is_container = connection
742            .as_deref()
743            .is_some_and(|c| c.starts_with("Container:"));
744        let is_ssh = connection.is_some() && !is_container;
745
746        let devcontainer_config_path = self.find_devcontainer_config();
747
748        let mut items: Vec<PopupListItem> = Vec::new();
749        let mut title: String = String::new();
750
751        // Plugin-supplied override (Connecting / FailedAttach) takes
752        // precedence over the authority-derived branches. A Connecting
753        // indicator shouldn't render the "Reopen in Container" menu
754        // of the underlying derived state — an attach is in flight;
755        // the user needs Show Logs / Cancel / (after B-3b) Retry.
756        //
757        // Local / Connected / Disconnected overrides are treated as
758        // labelling shortcuts, not menu-shape changes — they fall
759        // through to the derived branches below.
760        use crate::view::ui::status_bar::RemoteIndicatorOverride;
761        let override_handled = matches!(
762            self.remote_indicator_override,
763            Some(RemoteIndicatorOverride::Connecting { .. })
764                | Some(RemoteIndicatorOverride::FailedAttach { .. })
765        );
766        if let Some(over) = self.remote_indicator_override.clone() {
767            match over {
768                RemoteIndicatorOverride::Connecting { label } => {
769                    let suffix = label
770                        .filter(|s| !s.is_empty())
771                        .map(|s| format!(" — {}", s))
772                        .unwrap_or_default();
773                    title = format!("Remote: Connecting{}", suffix);
774                    items.push(
775                        PopupListItem::new("    Cancel Startup".to_string())
776                            .with_data("plugin:devcontainer_cancel_attach".to_string()),
777                    );
778                    items.push(
779                        PopupListItem::new("    Show Logs".to_string())
780                            .with_data("plugin:devcontainer_show_build_logs".to_string()),
781                    );
782                }
783                RemoteIndicatorOverride::FailedAttach { error } => {
784                    let suffix = error
785                        .filter(|s| !s.is_empty())
786                        .map(|s| format!(" — {}", s))
787                        .unwrap_or_default();
788                    title = format!("Remote: Attach failed{}", suffix);
789                    items.push(
790                        PopupListItem::new("    Retry".to_string())
791                            .with_data("plugin:devcontainer_retry_attach".to_string()),
792                    );
793                    items.push(
794                        PopupListItem::new("    Reopen Locally".to_string())
795                            .with_data("clear_override".to_string()),
796                    );
797                    items.push(
798                        PopupListItem::new("    Show Build Logs".to_string())
799                            .with_data("plugin:devcontainer_show_build_logs".to_string()),
800                    );
801                }
802                _ => {
803                    // Fall through to the derived branches.
804                }
805            }
806        }
807
808        // Core-driven FailedAttach: a dormant remote workspace whose
809        // dive-triggered reconnect failed (recorded on the window, not via the
810        // plugin override). Offer a generic Retry (re-run the core reconnect)
811        // and a Dismiss that clears the error — independent of the
812        // devcontainer-specific override path above.
813        let core_failed_attach = !override_handled
814            && self
815                .active_window()
816                .remote_reconnect_error
817                .as_deref()
818                .is_some();
819        if core_failed_attach {
820            let err = self
821                .active_window()
822                .remote_reconnect_error
823                .clone()
824                .unwrap_or_default();
825            title = if err.is_empty() {
826                "Remote: Reconnect failed".to_string()
827            } else {
828                format!("Remote: Reconnect failed — {err}")
829            };
830            items.push(
831                PopupListItem::new("    Retry".to_string())
832                    .with_data("retry_reconnect".to_string()),
833            );
834            items.push(
835                PopupListItem::new("    Reopen Locally".to_string())
836                    .with_data("clear_reconnect_error".to_string()),
837            );
838        }
839
840        if !override_handled && !core_failed_attach {
841            match (connection.as_deref(), is_disconnected) {
842                // Connected authority (container or SSH), not disconnected.
843                (Some(label), false) => {
844                    title = format!("Remote: {}", label);
845                    if is_container {
846                        items.push(
847                            PopupListItem::new("    Reopen Locally".to_string())
848                                .with_data("detach".to_string()),
849                        );
850                        items.push(
851                            PopupListItem::new("    Rebuild Container".to_string())
852                                .with_data("plugin:devcontainer_rebuild".to_string()),
853                        );
854                        items.push(
855                            PopupListItem::new("    Show Container Logs".to_string())
856                                .with_data("plugin:devcontainer_show_logs".to_string()),
857                        );
858                        items.push(
859                            PopupListItem::new("    Show Container Info".to_string())
860                                .with_data("plugin:devcontainer_show_info".to_string()),
861                        );
862                        // The build log file from the most recent
863                        // `devcontainer up` survives the post-attach
864                        // restart (path stashed in plugin global state,
865                        // file lives under the workspace's
866                        // `.fresh-cache/`). Surfacing it here means
867                        // users can revisit "what did the build
868                        // actually do" any time after attach without
869                        // hunting through the file tree.
870                        items.push(
871                            PopupListItem::new("    Show Build Logs".to_string())
872                                .with_data("plugin:devcontainer_show_build_logs".to_string()),
873                        );
874                    } else if is_ssh {
875                        items.push(
876                            PopupListItem::new("    Disconnect Remote".to_string())
877                                .with_data("detach".to_string()),
878                        );
879                    }
880                }
881                // Disconnected — warn and offer fallbacks.
882                (Some(_), true) => {
883                    title = "Remote: Disconnected".to_string();
884                    // Offer Reconnect for a live remote-agent (SSH/kube) window:
885                    // its backend can be rebuilt from the stored
886                    // `RemoteAgentSpec`, re-pointing this window's authority and
887                    // respawning its dead terminal over the new link. Container
888                    // (`Plugin`) windows reconnect through their owning plugin
889                    // (`devcontainer up`), not here, so they don't get this row.
890                    let is_remote_agent = matches!(
891                        self.active_window().authority_spec,
892                        crate::services::authority::SessionAuthoritySpec::RemoteAgent(_)
893                    );
894                    if is_remote_agent {
895                        items.push(
896                            PopupListItem::new("    Reconnect".to_string())
897                                .with_data("reconnect".to_string()),
898                        );
899                    }
900                    items.push(
901                        PopupListItem::new("    Go Local".to_string())
902                            .with_data("detach".to_string()),
903                    );
904                }
905                // Local authority.
906                (None, _) => {
907                    title = "Remote: Local".to_string();
908                    if devcontainer_config_path.is_some() {
909                        items.push(
910                            PopupListItem::new("    Reopen in Container".to_string())
911                                .with_data("plugin:devcontainer_attach".to_string()),
912                        );
913                        items.push(
914                            PopupListItem::new("    Open Dev Container Config".to_string())
915                                .with_data("plugin:devcontainer_open_config".to_string()),
916                        );
917                    } else {
918                        // No .devcontainer present — offer the scaffold
919                        // so users can bootstrap a config in one click
920                        // without dropping to a shell. The scaffold
921                        // command is plugin-owned and registered
922                        // unconditionally at plugin load, so this row is
923                        // always actionable.
924                        items.push(
925                            PopupListItem::new("    Create Dev Container Config".to_string())
926                                .with_data("plugin:devcontainer_scaffold_config".to_string()),
927                        );
928                    }
929                }
930            }
931        } // end: if !override_handled
932
933        // Dismiss row — mirrors the LSP popup's terminal Dismiss row so
934        // users have an on-screen way out of the popup.
935        let cancel_binding = self
936            .keybindings
937            .read()
938            .ok()
939            .and_then(|kb| {
940                kb.get_keybinding_for_action(
941                    &crate::input::keybindings::Action::PopupCancel,
942                    crate::input::keybindings::KeyContext::Popup,
943                )
944            })
945            .unwrap_or_else(|| "Esc".to_string());
946        items.push(
947            PopupListItem::new(format!("    Dismiss ({})", cancel_binding))
948                .with_data("cancel_popup".to_string()),
949        );
950
951        let first_actionable = items
952            .iter()
953            .position(|i| i.data.is_some() && !i.disabled)
954            .unwrap_or(0);
955
956        // Anchor the popup to the remote-indicator's left edge if it's
957        // visible in the last frame; otherwise fall back to the bottom-
958        // right corner so the popup still appears. `status_row` comes
959        // from the same cached layout so the popup hugs the status bar
960        // even in prompt-auto-hide mode.
961        let position = self
962            .active_chrome()
963            .status_bar
964            .clickable_area(crate::view::ui::status_bar::StatusBarClickable::RemoteIndicator)
965            .map(
966                |(status_row, col_start, _)| crate::view::popup::PopupPosition::AboveStatusBarAt {
967                    x: col_start,
968                    status_row,
969                },
970            )
971            .unwrap_or(crate::view::popup::PopupPosition::BottomRight);
972
973        let popup_width = (items
974            .iter()
975            .map(|i| unicode_width::UnicodeWidthStr::width(i.text.as_str()))
976            .max()
977            .unwrap_or(24)
978            + 4) as u16;
979
980        let popup = Popup {
981            kind: PopupKind::List,
982            title: Some(title),
983            description: None,
984            transient: false,
985            content: PopupContent::List {
986                items,
987                selected: first_actionable,
988            },
989            position,
990            width: popup_width.clamp(28, 50),
991            max_height: 10,
992            bordered: true,
993            border_style: Style::default().fg(self.theme.read().unwrap().popup_border_fg),
994            background_style: Style::default().bg(self.theme.read().unwrap().popup_bg),
995            scroll_offset: 0,
996            text_selection: None,
997            accept_key_hint: None,
998            resolver: PopupResolver::RemoteIndicator,
999            // Explicitly invoked from the status-bar `{remote}` element,
1000            // so this popup wants the keyboard immediately.
1001            focused: true,
1002            focus_key_hint: None,
1003        };
1004
1005        let buffer_id = self.active_buffer();
1006        if let Some(state) = self
1007            .windows
1008            .get_mut(&self.active_window)
1009            .map(|w| &mut w.buffers)
1010            .expect("active window present")
1011            .get_mut(&buffer_id)
1012        {
1013            state.popups.show(popup);
1014        }
1015    }
1016
1017    /// Show the read-only indicator menu, anchored to the status bar's
1018    /// `{read_only}` segment. Offers to enable editing (which dispatches
1019    /// `Action::ToggleReadOnly`). Toggles closed on a second click, mirroring
1020    /// the LSP / remote menus.
1021    pub fn show_read_only_popup(&mut self) {
1022        use crate::view::popup::{
1023            Popup, PopupContent, PopupKind, PopupListItem, PopupPosition, PopupResolver,
1024        };
1025        use ratatui::style::Style;
1026
1027        // Second click on the indicator closes the menu instead of rebuilding.
1028        if self
1029            .active_state()
1030            .popups
1031            .top()
1032            .is_some_and(|p| matches!(p.resolver, PopupResolver::ReadOnly))
1033        {
1034            self.hide_popup();
1035            return;
1036        }
1037        // Not a toggle-close: clear any other menu popup left open so this one
1038        // never renders over a stale popup (#1941).
1039        self.dismiss_menu_popups_for_prompt();
1040
1041        let items = vec![
1042            PopupListItem::new(format!("    {}", t!("read_only.menu.enable_editing")))
1043                .with_data("toggle_read_only".to_string()),
1044            PopupListItem::new(format!("    {}", t!("read_only.menu.cancel")))
1045                .with_data("cancel".to_string()),
1046        ];
1047
1048        let position = self
1049            .active_chrome()
1050            .status_bar
1051            .clickable_area(crate::view::ui::status_bar::StatusBarClickable::ReadOnly)
1052            .map(
1053                |(status_row, col_start, _)| PopupPosition::AboveStatusBarAt {
1054                    x: col_start,
1055                    status_row,
1056                },
1057            )
1058            .unwrap_or(PopupPosition::BottomRight);
1059
1060        let popup_width = (items
1061            .iter()
1062            .map(|i| unicode_width::UnicodeWidthStr::width(i.text.as_str()))
1063            .max()
1064            .unwrap_or(24)
1065            + 4) as u16;
1066
1067        let popup = Popup {
1068            kind: PopupKind::List,
1069            title: Some(t!("read_only.menu.title").to_string()),
1070            description: None,
1071            transient: false,
1072            content: PopupContent::List { items, selected: 0 },
1073            position,
1074            width: popup_width.clamp(28, 50),
1075            max_height: 10,
1076            bordered: true,
1077            border_style: Style::default().fg(self.theme.read().unwrap().popup_border_fg),
1078            background_style: Style::default().bg(self.theme.read().unwrap().popup_bg),
1079            scroll_offset: 0,
1080            text_selection: None,
1081            accept_key_hint: None,
1082            resolver: PopupResolver::ReadOnly,
1083            // Explicitly invoked from the status-bar `{read_only}` element, so
1084            // this popup wants the keyboard immediately.
1085            focused: true,
1086            focus_key_hint: None,
1087        };
1088
1089        let buffer_id = self.active_buffer();
1090        if let Some(state) = self
1091            .windows
1092            .get_mut(&self.active_window)
1093            .map(|w| &mut w.buffers)
1094            .expect("active window present")
1095            .get_mut(&buffer_id)
1096        {
1097            state.popups.show(popup);
1098        }
1099    }
1100
1101    /// Dispatch the action selected from the read-only indicator menu.
1102    /// `"toggle_read_only"` flips the buffer's read-only state (enabling
1103    /// editing); `"cancel"` is a no-op (the popup already closed).
1104    pub fn handle_read_only_menu_action(&mut self, action_key: &str) {
1105        match action_key {
1106            "toggle_read_only" => {
1107                if let Err(e) =
1108                    self.handle_action(crate::input::keybindings::Action::ToggleReadOnly)
1109                {
1110                    tracing::warn!("read-only menu: toggling read-only failed: {}", e);
1111                }
1112            }
1113            "cancel" => {}
1114            other => {
1115                tracing::warn!(
1116                    "handle_read_only_menu_action: unknown action key '{}'",
1117                    other
1118                );
1119            }
1120        }
1121    }
1122
1123    /// Dispatch the action selected from the Remote Indicator popup.
1124    ///
1125    /// - `"detach"` — `clear_authority()` (falls back to local).
1126    /// - `"clear_override"` — drop the Remote Indicator override
1127    ///   without changing the authority. Used by the FailedAttach
1128    ///   "Reopen Locally" row: nothing to detach (no authority was
1129    ///   ever installed), but the FailedAttach indicator should
1130    ///   clear.
1131    /// - `"plugin:<name>"` — forwards to `Action::PluginAction(name)`.
1132    /// - `"cancel_popup"` — no-op; the popup framework already
1133    ///   closed the popup when the row was confirmed.
1134    /// - anything else — logged and ignored.
1135    pub fn handle_remote_indicator_action(&mut self, action_key: &str) {
1136        if action_key == "detach" {
1137            self.remote_indicator_override = None;
1138            self.clear_authority();
1139            return;
1140        }
1141        if action_key == "clear_override" {
1142            self.remote_indicator_override = None;
1143            return;
1144        }
1145        if action_key == "reconnect" || action_key == "retry_reconnect" {
1146            // Reconnect the active window's remote backend on explicit request:
1147            // the Disconnected popup's "Reconnect" row and the FailedAttach
1148            // "Retry" row both land here. `force_reconnect_remote_session`
1149            // clears any recorded error up front (so the indicator flips to
1150            // "Connecting"), forces the connect even for a *live* window whose
1151            // stale keepalive is still parked, and is a no-op for a non-remote
1152            // workspace. The reconnect path is plugins-gated (remote sessions
1153            // are created through the orchestrator plugin), so this is a no-op
1154            // in a plugins-less build.
1155            #[cfg(feature = "plugins")]
1156            self.force_reconnect_remote_session(self.active_window);
1157            return;
1158        }
1159        if action_key == "clear_reconnect_error" {
1160            // "Reopen Locally" / dismiss: drop the failed-reconnect error so the
1161            // indicator stops showing FailedAttach. The workspace keeps its
1162            // remote `authority_spec`, so a later dive still retries the connect.
1163            if let Some(w) = self.windows.get_mut(&self.active_window) {
1164                w.remote_reconnect_error = None;
1165            }
1166            return;
1167        }
1168        if action_key == "cancel_popup" {
1169            return;
1170        }
1171        if let Some(plugin_action) = action_key.strip_prefix("plugin:") {
1172            // `handle_action` wires this through the plugin manager; if
1173            // the plugin isn't loaded it surfaces a status message, which
1174            // is the correct no-op behavior for every plugin-command
1175            // invocation site in the codebase. We still want to log an
1176            // unexpected dispatch error — plugin misbehavior shouldn't
1177            // leave the user staring at a silently-failed Retry click.
1178            if let Err(e) = self.handle_action(crate::input::keybindings::Action::PluginAction(
1179                plugin_action.to_string(),
1180            )) {
1181                tracing::warn!(
1182                    "remote indicator popup: dispatching '{}' failed: {}",
1183                    plugin_action,
1184                    e
1185                );
1186            }
1187            return;
1188        }
1189        tracing::warn!(
1190            "handle_remote_indicator_action: unknown action key '{}'",
1191            action_key
1192        );
1193    }
1194
1195    /// Show the trust prompt if this workspace is undecided and contains
1196    /// content whose execution trust matters (env files, project manifests,
1197    /// `.sln`/`.csproj`, …). No-op once a decision is recorded or when there's
1198    /// nothing to gate. Called from every editor-startup path (in-process run
1199    /// and the session server) so the prompt fires regardless of launch mode.
1200    pub fn maybe_prompt_workspace_trust(&mut self) {
1201        // Phase 1 of the trust+env+devcontainer UX plan (see
1202        // `docs/internal/trust-env-devcontainer-ux-plan.md`): when the
1203        // workspace is undecided AND has executable content, the core trust
1204        // modal is the *single* trust prompt for every kind of marker —
1205        // env-shell (`.envrc`/`mise.toml`/`.tool-versions`), project
1206        // manifests, devcontainer config, .NET solution/project files. It is
1207        // shown with concrete framing: the popup names the *specific* markers
1208        // that triggered it (Cargo.toml, build.rs, .envrc, App.sln…) rather
1209        // than the abstract "this project can run code on your machine." The
1210        // workspace starts Restricted while waiting for the user to choose.
1211        //
1212        // Previously env-shell folders were carved out here so the
1213        // env-manager plugin could surface its own combined "Trust this
1214        // folder and activate?" popup — a *second* trust UI for the same
1215        // decision, which is exactly the duplication users hit. Now the
1216        // plugin no longer asks the trust question: it activates the env as a
1217        // *consequence* of trust, driven by the `trust_changed` hook this
1218        // editor fires when the level changes (see env-manager.ts). One
1219        // decision, one prompt, one place it is recorded.
1220        //
1221        // A decision the user explicitly recorded is always honored — this
1222        // branch only fires for undecided projects.
1223        let store = crate::services::workspace_trust::TrustStore::for_project_dir(
1224            &self.dir_context.project_state_dir(self.working_dir()),
1225        );
1226        if store.is_decided() {
1227            return; // respect a decision the user already recorded
1228        }
1229
1230        let markers =
1231            crate::services::workspace_trust::executable_content_markers(self.working_dir());
1232
1233        if markers.is_empty() {
1234            // Nothing executable to gate (plain text/docs). Trust silently so
1235            // the restricted chip doesn't appear and the user isn't blocked on
1236            // a question with no real downside. Persist it — same decision we'd
1237            // record if the user had explicitly confirmed.
1238            self.authority()
1239                .workspace_trust
1240                .set_level(crate::services::workspace_trust::TrustLevel::Trusted);
1241            return;
1242        }
1243
1244        // All executable content — including a *bare* `.venv`/`venv` — goes
1245        // through the trust decision. A virtualenv is a module-namespace
1246        // boundary, NOT a security boundary: activating it runs the repo's
1247        // interpreter, which auto-executes any `.pth`/`sitecustomize.py` shipped
1248        // inside the venv (a documented malware-drop vector), so its mere
1249        // presence must not silently grant trust. "Path-only" still governs how
1250        // it *activates* — silently, with no second prompt, once the workspace
1251        // is trusted (see env-manager) — it just no longer exempts the folder
1252        // from the trust decision itself.
1253
1254        // Seed Restricted *in memory only* —
1255        // `set_level_transient` does not write to disk. The on-disk store
1256        // stays undecided until the user picks a concrete option in the
1257        // modal. That preserves the contract: cancelling (quit) leaves the
1258        // project undecided so the prompt fires again next time, while any
1259        // deliberate choice (the modal's three radios) writes the decision
1260        // through via `set_level`.
1261        self.authority()
1262            .workspace_trust
1263            .set_level_transient(crate::services::workspace_trust::TrustLevel::Restricted);
1264
1265        // Non-cancellable on open: the choice has to be made, but any
1266        // concrete option resolves it. (`Esc` is inert on the forced-choice
1267        // variant; the user must pick a row.)
1268        self.show_workspace_trust_popup(false);
1269    }
1270
1271    /// Show the workspace-trust prompt: a centered list asking how this
1272    /// project's tooling should be treated. Surfaced on opening an
1273    /// untrusted project that contains executable content (env files,
1274    /// `.csproj`/`.sln`, …). The default-focused choice is the safe
1275    /// "Restricted" — dismissing with Escape leaves the project undecided
1276    /// (and re-asks next open), while selecting any row records the
1277    /// decision so the prompt stops appearing.
1278    pub fn show_workspace_trust_popup(&mut self, cancellable: bool) {
1279        use crate::view::popup::{Popup, PopupContent, PopupKind, PopupResolver};
1280        use ratatui::style::Style;
1281
1282        self.workspace_trust_prompt_cancellable = cancellable;
1283        self.workspace_trust_scroll = 0;
1284        self.workspace_trust_markers =
1285            crate::services::workspace_trust::executable_content_markers(self.working_dir());
1286
1287        // Don't stack a second copy if one is already up. The prompt lives on
1288        // the editor-level (global) stack so it renders regardless of which
1289        // buffer is active — opening a directory makes the file-explorer /
1290        // dashboard the active buffer, which would orphan a buffer-scoped
1291        // popup and leave it unrendered.
1292        if self
1293            .global_popups
1294            .top()
1295            .is_some_and(|p| matches!(p.resolver, PopupResolver::WorkspaceTrust))
1296        {
1297            return;
1298        }
1299
1300        // Seed the radio selection from the project's current level so a
1301        // command-palette invocation shows the active choice; at startup
1302        // (undecided) this is the safe Restricted default.
1303        let selected = match self.authority().workspace_trust.level() {
1304            crate::services::workspace_trust::TrustLevel::Trusted => 0,
1305            crate::services::workspace_trust::TrustLevel::Restricted => 1,
1306            crate::services::workspace_trust::TrustLevel::Blocked => 2,
1307        };
1308
1309        let items = vec![
1310            crate::view::popup::PopupListItem::new("Trust this folder".to_string())
1311                .with_detail("Allow project tooling (LSP, env managers, tasks) to run".to_string())
1312                .with_data("trusted".to_string()),
1313            crate::view::popup::PopupListItem::new("Keep restricted (default)".to_string())
1314                .with_detail("Don't run repo-controlled code; system tools still run".to_string())
1315                .with_data("restricted".to_string()),
1316            crate::view::popup::PopupListItem::new("Block all execution".to_string())
1317                .with_detail("No processes run at all in this workspace".to_string())
1318                .with_data("blocked".to_string()),
1319        ];
1320
1321        let popup_width = (items
1322            .iter()
1323            .map(|i| {
1324                let detail_w = i
1325                    .detail
1326                    .as_deref()
1327                    .map(unicode_width::UnicodeWidthStr::width)
1328                    .unwrap_or(0);
1329                unicode_width::UnicodeWidthStr::width(i.text.as_str()).max(detail_w)
1330            })
1331            .max()
1332            .unwrap_or(40)
1333            + 4) as u16;
1334
1335        let popup = Popup {
1336            kind: PopupKind::List,
1337            title: Some("This project can run code on your machine. Trust it?".to_string()),
1338            description: None,
1339            transient: false,
1340            content: PopupContent::List { items, selected },
1341            position: crate::view::popup::PopupPosition::Centered,
1342            width: popup_width.clamp(40, 70),
1343            max_height: 10,
1344            bordered: true,
1345            border_style: Style::default().fg(self.theme.read().unwrap().popup_border_fg),
1346            background_style: Style::default().bg(self.theme.read().unwrap().popup_bg),
1347            scroll_offset: 0,
1348            text_selection: None,
1349            accept_key_hint: None,
1350            resolver: PopupResolver::WorkspaceTrust,
1351            focused: true,
1352            focus_key_hint: None,
1353        };
1354
1355        self.global_popups.show(popup);
1356    }
1357
1358    /// Dispatch the choice selected from the workspace-trust prompt.
1359    /// `"trusted"` / `"restricted"` / `"blocked"` set the level (persisted);
1360    /// the new policy applies live to the next authority-routed spawn, scoped
1361    /// to this session's window — no editor restart. Anything else is logged
1362    /// and ignored.
1363    pub fn handle_workspace_trust_action(&mut self, action_key: &str) {
1364        use crate::services::workspace_trust::TrustLevel;
1365        let level = match action_key {
1366            "trusted" => TrustLevel::Trusted,
1367            "restricted" => TrustLevel::Restricted,
1368            "blocked" => TrustLevel::Blocked,
1369            other => {
1370                tracing::warn!("handle_workspace_trust_action: unknown action key '{other}'");
1371                return;
1372            }
1373        };
1374        self.set_workspace_trust_level(level);
1375    }
1376
1377    /// Keyboard handling for the workspace-trust modal. Returns `Some(Consumed)`
1378    /// for every key (the modal swallows everything): arrows and the mnemonics
1379    /// `T`/`K`/`B` move the radio selection (two-step — they don't confirm),
1380    /// `Enter`/`O` confirm the current selection, the user's global quit key
1381    /// quits the editor, and `Esc` is inert.
1382    pub(crate) fn handle_workspace_trust_key(
1383        &mut self,
1384        event: &crossterm::event::KeyEvent,
1385    ) -> Option<crate::input::handler::InputResult> {
1386        use crate::input::handler::InputResult;
1387        use crate::input::keybindings::{Action, KeyContext};
1388        use crossterm::event::KeyCode;
1389
1390        let cancellable = self.workspace_trust_prompt_cancellable;
1391
1392        // The mandatory open-time gate (not cancellable) binds its secondary
1393        // action to the user's global quit key (default Ctrl+Q) and quits the
1394        // editor. A voluntarily-opened prompt (cancellable) does not — Escape
1395        // cancels it instead.
1396        if !cancellable {
1397            let resolved = self
1398                .keybindings
1399                .read()
1400                .ok()
1401                .map(|kb| kb.resolve(event, KeyContext::Normal));
1402            if matches!(resolved, Some(Action::Quit) | Some(Action::ForceQuit)) {
1403                self.hide_popup();
1404                self.should_quit = true;
1405                return Some(InputResult::Consumed);
1406            }
1407        }
1408
1409        match event.code {
1410            KeyCode::Up => self.move_workspace_trust_selection(-1),
1411            KeyCode::Down => self.move_workspace_trust_selection(1),
1412            KeyCode::Char('t') | KeyCode::Char('T') => self.set_workspace_trust_selection(0),
1413            KeyCode::Char('k') | KeyCode::Char('K') => self.set_workspace_trust_selection(1),
1414            KeyCode::Char('b') | KeyCode::Char('B') => self.set_workspace_trust_selection(2),
1415            KeyCode::Enter | KeyCode::Char('o') | KeyCode::Char('O') => {
1416                self.confirm_workspace_trust(self.current_workspace_trust_selection());
1417            }
1418            // Escape cancels a voluntarily-opened prompt; on the mandatory gate
1419            // it (and every other key) is inert but still consumed (modal).
1420            KeyCode::Esc if cancellable => self.hide_popup(),
1421            _ => {}
1422        }
1423        Some(InputResult::Consumed)
1424    }
1425
1426    /// Set the radio selection to an absolute index (0=Trust, 1=Restricted,
1427    /// 2=Block) without confirming.
1428    fn set_workspace_trust_selection(&mut self, index: usize) {
1429        if let Some(popup) = self.global_popups.top_mut() {
1430            if let crate::view::popup::PopupContent::List { selected, .. } = &mut popup.content {
1431                *selected = index.min(2);
1432            }
1433        }
1434    }
1435
1436    /// The currently-highlighted radio index (0=Trust, 1=Restricted, 2=Block).
1437    pub(crate) fn current_workspace_trust_selection(&self) -> usize {
1438        self.global_popups
1439            .top()
1440            .and_then(|p| match &p.content {
1441                crate::view::popup::PopupContent::List { selected, .. } => Some(*selected),
1442                _ => None,
1443            })
1444            .unwrap_or(1)
1445    }
1446
1447    /// Move the radio selection by `delta`, wrapping across the three options.
1448    fn move_workspace_trust_selection(&mut self, delta: i32) {
1449        if let Some(popup) = self.global_popups.top_mut() {
1450            if let crate::view::popup::PopupContent::List { selected, .. } = &mut popup.content {
1451                *selected = (((*selected as i32) + delta).rem_euclid(3)) as usize;
1452            }
1453        }
1454    }
1455
1456    /// Record the trust decision for radio `index` and dismiss the modal.
1457    pub(crate) fn confirm_workspace_trust(&mut self, index: usize) {
1458        let key = match index {
1459            0 => "trusted",
1460            2 => "blocked",
1461            _ => "restricted",
1462        };
1463        self.hide_popup();
1464        self.handle_workspace_trust_action(key);
1465    }
1466
1467    /// Probe for a `devcontainer.json` under the current working
1468    /// directory. Mirrors the first two priorities of the devcontainer
1469    /// plugin's `findConfig()` so the Remote Indicator menu can decide
1470    /// whether to offer "Reopen in Container" without actually having to
1471    /// call into the plugin.
1472    ///
1473    /// Routes through `authority.filesystem` per `CONTRIBUTING.md`
1474    /// guideline 4, so an SSH-rooted workspace probes the remote host
1475    /// rather than the local one.
1476    fn find_devcontainer_config(&self) -> Option<std::path::PathBuf> {
1477        let cwd = self.working_dir();
1478        let fs = self.authority().filesystem.as_ref();
1479        let primary = cwd.join(".devcontainer").join("devcontainer.json");
1480        if fs.exists(&primary) {
1481            return Some(primary);
1482        }
1483        let secondary = cwd.join(".devcontainer.json");
1484        if fs.exists(&secondary) {
1485            return Some(secondary);
1486        }
1487        None
1488    }
1489
1490    /// Show a transient hover popup with the given message text, positioned below the cursor.
1491    /// Used for file-open messages (e.g. `file.txt:10@"Look at this"`).
1492    pub fn show_file_message_popup(&mut self, message: &str) {
1493        use crate::view::popup::{Popup, PopupPosition};
1494        use ratatui::style::Style;
1495
1496        // Build markdown: message text + blank line + italic hint
1497        let md = format!("{}\n\n*esc to dismiss*", message);
1498        // Size popup width to content: longest line + border padding, clamped to reasonable bounds
1499        let content_width = message.lines().map(|l| l.len()).max().unwrap_or(0) as u16;
1500        let hint_width = 16u16; // "*esc to dismiss*"
1501        let popup_width = (content_width.max(hint_width) + 4).clamp(20, 60);
1502
1503        let mut popup = Popup::markdown(
1504            &md,
1505            &self.theme.read().unwrap(),
1506            Some(&self.grammar_registry),
1507        );
1508        popup.transient = false;
1509        popup.position = PopupPosition::BelowCursor;
1510        popup.width = popup_width;
1511        popup.max_height = 15;
1512        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1513        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1514
1515        let buffer_id = self.active_buffer();
1516        if let Some(state) = self
1517            .windows
1518            .get_mut(&self.active_window)
1519            .map(|w| &mut w.buffers)
1520            .expect("active window present")
1521            .get_mut(&buffer_id)
1522        {
1523            state.popups.show(popup);
1524        }
1525    }
1526
1527    /// Show a prominent, centered modal popup reporting that a settings save
1528    /// failed.
1529    ///
1530    /// Used when the config file on disk can't be parsed: the save is aborted
1531    /// and the file is left untouched, but the user must be told loudly. A
1532    /// status-bar line is far too easy to miss for "your change didn't take
1533    /// effect", so this raises a focused, centered popup (red border) that the
1534    /// user dismisses with Esc — and on dismissal we open the offending config
1535    /// file (for `layer`) so they can fix the syntax error right away.
1536    ///
1537    /// The body is hard-wrapped here (long config paths have no spaces to break
1538    /// on) and rendered as plain text so a long file name wraps inside the
1539    /// border instead of being clipped.
1540    pub fn show_settings_save_error_popup(
1541        &mut self,
1542        layer: crate::config_io::ConfigLayer,
1543        error: &str,
1544    ) {
1545        use crate::view::popup::{Popup, PopupPosition, PopupResolver};
1546        use ratatui::style::Style;
1547
1548        const WIDTH: u16 = 64;
1549        // Border (2) + a little inner padding/scrollbar headroom (2).
1550        let wrap_width = (WIDTH as usize).saturating_sub(4);
1551
1552        let detail = t!("settings.failed_to_save", error = error).to_string();
1553        let unchanged = t!("settings.save_failed_unchanged").to_string();
1554        let open_hint = t!("settings.save_failed_open_hint").to_string();
1555        let title = t!("settings.save_failed_title").to_string();
1556
1557        // One blank line between paragraphs; each paragraph hard-wrapped so a
1558        // long, space-less path breaks rather than overflowing the border.
1559        let mut lines: Vec<String> = Vec::new();
1560        for (i, para) in [detail.as_str(), unchanged.as_str(), open_hint.as_str()]
1561            .iter()
1562            .enumerate()
1563        {
1564            if i > 0 {
1565                lines.push(String::new());
1566            }
1567            lines.extend(hard_wrap(para, wrap_width));
1568        }
1569
1570        let popup = {
1571            let theme = self.theme.read().unwrap();
1572            let mut p = Popup::text(lines, &theme)
1573                .with_title(title)
1574                .with_focused(true);
1575            p.transient = false;
1576            p.position = PopupPosition::Centered;
1577            p.width = WIDTH;
1578            p.max_height = 14;
1579            // Red border to read as an error, not a neutral info popup.
1580            p.border_style = Style::default().fg(theme.diagnostic_error_fg);
1581            p.background_style = Style::default().bg(theme.popup_bg);
1582            p.resolver = PopupResolver::SettingsSaveError { layer };
1583            p
1584        };
1585
1586        let buffer_id = self.active_buffer();
1587        if let Some(state) = self
1588            .windows
1589            .get_mut(&self.active_window)
1590            .map(|w| &mut w.buffers)
1591            .expect("active window present")
1592            .get_mut(&buffer_id)
1593        {
1594            state.popups.show(popup);
1595        }
1596    }
1597
1598    /// Get text properties at the cursor position in the active buffer
1599    pub fn get_text_properties_at_cursor(
1600        &self,
1601    ) -> Option<Vec<&crate::primitives::text_property::TextProperty>> {
1602        let state = self
1603            .windows
1604            .get(&self.active_window)
1605            .map(|w| &w.buffers)
1606            .expect("active window present")
1607            .get(&self.active_buffer())?;
1608        let cursor_pos = self.active_cursors().primary().position;
1609        Some(state.text_properties.get_at(cursor_pos))
1610    }
1611}