Files
galaxy/app/src/code/footer.rs
T

1990 lines
72 KiB
Rust

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<WeakModelHandle<LspServerModel>>,
/// Tracks the server IDs we've already subscribed to, so we don't duplicate subscriptions.
subscribed_server_ids: Vec<LanguageServerId>,
lsp_status_button: ViewHandle<ActionButton>,
enable_lsp_button: Option<ViewHandle<ActionButton>>,
tab_config_skill_button: Option<ViewHandle<ActionButton>>,
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<LSPServerType, LspRepoStatus>,
}
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<LspServerModel>],
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<Item = &LspRepoStatus> {
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<Self>) -> ViewHandle<ActionButton> {
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<dyn Element> {
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<Self>) {
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<Self>,
) -> ViewHandle<ActionButton> {
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>) -> 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>) -> 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<Self>) {
let FooterMode::Workspace { root_path, .. } = &self.mode else {
return;
};
let lsp_manager = LspManagerModel::handle(ctx);
let new_servers: Vec<ModelHandle<LspServerModel>> = 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<String> {
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<String> {
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<Self>,
) -> 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<Self>) {
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<LspServerModel>,
ctx: &mut ViewContext<Self>,
) {
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>) {
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<dyn Element>,
color: ColorU,
background_color: ColorU,
) -> Box<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<F: 'static + Fn() -> Box<dyn Element>>(
appearance: &Appearance,
mouse_state: MouseStateHandle,
icon_creator: F,
label: &'static str,
action: CodeFooterViewAction,
) -> Box<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<ModelHandle<LspServerModel>> {
self.lsp_servers
.iter()
.filter_map(|weak| weak.upgrade(app))
.collect()
}
fn render_lsp_icon(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
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<dyn Element> {
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<String> {
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<String>, 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<String>, 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<LSPServerType>,
},
InstallAndEnableLSP {
path: PathBuf,
server_type: Option<LSPServerType>,
},
OpenLogs {
path: PathBuf,
},
RestartServer {
server: ModelHandle<LspServerModel>,
},
StopServer {
server: ModelHandle<LspServerModel>,
},
StartServer {
server: ModelHandle<LspServerModel>,
},
RestartAllServers {
servers: Vec<ModelHandle<LspServerModel>>,
},
StopAllServers {
servers: Vec<ModelHandle<LspServerModel>>,
},
StartAllServers {
servers: Vec<ModelHandle<LspServerModel>>,
},
ManageServers,
}
impl Entity for CodeFooterView {
type Event = CodeFooterViewEvent;
}
impl View for CodeFooterView {
fn ui_name() -> &'static str {
"CodeFooterView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
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<Self>) {
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<LspRepoStatus> = 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();
}
}
}
}