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