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