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