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