diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 953ef0444681aa0af3aae53e752c0a6a35408861..de27c34ce9c6e840758b884ab0c05e04dfa8c1e5 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -17,8 +17,7 @@ use project::{ }; use settings::{Settings as _, SettingsStore}; use ui::{ - Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide, - Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, Window, prelude::*, + ContextMenu, ContextMenuEntry, Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*, }; use util::{ResultExt, rel_path::RelPath}; @@ -91,7 +90,7 @@ struct LanguageServerBinaryStatus { message: Option, } -#[derive(Debug)] +#[derive(Debug, Clone)] struct ServerInfo { name: LanguageServerName, id: LanguageServerId, @@ -104,6 +103,12 @@ impl ServerInfo { fn server_selector(&self) -> LanguageServerSelector { LanguageServerSelector::Id(self.id) } + + fn can_stop(&self) -> bool { + self.binary_status.as_ref().is_none_or(|status| { + matches!(status.status, BinaryStatus::None | BinaryStatus::Starting) + }) + } } impl LanguageServerHealthStatus { @@ -145,7 +150,9 @@ impl LanguageServerState { } else { "Stop All Servers" }; + let restart = *restart; + let button = ContextMenuEntry::new(label).handler({ let state = cx.entity(); move |_, cx| { @@ -206,10 +213,12 @@ impl LanguageServerState { .ok(); } }); + if !first_button_encountered { menu = menu.separator(); first_button_encountered = true; } + menu = menu.item(button); continue; } else if let LspMenuItem::Header { header, separator } = item { @@ -229,165 +238,290 @@ impl LanguageServerState { .unwrap_or(false); let has_logs = is_remote || lsp_logs.read(cx).has_server_logs(&server_selector); - let status_color = server_info + let (status_color, status_label) = server_info .binary_status .as_ref() .and_then(|binary_status| match binary_status.status { BinaryStatus::None => None, BinaryStatus::CheckingForUpdate | BinaryStatus::Downloading - | BinaryStatus::Starting => Some(Color::Modified), - BinaryStatus::Stopping => Some(Color::Disabled), - BinaryStatus::Stopped => Some(Color::Disabled), - BinaryStatus::Failed { .. } => Some(Color::Error), + | BinaryStatus::Starting => Some((Color::Modified, "Starting…")), + BinaryStatus::Stopping | BinaryStatus::Stopped => { + Some((Color::Disabled, "Stopped")) + } + BinaryStatus::Failed { .. } => Some((Color::Error, "Error")), }) .or_else(|| { Some(match server_info.health? { - ServerHealth::Ok => Color::Success, - ServerHealth::Warning => Color::Warning, - ServerHealth::Error => Color::Error, + ServerHealth::Ok => (Color::Success, "Running"), + ServerHealth::Warning => (Color::Warning, "Warning"), + ServerHealth::Error => (Color::Error, "Error"), }) }) - .unwrap_or(Color::Success); + .unwrap_or((Color::Success, "Running")); let message = server_info .message .as_ref() .or_else(|| server_info.binary_status.as_ref()?.message.as_ref()) .cloned(); - let hover_label = if message.is_some() { - Some("View Message") - } else if has_logs { - Some("View Logs") - } else { - None - }; - let server_name = server_info.name.clone(); let server_version = server_versions .get(&server_info.id) .and_then(|version| version.clone()); - let tooltip_text = match (&server_version, &message) { + let metadata_label = match (&server_version, &message) { (None, None) => None, - (Some(version), None) => { - Some(SharedString::from(format!("Version: {}", version.as_ref()))) - } + (Some(version), None) => Some(SharedString::from(format!("v{}", version.as_ref()))), (None, Some(message)) => Some(message.clone()), (Some(version), Some(message)) => Some(SharedString::from(format!( - "Version: {}\n\n{}", + "v{}\n\n{}", version.as_ref(), message.as_ref() ))), }; - menu = menu.item(ContextMenuItem::custom_entry( - move |_, _| { - h_flex() - .group("menu_item") - .w_full() - .gap_2() - .justify_between() - .child( - h_flex() - .gap_2() - .child(Indicator::dot().color(status_color)) - .child(Label::new(server_name.0.clone())), - ) - .when_some(hover_label, |div, hover_label| { - div.child( - h_flex() - .visible_on_hover("menu_item") - .child( - Label::new(hover_label) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Icon::new(IconName::ChevronRight) - .size(IconSize::Small) - .color(Color::Muted), - ), - ) - }) - .into_any_element() - }, + + let submenu_server_name = server_info.name.clone(); + let submenu_server_info = server_info.clone(); + + menu = menu.submenu_with_colored_icon( + server_info.name.0.clone(), + IconName::Circle, + status_color, { let lsp_logs = lsp_logs.clone(); let message = message.clone(); let server_selector = server_selector.clone(); - let server_name = server_info.name.clone(); let workspace = self.workspace.clone(); - move |window, cx| { - if let Some(message) = &message { - let Some(create_buffer) = workspace - .update(cx, |workspace, cx| { - workspace - .project() - .update(cx, |project, cx| project.create_buffer(false, cx)) + let lsp_store = self.lsp_store.clone(); + let state = cx.entity().downgrade(); + let can_stop = submenu_server_info.can_stop(); + + move |menu, _window, _cx| { + let mut submenu = menu; + + if let Some(ref message) = message { + let workspace_for_message = workspace.clone(); + let message_for_handler = message.clone(); + let server_name_for_message = submenu_server_name.clone(); + submenu = submenu.entry("View Message", None, move |window, cx| { + let Some(create_buffer) = workspace_for_message + .update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.create_buffer(false, cx) + }) + }) + .ok() + else { + return; + }; + + let window_handle = window.window_handle(); + let workspace = workspace_for_message.clone(); + let message = message_for_handler.clone(); + let server_name = server_name_for_message.clone(); + cx.spawn(async move |cx| { + let buffer = create_buffer.await?; + buffer.update(cx, |buffer, cx| { + buffer.edit( + [( + 0..0, + format!( + "Language server {server_name}:\n\n{message}" + ), + )], + None, + cx, + ); + buffer.set_capability(language::Capability::ReadOnly, cx); + })?; + + workspace.update(cx, |workspace, cx| { + window_handle.update(cx, |_, window, cx| { + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = Editor::for_buffer( + buffer, None, window, cx, + ); + editor.set_read_only(true); + editor + })), + None, + true, + window, + cx, + ); + }) + })??; + + anyhow::Ok(()) }) - .ok() - else { + .detach(); + }); + } + + if has_logs { + let lsp_logs_for_debug = lsp_logs.clone(); + let workspace_for_debug = workspace.clone(); + let server_selector_for_debug = server_selector.clone(); + submenu = submenu.entry("View Logs", None, move |window, cx| { + lsp_log_view::open_server_trace( + &lsp_logs_for_debug, + workspace_for_debug.clone(), + server_selector_for_debug.clone(), + window, + cx, + ); + }); + } + + let state_for_restart = state.clone(); + let workspace_for_restart = workspace.clone(); + let lsp_store_for_restart = lsp_store.clone(); + let server_name_for_restart = submenu_server_name.clone(); + submenu = submenu.entry("Restart Server", None, move |_window, cx| { + let Some(workspace) = workspace_for_restart.upgrade() else { return; }; - let window = window.window_handle(); - let workspace = workspace.clone(); - let message = message.clone(); - let server_name = server_name.clone(); - cx.spawn(async move |cx| { - let buffer = create_buffer.await?; - buffer.update(cx, |buffer, cx| { - buffer.edit( - [( - 0..0, - format!("Language server {server_name}:\n\n{message}"), - )], - None, - cx, - ); - buffer.set_capability(language::Capability::ReadOnly, cx); - })?; - - workspace.update(cx, |workspace, cx| { - window.update(cx, |_, window, cx| { - workspace.add_item_to_active_pane( - Box::new(cx.new(|cx| { - let mut editor = - Editor::for_buffer(buffer, None, window, cx); - editor.set_read_only(true); - editor - })), - None, - true, - window, + let project = workspace.read(cx).project().clone(); + let path_style = project.read(cx).path_style(cx); + let buffer_store = project.read(cx).buffer_store().clone(); + + let buffers = state_for_restart + .update(cx, |state, cx| { + let server_buffers = state + .language_servers + .servers_per_buffer_abs_path + .iter() + .filter_map(|(abs_path, servers)| { + // Check if this server is associated with this path + let has_server = servers.servers.values().any(|name| { + name.as_ref() == Some(&server_name_for_restart) + }); + + if !has_server { + return None; + } + + let worktree = servers.worktree.as_ref()?.upgrade()?; + let worktree_ref = worktree.read(cx); + let relative_path = abs_path + .strip_prefix(&worktree_ref.abs_path()) + .ok()?; + let relative_path = + RelPath::new(relative_path, path_style) + .log_err()?; + let entry = + worktree_ref.entry_for_path(&relative_path)?; + let project_path = + project.read(cx).path_for_entry(entry.id, cx)?; + + buffer_store.read(cx).get_by_path(&project_path) + }) + .collect::>(); + + if server_buffers.is_empty() { + state + .language_servers + .servers_per_buffer_abs_path + .iter() + .filter_map(|(abs_path, servers)| { + let worktree = + servers.worktree.as_ref()?.upgrade()?.read(cx); + let relative_path = abs_path + .strip_prefix(&worktree.abs_path()) + .ok()?; + let relative_path = + RelPath::new(relative_path, path_style) + .log_err()?; + let entry = + worktree.entry_for_path(&relative_path)?; + let project_path = project + .read(cx) + .path_for_entry(entry.id, cx)?; + buffer_store.read(cx).get_by_path(&project_path) + }) + .collect() + } else { + server_buffers + } + }) + .unwrap_or_default(); + + if !buffers.is_empty() { + lsp_store_for_restart + .update(cx, |lsp_store, cx| { + lsp_store.restart_language_servers_for_buffers( + buffers, + HashSet::from_iter([LanguageServerSelector::Name( + server_name_for_restart.clone(), + )]), cx, ); }) - })??; + .ok(); + } + }); - anyhow::Ok(()) - }) - .detach(); - } else if has_logs { - lsp_log_view::open_server_trace( - &lsp_logs, - workspace.clone(), - server_selector.clone(), - window, - cx, - ); - } else { - cx.propagate(); + if can_stop { + let lsp_store_for_stop = lsp_store.clone(); + let server_selector_for_stop = server_selector.clone(); + + submenu = submenu.entry("Stop Server", None, move |_window, cx| { + lsp_store_for_stop + .update(cx, |lsp_store, cx| { + lsp_store + .stop_language_servers_for_buffers( + Vec::new(), + HashSet::from_iter([ + server_selector_for_stop.clone() + ]), + cx, + ) + .detach_and_log_err(cx); + }) + .ok(); + }); } + + submenu = submenu.separator().custom_row({ + let metadata_label = metadata_label.clone(); + move |_, _| { + h_flex() + .ml_neg_1() + .gap_1() + .child( + Icon::new(IconName::Circle) + .color(status_color) + .size(IconSize::Small), + ) + .child( + Label::new(status_label) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when_some(metadata_label.as_ref(), |submenu, metadata| { + submenu + .child( + Icon::new(IconName::Dash) + .color(Color::Disabled) + .size(IconSize::XSmall), + ) + .child( + Label::new(metadata) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .into_any_element() + } + }); + + submenu } }, - tooltip_text.map(|tooltip_text| { - DocumentationAside::new( - DocumentationSide::Right, - Rc::new(move |_| Label::new(tooltip_text.clone()).into_any_element()), - ) - }), - )); + ); } menu } diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index d0164dc939213fb02185ac73cb2c9adbe9ea6de4..05a70a1d20c934510d21b1c340e36d9297ae5d2a 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -27,7 +27,6 @@ struct OpenSubmenu { item_index: usize, entity: Entity, trigger_bounds: Option>, - // Capture the submenu's vertical offset once and keep it stable while the submenu is open. offset: Option, _dismiss_subscription: Subscription, } @@ -61,6 +60,7 @@ pub enum ContextMenuItem { Submenu { label: SharedString, icon: Option, + icon_color: Option, builder: Rc) -> ContextMenu>, }, } @@ -763,6 +763,7 @@ impl ContextMenu { self.items.push(ContextMenuItem::Submenu { label: label.into(), icon: None, + icon_color: None, builder: Rc::new(builder), }); self @@ -777,6 +778,23 @@ impl ContextMenu { self.items.push(ContextMenuItem::Submenu { label: label.into(), icon: Some(icon), + icon_color: None, + builder: Rc::new(builder), + }); + self + } + + pub fn submenu_with_colored_icon( + mut self, + label: impl Into, + icon: IconName, + icon_color: Color, + builder: impl Fn(ContextMenu, &mut Window, &mut Context) -> ContextMenu + 'static, + ) -> Self { + self.items.push(ContextMenuItem::Submenu { + label: label.into(), + icon: Some(icon), + icon_color: Some(icon_color), builder: Rc::new(builder), }); self @@ -1319,8 +1337,13 @@ impl ContextMenu { ) .into_any_element() } - ContextMenuItem::Submenu { label, icon, .. } => self - .render_submenu_item_trigger(ix, label.clone(), *icon, cx) + ContextMenuItem::Submenu { + label, + icon, + icon_color, + .. + } => self + .render_submenu_item_trigger(ix, label.clone(), *icon, *icon_color, cx) .into_any_element(), } } @@ -1330,6 +1353,7 @@ impl ContextMenu { ix: usize, label: SharedString, icon: Option, + icon_color: Option, cx: &mut Context, ) -> impl IntoElement { let toggle_state = Some(ix) == self.selected_index @@ -1413,9 +1437,17 @@ impl ContextMenu { SubmenuState::Open(open_submenu) if open_submenu.item_index == ix ); - if is_open_for_this_item && this.hover_target != HoverTarget::Submenu { + let mouse_in_submenu_zone = this + .padded_submenu_bounds() + .is_some_and(|bounds| bounds.contains(&window.mouse_position())); + + if is_open_for_this_item + && this.hover_target != HoverTarget::Submenu + && !mouse_in_submenu_zone + { this.close_submenu(false, cx); this.clear_selected(); + window.focus(&this.focus_handle.clone(), cx); cx.notify(); } } @@ -1441,6 +1473,7 @@ impl ContextMenu { .child( h_flex() .w_full() + .gap_2() .justify_between() .child( h_flex() @@ -1449,7 +1482,7 @@ impl ContextMenu { this.child( Icon::new(icon_name) .size(IconSize::Small) - .color(Color::Default), + .color(icon_color.unwrap_or(Color::Muted)), ) }) .child(Label::new(label).color(Color::Default)), @@ -1973,9 +2006,14 @@ impl Render for ContextMenu { .on_action(cx.listener(ContextMenu::select_submenu_parent)) .on_action(cx.listener(ContextMenu::confirm)) .on_action(cx.listener(ContextMenu::cancel)) - .on_hover(cx.listener(|this, hovered: &bool, _, _| { + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { if *hovered { this.hover_target = HoverTarget::MainMenu; + if let Some(parent) = &this.main_menu { + parent.update(cx, |parent, _| { + parent.hover_target = HoverTarget::Submenu; + }); + } } })) .on_mouse_down_out(cx.listener(