use std::collections::HashMap; use std::path::{Path, PathBuf}; use galaxy_core::send_telemetry_from_ctx; use lsp::supported_servers::LSPServerType; use lsp::{ LanguageId, LanguageServerId, LspManagerModel, LspManagerModelEvent, LspServerModel, LspState as LspModelState, }; use crate::code::lsp_telemetry::{LspControlActionType, LspEnablementSource, LspTelemetryEvent}; use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::{Fill as ThemeFill, WarpTheme}; use galaxy_core::ui::{appearance::Appearance, Icon}; use galaxyui::elements::{ ChildAnchor, ChildView, Dismiss, Empty, Hoverable, MainAxisSize, MouseStateHandle, ParentAnchor, ParentOffsetBounds, Rect, Shrinkable, }; use galaxyui::platform::Cursor; use galaxyui::ui_components::components::{UiComponent, UiComponentStyles}; use galaxyui::{ elements::{ Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex, MainAxisAlignment, OffsetPositioning, Padding, ParentElement, Radius, Stack, }, AppContext, Element, Entity, ModelHandle, SingletonEntity, View, WeakModelHandle, }; use galaxyui::{TypedActionView, ViewContext, ViewHandle}; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; use galaxy_core::ui::theme::AnsiColorIdentifier; #[cfg(feature = "local_fs")] use crate::ai::persisted_workspace::PersistedWorkspaceEvent; use crate::ai::persisted_workspace::{ LSPEnablementResultForFile, LspRepoStatus, PersistedWorkspace, }; use crate::settings::AISettings; use crate::ui_components::blended_colors; #[cfg(feature = "local_fs")] use crate::user_config::is_tab_config_toml; use crate::view_components::action_button::{ ActionButton, ButtonSize, NakedTheme, PaneHeaderTheme, }; #[cfg(feature = "local_fs")] use repo_metadata::repositories::DetectedRepositories; const FOOTER_HEIGHT: f32 = 24.; /// Margin around the LSP icon container const ICON_MARGIN: f32 = 4.; const INDICATOR_SIZE: f32 = 8.; #[derive(Default)] struct SingleFileMouseStates { open_logs: MouseStateHandle, restart_server: MouseStateHandle, stop_server: MouseStateHandle, start_server: MouseStateHandle, remove_server: MouseStateHandle, } #[derive(Default)] struct WorkspaceMouseStates { restart_all: MouseStateHandle, stop_all: MouseStateHandle, start_all: MouseStateHandle, manage_servers: MouseStateHandle, } /// Determines the operating mode of the footer. enum FooterMode { /// Tab config editor — shows a skill CTA instead of LSP details. TabConfig { path: PathBuf }, /// Single file editor — tracks one server for one file path. SingleFile { path: PathBuf, mouse_states: SingleFileMouseStates, /// Status of LSP server relevance and installation for the file's repo. lsp_repo_status: LspRepoStatus, }, /// Workspace-level — tracks all servers for a repo root. Workspace { root_path: PathBuf, mouse_states: WorkspaceMouseStates, /// Per-server-type status of LSP relevance and installation. lsp_repo_statuses: LspRepoStatuses, }, } impl FooterMode { fn path(&self) -> &Path { match self { FooterMode::TabConfig { path } => path, FooterMode::SingleFile { path, .. } => path, FooterMode::Workspace { root_path, .. } => root_path, } } /// Returns all CTA-worthy `LspRepoStatus` entries (i.e. those that need user action). /// For SingleFile, returns at most one. For Workspace, returns all /// `DisabledAndInstalled` or `DisabledAndNotInstalled` entries. fn cta_lsp_repo_statuses(&self) -> Vec<&LspRepoStatus> { match self { FooterMode::TabConfig { .. } => vec![], FooterMode::SingleFile { lsp_repo_status, .. } => { if matches!( lsp_repo_status, LspRepoStatus::DisabledAndInstalled { .. } | LspRepoStatus::DisabledAndNotInstalled { .. } ) { vec![lsp_repo_status] } else { vec![] } } FooterMode::Workspace { lsp_repo_statuses, .. } => lsp_repo_statuses .values() .filter(|s| { matches!( s, LspRepoStatus::DisabledAndInstalled { .. } | LspRepoStatus::DisabledAndNotInstalled { .. } ) }) .collect(), } } } #[derive(Debug, Clone, PartialEq)] pub enum CodeFooterViewAction { CloseMenu, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] ToggleMenu, EnableLSP, RunTabConfigSkill, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] OpenLogs, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] RestartServer, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] StopServer, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] StartServer, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] RemoveServer, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] RestartAllServers, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] StopAllServers, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] StartAllServers, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] ManageServers, } enum LSPServerRenderStatus { Available, Stopped, Busy, Failed, } impl LSPServerRenderStatus { fn to_icon_color(&self, theme: &WarpTheme) -> ColorU { match self { LSPServerRenderStatus::Available => AnsiColorIdentifier::Green .to_ansi_color(&theme.terminal_colors().normal) .into(), LSPServerRenderStatus::Busy => AnsiColorIdentifier::Yellow .to_ansi_color(&theme.terminal_colors().normal) .into(), LSPServerRenderStatus::Failed => AnsiColorIdentifier::Red .to_ansi_color(&theme.terminal_colors().normal) .into(), LSPServerRenderStatus::Stopped => internal_colors::neutral_5(theme), } } fn render_status(server: Option<&LspServerModel>) -> Self { match server { Some(server) => match server.state() { LspModelState::Available { .. } if !server.has_pending_tasks() => { LSPServerRenderStatus::Available } LspModelState::Starting | LspModelState::Available { .. } => { LSPServerRenderStatus::Busy } LspModelState::Failed { .. } => LSPServerRenderStatus::Failed, LspModelState::Stopped { .. } | LspModelState::Stopping { .. } => { LSPServerRenderStatus::Stopped } }, None => LSPServerRenderStatus::Stopped, } } } pub struct CodeFooterView { mode: FooterMode, lsp_servers: Vec>, /// Tracks the server IDs we've already subscribed to, so we don't duplicate subscriptions. subscribed_server_ids: Vec, lsp_status_button: ViewHandle, enable_lsp_button: Option>, tab_config_skill_button: Option>, is_lsp_menu_open: bool, /// Whether to render the top border. Disabled for code review footer. show_border: bool, } /// Wraps the per-server-type status map and enforces a single invariant on /// every mutation: **a running server always wins**. All status updates must /// go through [`update_status`](Self::update_status) so that stale async /// results (e.g. an `InstallStatusUpdate` arriving after the server has /// already started) can never clobber a live server's `Ready` status. #[derive(Debug, Default)] struct LspRepoStatuses { inner: HashMap, } impl LspRepoStatuses { /// The single mutation point for workspace repo statuses. /// /// If the server type has a live (upgradeable) server in `lsp_servers`, /// the effective status is forced to `Ready` regardless of `proposed`. /// Otherwise `proposed` is used as-is. fn update_status( &mut self, server_type: LSPServerType, proposed: LspRepoStatus, lsp_servers: &[WeakModelHandle], app: &AppContext, ) { let is_live = lsp_servers.iter().any(|w| { w.upgrade(app) .is_some_and(|s| s.as_ref(app).server_type() == server_type) }); let effective = if is_live { LspRepoStatus::Ready } else { proposed }; self.inner.insert(server_type, effective); } fn is_empty(&self) -> bool { self.inner.is_empty() } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn contains_key(&self, server_type: &LSPServerType) -> bool { self.inner.contains_key(server_type) } fn values(&self) -> impl Iterator { self.inner.values() } } impl CodeFooterView { #[cfg(feature = "local_fs")] fn is_tab_config_path(path: &Path) -> bool { is_tab_config_toml(path) } #[cfg(not(feature = "local_fs"))] fn is_tab_config_path(_path: &Path) -> bool { false } fn create_tab_config_skill_button(ctx: &mut ViewContext) -> ViewHandle { ctx.add_typed_action_view(|_ctx| { ActionButton::new("/update-tab-config", NakedTheme) .with_icon(Icon::Oz) .with_size(ButtonSize::Small) .with_disabled_theme(PaneHeaderTheme) .on_click(|ctx| { ctx.dispatch_typed_action(CodeFooterViewAction::RunTabConfigSkill); }) }) } fn render_tab_config_info_icon(theme: &WarpTheme) -> Box { Container::new( ConstrainedBox::new( Icon::Info .to_galaxyui_icon(theme.active_ui_text_color()) .finish(), ) .with_width(12.) .with_height(12.) .finish(), ) .with_margin_left(ICON_MARGIN) .finish() } fn is_tab_config_footer(&self) -> bool { matches!(self.mode, FooterMode::TabConfig { .. }) } fn sync_tab_config_skill_button(&mut self, ctx: &mut ViewContext) { let Some(button) = &self.tab_config_skill_button else { return; }; let is_ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx); button.update(ctx, |button, ctx| { button.set_disabled(!is_ai_enabled, ctx); button.set_tooltip( Some(if is_ai_enabled { "Open agent input with the /update-tab-config skill" } else { "Enable AI to use the /update-tab-config skill" }), ctx, ); }); } fn create_lsp_status_button( disabled: bool, ctx: &mut ViewContext, ) -> ViewHandle { ctx.add_typed_action_view(|ctx| { // Use Small size to get proper padding around the icon for a larger click target let mut button = ActionButton::new("", NakedTheme) .with_icon(Icon::Lightning) .with_size(ButtonSize::Small) .with_disabled_theme(NakedTheme) .on_click(|ctx| { ctx.dispatch_typed_action(CodeFooterViewAction::ToggleMenu); }); button.set_disabled(disabled, ctx); button }) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn new(path: PathBuf, ctx: &mut ViewContext) -> Self { let lsp_status_button = Self::create_lsp_status_button(true, ctx); if Self::is_tab_config_path(&path) { let tab_config_skill_button = Self::create_tab_config_skill_button(ctx); let mut footer = Self { mode: FooterMode::TabConfig { path }, lsp_servers: Vec::new(), subscribed_server_ids: Vec::new(), lsp_status_button, enable_lsp_button: None, tab_config_skill_button: Some(tab_config_skill_button), is_lsp_menu_open: false, show_border: true, }; footer.sync_tab_config_skill_button(ctx); ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, _, ctx| { me.sync_tab_config_skill_button(ctx); }); return footer; } let server_type = LanguageId::from_path(&path).map(|id| id.server_type()); // Create a button that dispatches EnableLSP action // The action handler will check lsp_repo_status to decide whether to install first let enable_lsp_button = server_type.map(|st| { let label = format!("Enable {}", st.binary_name()); ctx.add_typed_action_view(|_ctx| { ActionButton::new(label, NakedTheme) .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(CodeFooterViewAction::EnableLSP); }) }) }); // Kick off detection via PersistedWorkspace and subscribe for updates #[cfg(feature = "local_fs")] let initial_status = { let status = Self::detect_installation_status(&path, ctx); // Update button label based on initial status (handles cached results) if let Some(enable_button) = &enable_lsp_button { if let Some(label) = Self::button_label_for_status(&status) { enable_button.update(ctx, |button, ctx| { button.set_label(label, ctx); }); } } // Subscribe to InstallStatusUpdate events from PersistedWorkspace let persisted = PersistedWorkspace::handle(ctx); ctx.subscribe_to_model(&persisted, move |me, _model_handle, event, ctx| { // Only handle InstallStatusUpdate events for our server type let PersistedWorkspaceEvent::InstallStatusUpdate { server_type: event_server_type, status, } = event else { return; }; let FooterMode::SingleFile { path, .. } = &me.mode else { return; }; let Some(current_server_type) = LanguageId::from_path(path).map(|id| id.server_type()) else { return; }; // Only update if the event is for the server type we're tracking if *event_server_type != current_server_type { return; } // Convert LSPInstallationStatus to LspRepoStatus let new_status = LspRepoStatus::from_installation_status(status, *event_server_type); if let FooterMode::SingleFile { lsp_repo_status, .. } = &mut me.mode { *lsp_repo_status = new_status; } me.update_enable_button_label(ctx); ctx.notify(); }); status }; #[cfg(not(feature = "local_fs"))] let initial_status = LspRepoStatus::CheckingForInstallation; Self { mode: FooterMode::SingleFile { path, mouse_states: SingleFileMouseStates::default(), lsp_repo_status: initial_status, }, lsp_servers: Vec::new(), subscribed_server_ids: Vec::new(), is_lsp_menu_open: false, lsp_status_button, enable_lsp_button, tab_config_skill_button: None, show_border: true, } } /// Creates a footer in workspace mode that tracks all LSP servers for a repo root. /// Used by code review to show a single aggregated footer. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn new_for_workspace(root_path: PathBuf, ctx: &mut ViewContext) -> Self { let lsp_status_button = Self::create_lsp_status_button(true, ctx); // Subscribe to LspManagerModel to auto-track server lifecycle let workspace_root = root_path.clone(); ctx.subscribe_to_model( &LspManagerModel::handle(ctx), move |me, _, event, ctx| match event { LspManagerModelEvent::ServerStarted(path) if path == &workspace_root => { me.refresh_workspace_servers(ctx); } LspManagerModelEvent::ServerStopped(path) if path == &workspace_root => { ctx.notify(); } LspManagerModelEvent::ServerRemoved { workspace_root: removed_root, .. } if removed_root == &workspace_root => { me.refresh_workspace_servers(ctx); } _ => {} }, ); // Kick off async detection of available servers for this workspace #[cfg(feature = "local_fs")] { let persisted = PersistedWorkspace::handle(ctx); persisted.update(ctx, |model, ctx| { model.detect_available_servers_for_workspaces(vec![root_path.clone()], false, ctx); }); // Subscribe to AvailableServersDetected to populate per-server statuses let workspace_root_for_detect = root_path.clone(); ctx.subscribe_to_model(&persisted, move |me, _model_handle, event, ctx| { match event { PersistedWorkspaceEvent::AvailableServersDetected { workspace_path, servers, } if *workspace_path == workspace_root_for_detect => { let FooterMode::Workspace { root_path, lsp_repo_statuses, .. } = &mut me.mode else { return; }; // For each suggested server, detect its installation status let root = root_path.clone(); for &server_type in servers { let proposed = PersistedWorkspace::handle(ctx).update(ctx, |model, ctx| { model.detect_lsp_workspace_status( root.clone(), server_type, ctx, ) }); lsp_repo_statuses.update_status( server_type, proposed, &me.lsp_servers, ctx, ); } // Create enable button for all CTA-worthy servers let cta_statuses = me.mode.cta_lsp_repo_statuses(); if let Some(label) = Self::button_label_for_cta_statuses(&cta_statuses) { me.enable_lsp_button = Some(ctx.add_typed_action_view(|_ctx| { ActionButton::new(label, NakedTheme) .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(CodeFooterViewAction::EnableLSP); }) })); } ctx.notify(); } PersistedWorkspaceEvent::InstallStatusUpdate { server_type, status, } => { let FooterMode::Workspace { lsp_repo_statuses, .. } = &mut me.mode else { return; }; // Only update if we're tracking this server type if lsp_repo_statuses.contains_key(server_type) { let proposed = LspRepoStatus::from_installation_status(status, *server_type); lsp_repo_statuses.update_status( *server_type, proposed, &me.lsp_servers, ctx, ); me.update_enable_button_label(ctx); ctx.notify(); } } PersistedWorkspaceEvent::AvailableServersDetected { .. } | PersistedWorkspaceEvent::InstallationSucceeded | PersistedWorkspaceEvent::InstallationFailed | PersistedWorkspaceEvent::WorkspaceAdded { .. } => {} } }); } let mut view = Self { mode: FooterMode::Workspace { root_path, mouse_states: WorkspaceMouseStates::default(), lsp_repo_statuses: LspRepoStatuses::default(), }, lsp_servers: Vec::new(), subscribed_server_ids: Vec::new(), is_lsp_menu_open: false, lsp_status_button, enable_lsp_button: None, tab_config_skill_button: None, show_border: false, }; // Populate initial servers from the manager view.refresh_workspace_servers(ctx); view } /// Refreshes the server list from the LspManagerModel for workspace mode. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn refresh_workspace_servers(&mut self, ctx: &mut ViewContext) { let FooterMode::Workspace { root_path, .. } = &self.mode else { return; }; let lsp_manager = LspManagerModel::handle(ctx); let new_servers: Vec> = lsp_manager .as_ref(ctx) .servers_for_workspace(root_path) .cloned() .unwrap_or_default(); // Subscribe to any newly added servers (check by LanguageServerId) for server in &new_servers { let server_id = server.as_ref(ctx).id(); if !self.subscribed_server_ids.contains(&server_id) { ctx.subscribe_to_model(server, |_, _, _, ctx| { ctx.notify(); }); } } // Mark any server types that now have live servers as Ready. // Uses reconciled_repo_status (with Ready as the proposed status) // which is a no-op here since the server IS live, but keeps all // mutations going through the single reconciliation point. if let FooterMode::Workspace { lsp_repo_statuses, .. } = &mut self.mode { for server in &new_servers { let server_type = server.as_ref(ctx).server_type(); lsp_repo_statuses.update_status( server_type, LspRepoStatus::Ready, &self.lsp_servers, ctx, ); } } // Rebuild subscribed IDs to match current server set self.subscribed_server_ids = new_servers.iter().map(|s| s.as_ref(ctx).id()).collect(); self.lsp_servers = new_servers.iter().map(|s| s.downgrade()).collect(); let has_servers = !self.lsp_servers.is_empty(); self.lsp_status_button.update(ctx, |button, ctx| { button.set_disabled(!has_servers, ctx); }); // Update (or hide) the enable button based on remaining CTA statuses. self.update_enable_button_label(ctx); ctx.notify(); } /// Returns the appropriate button label for the given LSP repo status. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn button_label_for_status(status: &LspRepoStatus) -> Option { match status { LspRepoStatus::DisabledAndNotInstalled { server_type } => { Some(format!("Install {}", server_type.binary_name())) } LspRepoStatus::DisabledAndInstalled { server_type } => { Some(format!("Enable {}", server_type.binary_name())) } _ => None, } } /// Returns the appropriate button label for a set of CTA-worthy statuses. /// When multiple servers need action, uses plural labels /// ("Enable servers" / "Install servers"). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn button_label_for_cta_statuses(statuses: &[&LspRepoStatus]) -> Option { match statuses.len() { 0 => None, 1 => Self::button_label_for_status(statuses[0]), _ => { let any_needs_install = statuses .iter() .any(|s| matches!(s, LspRepoStatus::DisabledAndNotInstalled { .. })); if any_needs_install { Some("Install servers".to_string()) } else { Some("Enable servers".to_string()) } } } } /// Detects LSP installation status for the given file path and returns the initial status. /// This is shared between `new` and `clear_server_subscription`. Only used in SingleFile mode. #[cfg(feature = "local_fs")] fn detect_installation_status( file_path: &std::path::Path, ctx: &mut ViewContext, ) -> LspRepoStatus { let server_type = LanguageId::from_path(file_path).map(|id| id.server_type()); let Some(server_type) = server_type else { return LspRepoStatus::CheckingForInstallation; }; let repo_root = DetectedRepositories::handle(ctx) .as_ref(ctx) .get_root_for_path(file_path) .or_else(|| file_path.parent().map(|p| p.to_path_buf())); let Some(repo_root) = repo_root else { return LspRepoStatus::CheckingForInstallation; }; PersistedWorkspace::handle(ctx).update(ctx, |model, ctx| { model.detect_lsp_workspace_status(repo_root, server_type, ctx) }) } /// Updates the enable button label based on the current CTA-worthy repo statuses. /// Hides the button when no CTAs remain. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn update_enable_button_label(&mut self, ctx: &mut ViewContext) { let cta_statuses = self.mode.cta_lsp_repo_statuses(); let Some(label) = Self::button_label_for_cta_statuses(&cta_statuses) else { // No CTA-worthy statuses remain — hide the button. self.enable_lsp_button = None; return; }; if let Some(enable_button) = &self.enable_lsp_button { enable_button.update(ctx, |button, ctx| { button.set_label(label, ctx); }); } else { self.enable_lsp_button = Some(ctx.add_typed_action_view(|_ctx| { ActionButton::new(label, NakedTheme) .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(CodeFooterViewAction::EnableLSP); }) })); } } /// Subscribes to a single server's events and adds it. Used in SingleFile mode. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn subscribe_to_server_events( &mut self, lsp_server: &ModelHandle, ctx: &mut ViewContext, ) { ctx.subscribe_to_model(lsp_server, |_, _, _, ctx| { ctx.notify(); }); self.lsp_servers = vec![lsp_server.downgrade()]; self.subscribed_server_ids = vec![lsp_server.as_ref(ctx).id()]; if let FooterMode::SingleFile { lsp_repo_status, .. } = &mut self.mode { *lsp_repo_status = LspRepoStatus::Ready; } self.lsp_status_button.update(ctx, |button, ctx| { button.set_disabled(false, ctx); }); ctx.notify(); } /// Clears the server subscription when a server is removed from the manager. /// This resets the footer to the "no server" state and kicks off installation detection. /// Used in SingleFile mode. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn clear_server_subscription(&mut self, ctx: &mut ViewContext) { self.lsp_servers.clear(); self.subscribed_server_ids.clear(); self.is_lsp_menu_open = false; self.lsp_status_button.update(ctx, |button, ctx| { button.set_disabled(true, ctx); }); // Set initial status and kick off installation status detection #[cfg(feature = "local_fs")] if let FooterMode::SingleFile { path, lsp_repo_status, .. } = &mut self.mode { *lsp_repo_status = Self::detect_installation_status(path, ctx); self.update_enable_button_label(ctx); } #[cfg(not(feature = "local_fs"))] if let FooterMode::SingleFile { lsp_repo_status, .. } = &mut self.mode { *lsp_repo_status = LspRepoStatus::CheckingForInstallation; } ctx.notify(); } fn render_indicator( lsp_icon: Box, color: ColorU, background_color: ColorU, ) -> Box { let circle = Container::new(Empty::new().finish()) .with_background(Fill::Solid(color)) .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))) .with_border(Border::all(1.5).with_border_fill(background_color)) .finish(); let inner_size = INDICATOR_SIZE; let inner_circle_constrained = ConstrainedBox::new(circle) .with_width(inner_size) .with_height(inner_size) .finish(); let mut stack = Stack::new(); stack.add_child(lsp_icon); let badge_offset = vec2f(-2., 2.); stack.add_positioned_child( inner_circle_constrained, OffsetPositioning::offset_from_parent( badge_offset, ParentOffsetBounds::Unbounded, ParentAnchor::TopRight, ChildAnchor::Center, ), ); stack.finish() } fn render_menu_title( title: String, background: ThemeFill, appearance: &Appearance, ) -> Box { appearance .ui_builder() .span(title) .with_style(UiComponentStyles { font_color: Some(appearance.theme().disabled_text_color(background).into()), font_size: Some(12.), ..Default::default() }) .build() .with_vertical_padding(4.) .with_horizontal_padding(16.) .finish() } fn render_server_row(server: &LspServerModel, appearance: &Appearance) -> Box { let render_status = LSPServerRenderStatus::render_status(Some(server)); let failed_error = match server.state() { LspModelState::Failed { error } => Some(error.clone()), _ => None, }; let background = appearance.theme().surface_2(); let mut text_col = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child( appearance .ui_builder() .span(server.server_name()) .build() .finish(), ); if let Some(error) = failed_error.filter(|e| !e.trim().is_empty()) { text_col.add_child( appearance .ui_builder() .span(error) .with_style(UiComponentStyles { font_color: Some( appearance.theme().sub_text_color(background).into_solid(), ), ..Default::default() }) .build() .finish(), ); } Flex::row() .with_child( Container::new( ConstrainedBox::new( Container::new(Empty::new().finish()) .with_background(Fill::Solid( render_status.to_icon_color(appearance.theme()), )) .with_uniform_margin(2.) .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))) .finish(), ) .with_width(14.) .with_height(14.) .finish(), ) .with_padding_right(8.) .finish(), ) .with_child(Shrinkable::new(1., text_col.finish()).finish()) .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_main_axis_size(MainAxisSize::Max) .finish() } fn render_menu_separator(appearance: &Appearance) -> Box { Container::new( ConstrainedBox::new( Rect::new() .with_background_color(blended_colors::neutral_4(appearance.theme())) .finish(), ) .with_height(1.) .finish(), ) .with_vertical_padding(4.) .finish() } fn wrap_menu_in_dismiss(col: Flex, appearance: &Appearance) -> Box { let background = appearance.theme().surface_2(); Dismiss::new( ConstrainedBox::new( Container::new(col.finish()) .with_vertical_padding(8.) .with_background(background) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) .with_border( Border::all(1.) .with_border_color(internal_colors::neutral_4(appearance.theme())), ) .finish(), ) .with_width(320.) .finish(), ) .on_dismiss(|ctx, _app| ctx.dispatch_typed_action(CodeFooterViewAction::CloseMenu)) .prevent_interaction_with_other_elements() .finish() } /// Renders the menu for SingleFile mode (single server detail + per-server actions). fn render_single_server_menu( &self, model: &LspServerModel, mouse_states: &SingleFileMouseStates, appearance: &Appearance, app: &AppContext, ) -> Box { let title = PersistedWorkspace::as_ref(app) .root_for_workspace(self.mode.path()) .and_then(|path| path.file_name()) .and_then(|directory_name| directory_name.to_str().map(|s| s.to_string())) .unwrap_or("unknown workspace".to_string()); let background = appearance.theme().surface_2(); let mut col = Flex::column() .with_child(Self::render_menu_title(title, background, appearance)) .with_child( Container::new(Self::render_server_row(model, appearance)) .with_vertical_padding(4.) .with_horizontal_padding(16.) .finish(), ); // Add separator and action items based on server status let render_status = LSPServerRenderStatus::render_status(Some(model)); let has_actions = matches!( render_status, LSPServerRenderStatus::Available | LSPServerRenderStatus::Stopped | LSPServerRenderStatus::Failed ); if has_actions { col.add_child(Self::render_menu_separator(appearance)); } if matches!(render_status, LSPServerRenderStatus::Available) { col.add_child(Self::render_open_logs_menu_item(mouse_states, appearance)); col.add_child(Self::render_restart_server_menu_item( mouse_states, appearance, )); col.add_child(Self::render_stop_server_menu_item(mouse_states, appearance)); } if matches!(render_status, LSPServerRenderStatus::Stopped) { col.add_child(Self::render_start_server_menu_item( mouse_states, appearance, )); } if matches!(render_status, LSPServerRenderStatus::Failed) { col.add_child(Self::render_open_logs_menu_item(mouse_states, appearance)); col.add_child(Self::render_restart_server_menu_item( mouse_states, appearance, )); col.add_child(Self::render_remove_server_menu_item( mouse_states, appearance, )); } Self::wrap_menu_in_dismiss(col, appearance) } /// Renders the menu for Workspace mode (multi-server list + global actions). fn render_workspace_menu( &self, mouse_states: &WorkspaceMouseStates, appearance: &Appearance, app: &AppContext, ) -> Box { let title = self .mode .path() .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown workspace") .to_string(); let background = appearance.theme().surface_2(); let mut col = Flex::column().with_child(Self::render_menu_title(title, background, appearance)); // Render a row per server let mut has_running = false; let mut has_stopped = false; let mut server_count: usize = 0; for weak in &self.lsp_servers { let Some(server) = weak.upgrade(app) else { continue; }; server_count += 1; let server_ref = server.as_ref(app); match LSPServerRenderStatus::render_status(Some(server_ref)) { LSPServerRenderStatus::Available | LSPServerRenderStatus::Busy => { has_running = true; } LSPServerRenderStatus::Stopped | LSPServerRenderStatus::Failed => { has_stopped = true; } } col.add_child( Container::new(Self::render_server_row(server_ref, appearance)) .with_vertical_padding(4.) .with_horizontal_padding(16.) .finish(), ); } // Separator + conditional global actions based on server states col.add_child(Self::render_menu_separator(appearance)); let is_plural = server_count > 1; if has_running { col.add_child(Self::render_restart_all_servers_menu_item( mouse_states, is_plural, appearance, )); col.add_child(Self::render_stop_all_servers_menu_item( mouse_states, is_plural, appearance, )); } if has_stopped { col.add_child(Self::render_start_all_servers_menu_item( mouse_states, has_running, is_plural, appearance, )); } col.add_child(Self::render_manage_servers_menu_item( mouse_states, appearance, )); Self::wrap_menu_in_dismiss(col, appearance) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] /// Generic rendering function for each action row in the menu. Note that we need to take in a closure here /// as Hoverable needs to dynamically construct its inner element. fn render_menu_item Box>( appearance: &Appearance, mouse_state: MouseStateHandle, icon_creator: F, label: &'static str, action: CodeFooterViewAction, ) -> Box { let theme = appearance.theme().clone(); let background = theme.surface_2(); let ui_builder = appearance.ui_builder().clone(); Hoverable::new(mouse_state, move |state| { let is_hovered = state.is_hovered(); let text_color = theme.main_text_color(background).into(); let icon_size = 14.; let icon = ConstrainedBox::new(icon_creator()) .with_width(icon_size) .with_height(icon_size) .finish(); let label_element = ui_builder .span(label) .with_style(UiComponentStyles { font_color: Some(text_color), ..Default::default() }) .build() .finish(); let row = Flex::row() .with_child(Container::new(icon).with_padding_right(8.).finish()) .with_child(label_element) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .finish(); let hover_background = if is_hovered { Some(Fill::Solid(internal_colors::neutral_4(&theme))) } else { None }; let container = Container::new(row) .with_horizontal_padding(16.) .with_vertical_padding(4.); let container = if let Some(bg) = hover_background { container.with_background(bg) } else { container }; container.finish() }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(action.clone()); }) .finish() } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn render_open_logs_menu_item( mouse_states: &SingleFileMouseStates, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background = theme.surface_2(); let text_color: ColorU = theme.main_text_color(background).into(); Self::render_menu_item( appearance, mouse_states.open_logs.clone(), move || { Icon::Code1 .to_galaxyui_icon(ThemeFill::Solid(text_color)) .finish() }, "Open logs", CodeFooterViewAction::OpenLogs, ) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn render_restart_server_menu_item( mouse_states: &SingleFileMouseStates, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background = theme.surface_2(); let text_color: ColorU = theme.main_text_color(background).into(); Self::render_menu_item( appearance, mouse_states.restart_server.clone(), move || { Icon::RefreshCcw .to_galaxyui_icon(ThemeFill::Solid(text_color)) .finish() }, "Restart server", CodeFooterViewAction::RestartServer, ) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn render_stop_server_menu_item( mouse_states: &SingleFileMouseStates, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let stop_color: ColorU = AnsiColorIdentifier::Red .to_ansi_color(&theme.terminal_colors().bright) .into(); Self::render_menu_item( appearance, mouse_states.stop_server.clone(), move || { Container::new(Rect::new().with_background_color(stop_color).finish()) .with_uniform_padding(2.) .finish() }, "Stop server", CodeFooterViewAction::StopServer, ) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn render_start_server_menu_item( mouse_states: &SingleFileMouseStates, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background = theme.surface_2(); let text_color: ColorU = theme.main_text_color(background).into(); Self::render_menu_item( appearance, mouse_states.start_server.clone(), move || { Icon::Play .to_galaxyui_icon(ThemeFill::Solid(text_color)) .finish() }, "Start server", CodeFooterViewAction::StartServer, ) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn render_remove_server_menu_item( mouse_states: &SingleFileMouseStates, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background = theme.surface_2(); let text_color: ColorU = theme.main_text_color(background).into(); Self::render_menu_item( appearance, mouse_states.remove_server.clone(), move || { Icon::Trash .to_galaxyui_icon(ThemeFill::Solid(text_color)) .finish() }, "Remove server", CodeFooterViewAction::RemoveServer, ) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn render_restart_all_servers_menu_item( mouse_states: &WorkspaceMouseStates, is_plural: bool, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background = theme.surface_2(); let text_color: ColorU = theme.main_text_color(background).into(); Self::render_menu_item( appearance, mouse_states.restart_all.clone(), move || { Icon::RefreshCcw .to_galaxyui_icon(ThemeFill::Solid(text_color)) .finish() }, if is_plural { "Restart all servers" } else { "Restart server" }, CodeFooterViewAction::RestartAllServers, ) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn render_stop_all_servers_menu_item( mouse_states: &WorkspaceMouseStates, is_plural: bool, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let stop_color: ColorU = AnsiColorIdentifier::Red .to_ansi_color(&theme.terminal_colors().bright) .into(); Self::render_menu_item( appearance, mouse_states.stop_all.clone(), move || { Container::new(Rect::new().with_background_color(stop_color).finish()) .with_uniform_padding(2.) .finish() }, if is_plural { "Stop all servers" } else { "Stop server" }, CodeFooterViewAction::StopAllServers, ) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn render_start_all_servers_menu_item( mouse_states: &WorkspaceMouseStates, has_running: bool, is_plural: bool, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background = theme.surface_2(); let text_color: ColorU = theme.main_text_color(background).into(); Self::render_menu_item( appearance, mouse_states.start_all.clone(), move || { Icon::Play .to_galaxyui_icon(ThemeFill::Solid(text_color)) .finish() }, if !is_plural { "Start server" } else if has_running { "Start all stopped servers" } else { "Start all servers" }, CodeFooterViewAction::StartAllServers, ) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn render_manage_servers_menu_item( mouse_states: &WorkspaceMouseStates, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background = theme.surface_2(); let text_color: ColorU = theme.main_text_color(background).into(); Self::render_menu_item( appearance, mouse_states.manage_servers.clone(), move || { Icon::Gear .to_galaxyui_icon(ThemeFill::Solid(text_color)) .finish() }, "Manage servers", CodeFooterViewAction::ManageServers, ) } /// Computes the aggregate indicator color across all tracked servers. /// Priority: Failed > Busy > Stopped > Available. fn aggregate_indicator_color(&self, theme: &WarpTheme, app: &AppContext) -> ColorU { if self.lsp_servers.is_empty() { return LSPServerRenderStatus::Stopped.to_icon_color(theme); } let mut worst = LSPServerRenderStatus::Available; for weak in &self.lsp_servers { let Some(server) = weak.upgrade(app) else { continue; }; let status = LSPServerRenderStatus::render_status(Some(server.as_ref(app))); worst = match (&worst, &status) { (_, LSPServerRenderStatus::Failed) => LSPServerRenderStatus::Failed, (LSPServerRenderStatus::Failed, _) => LSPServerRenderStatus::Failed, (_, LSPServerRenderStatus::Busy) => LSPServerRenderStatus::Busy, (LSPServerRenderStatus::Busy, _) => LSPServerRenderStatus::Busy, (_, LSPServerRenderStatus::Stopped) => LSPServerRenderStatus::Stopped, (LSPServerRenderStatus::Stopped, _) => LSPServerRenderStatus::Stopped, _ => LSPServerRenderStatus::Available, }; } worst.to_icon_color(theme) } /// Returns strong handles for all servers that can still be upgraded. fn live_servers(&self, app: &AppContext) -> Vec> { self.lsp_servers .iter() .filter_map(|weak| weak.upgrade(app)) .collect() } fn render_lsp_icon(&self, appearance: &Appearance, app: &AppContext) -> Box { if self.is_tab_config_footer() { return Empty::new().finish(); } let theme = appearance.theme(); let lsp_icon = ChildView::new(&self.lsp_status_button).finish(); let background_color = theme.background().into_solid(); let indicator_color = self.aggregate_indicator_color(theme, app); let indicator = Self::render_indicator(lsp_icon, indicator_color, background_color); let live = self.live_servers(app); if !self.is_lsp_menu_open || live.is_empty() { return indicator; } let menu = match &self.mode { FooterMode::TabConfig { .. } => return indicator, FooterMode::SingleFile { mouse_states, .. } => { let server = live[0].as_ref(app); self.render_single_server_menu(server, mouse_states, appearance, app) } FooterMode::Workspace { mouse_states, .. } => { self.render_workspace_menu(mouse_states, appearance, app) } }; let mut element = Stack::new().with_child(indicator); element.add_positioned_child( menu, OffsetPositioning::offset_from_parent( vec2f(0., -2.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopLeft, ChildAnchor::BottomLeft, ), ); element.finish() } fn render_status_text( theme: &WarpTheme, appearance: &Appearance, message: String, ) -> Box { let status_content = appearance .ui_builder() .span(message) .with_style(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_color: Some(internal_colors::text_sub(theme, theme.background())), font_size: Some(12.0), ..Default::default() }) .build() .finish(); // Left margin only to separate from the icon; right margin removed to tighten // spacing between status text and the enable button action Container::new(status_content) .with_margin_left(ICON_MARGIN) .finish() } /// Returns a status message for a single server, if any. fn server_status_message(server: &LspServerModel) -> Option { match server.state() { LspModelState::Available { .. } | LspModelState::Starting => server .latest_progress_update() .map(|update| update.to_display_message()) .filter(|msg| !msg.trim().is_empty()) .map(|msg| format!("{}: {msg}", server.server_name())), LspModelState::Stopped { .. } | LspModelState::Stopping { .. } => { Some(format!("{}: stopped", server.server_name())) } LspModelState::Failed { .. } => Some(format!("{}: error", server.server_name())), } } /// Returns the CTA message and button flag for workspace mode if any servers /// are disabled and need user action. Returns `None` if not in workspace mode /// or no CTA is needed. fn workspace_cta_message(&self) -> Option<(Option, bool)> { let FooterMode::Workspace { root_path, lsp_repo_statuses, .. } = &self.mode else { return None; }; if lsp_repo_statuses.is_empty() { return None; } let has_cta = lsp_repo_statuses.values().any(|s| { matches!( s, LspRepoStatus::DisabledAndInstalled { .. } | LspRepoStatus::DisabledAndNotInstalled { .. } ) }); if has_cta { let root_name = root_path .file_name() .and_then(|s| s.to_str()) .unwrap_or("this workspace"); Some(( Some(format!( "Language support is not currently enabled for {root_name}" )), true, )) } else { None } } /// Computes the aggregated status message across all servers. /// Priority: failed error > starting/progress text > stopped text. fn compute_status_message(&self, app: &AppContext) -> (Option, bool) { if self.is_tab_config_footer() { return (None, false); } let live = self.live_servers(app); if !live.is_empty() { // Check for any failed server first for server in &live { let server_ref = server.as_ref(app); if let LspModelState::Failed { error } = server_ref.state() { return ( Some(format!("{}: {error}", server_ref.server_name())), false, ); } } // Then check for any starting/busy server for server in &live { let server_ref = server.as_ref(app); if let Some(msg) = Self::server_status_message(server_ref) { if matches!( server_ref.state(), LspModelState::Starting | LspModelState::Available { .. } ) { return (Some(msg), false); } } } // Then check stopped for server in &live { let server_ref = server.as_ref(app); if matches!( server_ref.state(), LspModelState::Stopped { .. } | LspModelState::Stopping { .. } ) { return ( Some(format!("{}: stopped", server_ref.server_name())), false, ); } } // All servers are available with no progress — but in workspace mode, // there may still be disabled servers that need a CTA. if let Some(cta) = self.workspace_cta_message() { return cta; } return (None, false); } // No servers — show enablement CTA based on mode match &self.mode { FooterMode::TabConfig { .. } => (None, false), FooterMode::SingleFile { path, lsp_repo_status, .. } => match PersistedWorkspace::as_ref(app).has_enabled_lsp_server_for_file_path(path) { LSPEnablementResultForFile::UnsupportedLanguage => ( Some("Language support is unavailable for this file type".to_string()), false, ), LSPEnablementResultForFile::LSPNotEnabled { root_name } => match lsp_repo_status { LspRepoStatus::CheckingForInstallation => ( Some(format!( "Language support is not currently enabled for {}", root_name.unwrap_or("this codebase".to_string()) )), false, ), LspRepoStatus::Ready | LspRepoStatus::Enabled => ( Some("Language server is unavailable for this codebase".to_string()), false, ), LspRepoStatus::DisabledAndNotInstalled { .. } | LspRepoStatus::DisabledAndInstalled { .. } => ( Some(format!( "Language support is not currently enabled for {}", root_name.unwrap_or("this codebase".to_string()) )), true, ), LspRepoStatus::Installing { server_type } => ( Some(format!("Installing {}...", server_type.binary_name())), false, ), }, LSPEnablementResultForFile::Enabled => (None, false), }, FooterMode::Workspace { root_path, lsp_repo_statuses, .. } => { if lsp_repo_statuses.is_empty() { // Still waiting for async detection return (None, false); } let root_name = root_path .file_name() .and_then(|s| s.to_str()) .unwrap_or("this workspace"); // Check if any server has a CTA-worthy status if let Some(cta) = self.workspace_cta_message() { return cta; } // Check if any server is installing for status in lsp_repo_statuses.values() { if let LspRepoStatus::Installing { server_type } = status { return ( Some(format!("Installing {}...", server_type.binary_name())), false, ); } } // Check if all are still checking let all_checking = lsp_repo_statuses .values() .all(|s| matches!(s, LspRepoStatus::CheckingForInstallation)); if all_checking { return (None, false); } // All servers are enabled/ready but no live servers — unavailable ( Some(format!("Language support is unavailable for {root_name}")), false, ) } } } } #[derive(Clone)] #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] pub enum CodeFooterViewEvent { RunTabConfigSkill { path: PathBuf, }, EnableLSP { path: PathBuf, server_type: Option, }, InstallAndEnableLSP { path: PathBuf, server_type: Option, }, OpenLogs { path: PathBuf, }, RestartServer { server: ModelHandle, }, StopServer { server: ModelHandle, }, StartServer { server: ModelHandle, }, RestartAllServers { servers: Vec>, }, StopAllServers { servers: Vec>, }, StartAllServers { servers: Vec>, }, ManageServers, } impl Entity for CodeFooterView { type Event = CodeFooterViewEvent; } impl View for CodeFooterView { fn ui_name() -> &'static str { "CodeFooterView" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let mut footer_content = Flex::row() .with_main_axis_alignment(MainAxisAlignment::Start) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max); if self.is_tab_config_footer() { footer_content.add_child(Self::render_tab_config_info_icon(theme)); footer_content.add_child( Shrinkable::new( 1., Self::render_status_text( theme, appearance, "Use Oz to update this config".to_string(), ), ) .finish(), ); if let Some(tab_config_skill_button) = &self.tab_config_skill_button { footer_content.add_child( Container::new(ChildView::new(tab_config_skill_button).finish()) .with_margin_left(ICON_MARGIN) .finish(), ); } } else { footer_content.add_child(self.render_lsp_icon(appearance, app)); let (status_message, should_show_enable_button) = self.compute_status_message(app); if let Some(status_message) = status_message { footer_content.add_child( Shrinkable::new( 1., Self::render_status_text(theme, appearance, status_message), ) .finish(), ); } if should_show_enable_button { if let Some(enable_lsp) = &self.enable_lsp_button { // Left margin only to separate from status text; right margin removed // to tighten padding between elements footer_content.add_child( Container::new(ChildView::new(enable_lsp).finish()) .with_margin_left(ICON_MARGIN) .finish(), ); } } } let mut container = Container::new( ConstrainedBox::new( Container::new(footer_content.finish()) .with_padding(Padding::uniform(4.0)) .finish(), ) .with_height(FOOTER_HEIGHT) .finish(), ) .with_background_color(theme.background().into()); if self.show_border { container = container .with_border(Border::top(2.0).with_border_fill(internal_colors::neutral_2(theme))) .with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.))); } container.finish() } } impl TypedActionView for CodeFooterView { type Action = CodeFooterViewAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { CodeFooterViewAction::ToggleMenu => { self.is_lsp_menu_open = !self.is_lsp_menu_open; ctx.notify(); } CodeFooterViewAction::CloseMenu => { self.is_lsp_menu_open = false; ctx.notify(); } CodeFooterViewAction::RunTabConfigSkill => { let FooterMode::TabConfig { path } = &self.mode else { return; }; ctx.emit(CodeFooterViewEvent::RunTabConfigSkill { path: path.clone() }); } CodeFooterViewAction::EnableLSP => { let path = self.mode.path().to_path_buf(); let cta_statuses: Vec = self .mode .cta_lsp_repo_statuses() .into_iter() .cloned() .collect(); for status in &cta_statuses { let needed_install = matches!(status, LspRepoStatus::DisabledAndNotInstalled { .. }); let server_type = match status { LspRepoStatus::DisabledAndInstalled { server_type } | LspRepoStatus::DisabledAndNotInstalled { server_type } => { Some(*server_type) } _ => None, }; if let Some(st) = server_type { send_telemetry_from_ctx!( LspTelemetryEvent::ServerEnabled { server_type: st.binary_name().to_string(), source: LspEnablementSource::FooterButton, needed_install, }, ctx ); } if needed_install { ctx.emit(CodeFooterViewEvent::InstallAndEnableLSP { path: path.clone(), server_type, }); } else { ctx.emit(CodeFooterViewEvent::EnableLSP { path: path.clone(), server_type, }); } } } CodeFooterViewAction::OpenLogs => { self.is_lsp_menu_open = false; let server_name = self .lsp_servers .first() .and_then(|w| w.upgrade(ctx)) .map(|s| s.as_ref(ctx).server_name()); send_telemetry_from_ctx!( LspTelemetryEvent::ControlAction { action: LspControlActionType::OpenLogs, server_type: server_name, }, ctx ); ctx.emit(CodeFooterViewEvent::OpenLogs { path: self.mode.path().to_path_buf(), }); ctx.notify(); } CodeFooterViewAction::RestartServer => { self.is_lsp_menu_open = false; if let Some(server) = self.lsp_servers.first().and_then(|w| w.upgrade(ctx)) { send_telemetry_from_ctx!( LspTelemetryEvent::ControlAction { action: LspControlActionType::Restart, server_type: Some(server.as_ref(ctx).server_name()), }, ctx ); ctx.emit(CodeFooterViewEvent::RestartServer { server: server.clone(), }); } ctx.notify(); } CodeFooterViewAction::StopServer => { self.is_lsp_menu_open = false; if let Some(server) = self.lsp_servers.first().and_then(|w| w.upgrade(ctx)) { send_telemetry_from_ctx!( LspTelemetryEvent::ControlAction { action: LspControlActionType::Stop, server_type: Some(server.as_ref(ctx).server_name()), }, ctx ); ctx.emit(CodeFooterViewEvent::StopServer { server: server.clone(), }); } ctx.notify(); } CodeFooterViewAction::StartServer => { self.is_lsp_menu_open = false; if let Some(server) = self.lsp_servers.first().and_then(|w| w.upgrade(ctx)) { send_telemetry_from_ctx!( LspTelemetryEvent::ControlAction { action: LspControlActionType::Start, server_type: Some(server.as_ref(ctx).server_name()), }, ctx ); ctx.emit(CodeFooterViewEvent::StartServer { server: server.clone(), }); } ctx.notify(); } CodeFooterViewAction::RemoveServer => { self.is_lsp_menu_open = false; if let Some(server) = self.lsp_servers.first().and_then(|w| w.upgrade(ctx)) { let workspace_root = server.as_ref(ctx).initial_workspace().to_path_buf(); let server_type = server.as_ref(ctx).server_type(); send_telemetry_from_ctx!( LspTelemetryEvent::ServerRemoved { server_type: server_type.binary_name().to_string(), source: LspEnablementSource::FooterButton, }, ctx ); // Remove from manager (stops and removes) LspManagerModel::handle(ctx).update(ctx, |manager, ctx| { manager.remove_server(&workspace_root, server_type, ctx); }); // Disable in PersistedWorkspace PersistedWorkspace::handle(ctx).update(ctx, |workspace, _| { workspace.disable_lsp_server_for_path(&workspace_root, server_type); }); } ctx.notify(); } CodeFooterViewAction::RestartAllServers => { self.is_lsp_menu_open = false; send_telemetry_from_ctx!( LspTelemetryEvent::ControlAction { action: LspControlActionType::RestartAll, server_type: None, }, ctx ); let live = self.live_servers(ctx); ctx.emit(CodeFooterViewEvent::RestartAllServers { servers: live }); ctx.notify(); } CodeFooterViewAction::StopAllServers => { self.is_lsp_menu_open = false; send_telemetry_from_ctx!( LspTelemetryEvent::ControlAction { action: LspControlActionType::StopAll, server_type: None, }, ctx ); let live = self.live_servers(ctx); ctx.emit(CodeFooterViewEvent::StopAllServers { servers: live }); ctx.notify(); } CodeFooterViewAction::StartAllServers => { self.is_lsp_menu_open = false; let live = self.live_servers(ctx); ctx.emit(CodeFooterViewEvent::StartAllServers { servers: live }); ctx.notify(); } CodeFooterViewAction::ManageServers => { self.is_lsp_menu_open = false; ctx.emit(CodeFooterViewEvent::ManageServers); ctx.notify(); } } } }