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}