Files
galaxy/app/src/settings_view/mcp_servers/server_card.rs
T

1099 lines
40 KiB
Rust

use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use pathfinder_color::ColorU;
use galaxy_core::{
features::FeatureFlag,
ui::{
external_product_icon::ExternalProductIcon,
icons::{Icon, ICON_DIMENSIONS},
theme::{color::internal_colors, AnsiColorIdentifier},
},
};
use galaxyui::{
accessibility::ActionAccessibilityContent,
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex,
FormattedTextElement, HighlightedHyperlink, Hoverable, MainAxisAlignment, MainAxisSize,
MouseState, MouseStateHandle, Padding, ParentElement, Radius, Text, Wrap,
},
fonts::Weight,
platform::Cursor,
ui_components::{
button::ButtonVariant,
chip::Chip,
components::{Coords, UiComponent, UiComponentStyles},
switch::SwitchStateHandle,
},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
use crate::{
ai::mcp::{
templatable::CloudTemplatableMCPServer, MCPServerState, TemplatableMCPServerManager,
},
appearance::Appearance,
cloud_object::CloudObject,
settings_view::mcp_servers::{style, ServerCardItemId},
ui_components::{
avatar::{Avatar, AvatarContent, StatusElementTypes},
blended_colors,
buttons::icon_button,
red_notification_dot::RedNotificationDot,
},
};
/// A chip displayed inline with the server card title, optionally with a leading icon.
#[derive(Debug, Clone)]
pub struct TitleChip {
pub text: String,
pub leading_icon: Option<Icon>,
}
impl TitleChip {
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
leading_icon: None,
}
}
pub fn with_icon(text: impl Into<String>, icon: Icon) -> Self {
Self {
text: text.into(),
leading_icon: Some(icon),
}
}
}
#[derive(Debug, Clone)]
pub enum ServerCardAction {
ToggleToolsExpanded,
ToggleRunningSwitch,
Edit(ServerCardItemId),
Share(ServerCardItemId),
Install(ServerCardItemId),
InstallServerUpdate(ServerCardItemId),
ViewLogs(ServerCardItemId),
LogOut(ServerCardItemId),
FullCardClick,
}
#[derive(Debug, Clone)]
pub enum ServerCardEvent {
Edit(ServerCardItemId),
Share(ServerCardItemId),
ToggleRunningSwitch(ServerCardItemId, bool),
Install(ServerCardItemId),
InstallServerUpdate(ServerCardItemId),
ViewLogs(ServerCardItemId),
LogOut(ServerCardItemId),
}
#[derive(Default)]
pub struct ServerCardMouseHandles {
show_logs_icon_button: MouseStateHandle,
logout_icon_button: MouseStateHandle,
share_icon_button: MouseStateHandle,
edit_icon_button: MouseStateHandle,
update_icon_button: MouseStateHandle,
view_logs_button: MouseStateHandle,
edit_config_button: MouseStateHandle,
setup_button: MouseStateHandle,
tools_expandable_hover: MouseStateHandle,
card_hover: MouseStateHandle,
}
pub enum StatusColor {
Red,
Yellow,
Green,
Neutral,
}
impl StatusColor {
fn to_color(&self, appearance: &Appearance) -> ColorU {
match self {
StatusColor::Red => appearance.theme().ui_error_color(),
StatusColor::Yellow => appearance.theme().ansi_fg_yellow(),
StatusColor::Green => appearance.theme().ansi_fg_green(),
StatusColor::Neutral => {
blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1())
}
}
}
}
pub struct StatusElement {
indicator_type: StatusElementTypes,
color: StatusColor,
}
#[derive(Default)]
pub enum Background {
#[default]
Transparent,
Filled,
}
#[derive(Default)]
pub struct ServerCardOptions {
pub show_view_logs_icon_button: bool,
pub show_log_out_icon_button: bool,
pub show_share_icon_button: bool,
pub show_edit_config_icon_button: bool,
pub show_update_available_icon_button: bool,
pub show_view_logs_text_button: bool,
pub show_edit_config_text_button: bool,
pub show_setup_text_button: bool,
pub show_add_icon: bool,
pub full_card_clickable: bool,
pub server_running_switch_state: Option<bool>,
pub status_indicator: Option<StatusElement>,
pub status_line: Option<String>,
pub background: Background,
}
#[derive(PartialEq, Clone, Copy)]
pub enum ServerCardStatus {
AvailableToSave,
SavedToDrive,
Installed,
StartingServer,
Authenticating,
Running,
ShuttingDown,
Error,
}
// TODO(aeybel): We'll want to get rid of this `from` function and the ServerCardStatus enum
impl From<MCPServerState> for ServerCardStatus {
fn from(state: MCPServerState) -> ServerCardStatus {
match state {
MCPServerState::NotRunning => ServerCardStatus::Installed,
MCPServerState::Starting => ServerCardStatus::StartingServer,
MCPServerState::Authenticating => ServerCardStatus::Authenticating,
MCPServerState::Running => ServerCardStatus::Running,
MCPServerState::ShuttingDown => ServerCardStatus::ShuttingDown,
MCPServerState::FailedToStart => ServerCardStatus::Error,
}
}
}
impl From<ServerCardStatus> for ServerCardOptions {
fn from(status: ServerCardStatus) -> ServerCardOptions {
match status {
ServerCardStatus::AvailableToSave => ServerCardOptions {
show_view_logs_icon_button: false,
show_log_out_icon_button: false,
show_share_icon_button: false,
show_edit_config_icon_button: false,
show_update_available_icon_button: false,
show_view_logs_text_button: false,
show_edit_config_text_button: false,
show_setup_text_button: false,
show_add_icon: true,
server_running_switch_state: None,
status_indicator: None,
status_line: None,
background: Background::Transparent,
full_card_clickable: true,
},
ServerCardStatus::SavedToDrive => ServerCardOptions {
show_view_logs_icon_button: false,
show_log_out_icon_button: false,
show_share_icon_button: false,
show_edit_config_icon_button: true,
show_update_available_icon_button: false,
show_view_logs_text_button: false,
show_edit_config_text_button: false,
show_setup_text_button: true,
show_add_icon: false,
server_running_switch_state: None,
status_indicator: None,
status_line: None,
background: Background::Filled,
full_card_clickable: false,
},
ServerCardStatus::Installed => ServerCardOptions {
show_view_logs_icon_button: true,
show_log_out_icon_button: false,
show_share_icon_button: false,
show_edit_config_icon_button: true,
show_update_available_icon_button: false,
show_view_logs_text_button: false,
show_edit_config_text_button: false,
show_setup_text_button: false,
show_add_icon: false,
server_running_switch_state: Some(false),
status_indicator: Some(StatusElement {
indicator_type: StatusElementTypes::Circle,
color: StatusColor::Neutral,
}),
status_line: Some("Offline".to_string()),
background: Background::Filled,
full_card_clickable: false,
},
ServerCardStatus::StartingServer => ServerCardOptions {
show_view_logs_icon_button: true,
show_log_out_icon_button: false,
show_share_icon_button: false,
show_edit_config_icon_button: true,
show_update_available_icon_button: false,
show_view_logs_text_button: false,
show_edit_config_text_button: false,
show_setup_text_button: false,
show_add_icon: false,
server_running_switch_state: Some(true),
status_indicator: Some(StatusElement {
indicator_type: StatusElementTypes::Circle,
color: StatusColor::Yellow,
}),
status_line: Some("Starting server...".to_string()),
background: Background::Filled,
full_card_clickable: false,
},
ServerCardStatus::Authenticating => ServerCardOptions {
show_view_logs_icon_button: true,
show_log_out_icon_button: false,
show_share_icon_button: false,
show_edit_config_icon_button: true,
show_update_available_icon_button: false,
show_view_logs_text_button: false,
show_edit_config_text_button: false,
show_setup_text_button: false,
show_add_icon: false,
server_running_switch_state: Some(true),
status_indicator: Some(StatusElement {
indicator_type: StatusElementTypes::Circle,
color: StatusColor::Yellow,
}),
status_line: Some("Authenticating...".to_string()),
background: Background::Filled,
full_card_clickable: false,
},
ServerCardStatus::Running => ServerCardOptions {
show_view_logs_icon_button: true,
show_log_out_icon_button: false,
show_share_icon_button: false,
show_edit_config_icon_button: true,
show_update_available_icon_button: false,
show_view_logs_text_button: false,
show_edit_config_text_button: false,
show_setup_text_button: false,
show_add_icon: false,
server_running_switch_state: Some(true),
status_indicator: Some(StatusElement {
indicator_type: StatusElementTypes::Circle,
color: StatusColor::Green,
}),
status_line: None,
background: Background::Filled,
full_card_clickable: false,
},
ServerCardStatus::ShuttingDown => ServerCardOptions {
show_view_logs_icon_button: true,
show_log_out_icon_button: false,
show_share_icon_button: false,
show_edit_config_icon_button: true,
show_update_available_icon_button: false,
show_view_logs_text_button: false,
show_edit_config_text_button: false,
show_setup_text_button: false,
show_add_icon: false,
server_running_switch_state: Some(false),
status_indicator: Some(StatusElement {
indicator_type: StatusElementTypes::Circle,
color: StatusColor::Neutral,
}),
status_line: Some("Shutting down...".to_string()),
background: Background::Filled,
full_card_clickable: false,
},
ServerCardStatus::Error => ServerCardOptions {
show_view_logs_icon_button: false,
show_log_out_icon_button: false,
show_share_icon_button: false,
show_edit_config_icon_button: false,
show_update_available_icon_button: false,
show_view_logs_text_button: true,
show_edit_config_text_button: true,
show_setup_text_button: false,
show_add_icon: false,
server_running_switch_state: Some(false),
status_indicator: Some(StatusElement {
indicator_type: StatusElementTypes::Icon(Icon::AlertTriangle),
color: StatusColor::Red,
}),
status_line: None,
background: Background::Filled,
full_card_clickable: false,
},
}
}
}
pub struct ServerCardView {
pub item_id: ServerCardItemId,
title: String,
description: Option<String>,
tools: Option<Vec<String>>,
error_text: Option<String>,
title_chips: Vec<TitleChip>,
render_options: ServerCardOptions,
switch_state_handle: SwitchStateHandle,
mouse_handles: ServerCardMouseHandles,
is_tools_expanded: bool,
}
impl ServerCardView {
pub fn new(
item_id: ServerCardItemId,
title: String,
description: Option<String>,
tools: Option<Vec<String>>,
error_text: Option<String>,
title_chips: Vec<TitleChip>,
render_options: ServerCardOptions,
) -> Self {
Self {
item_id,
title,
description,
tools,
error_text,
title_chips,
render_options,
switch_state_handle: Default::default(),
mouse_handles: Default::default(),
is_tools_expanded: false,
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn render_options(&self) -> &ServerCardOptions {
&self.render_options
}
fn render_server_icon_and_status(&self, appearance: &Appearance) -> Box<dyn Element> {
// TODO(aeybel) will want to use gallery ids instead of title in the future
// pending data model for the gallery items
let product_icon = ExternalProductIcon::from_string(self.title.as_str());
let avatar_content = if let Some(icon) = product_icon {
AvatarContent::ExternalProductIcon(icon)
} else {
AvatarContent::DisplayName(self.title.clone())
};
let mut avatar = Avatar::new(
avatar_content,
UiComponentStyles {
width: Some(32.),
height: Some(32.),
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
font_family_id: Some(appearance.ui_font_family()),
font_weight: Some(Weight::Bold),
background: Some(appearance.theme().background().into()),
font_size: Some(20.),
font_color: Some(blended_colors::text_main(
appearance.theme(),
appearance.theme().background(),
)),
..Default::default()
},
);
if let Some(status_indicator) = &self.render_options.status_indicator {
let status_indicator_circle_diameter: f32 = 8.;
let status_indicator_icon_diameter: f32 = 10.;
let status_style = match status_indicator.indicator_type {
StatusElementTypes::Circle => UiComponentStyles {
width: Some(status_indicator_circle_diameter),
height: Some(status_indicator_circle_diameter),
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
background: Some(Fill::Solid(status_indicator.color.to_color(appearance))),
..Default::default()
},
StatusElementTypes::Icon(_) => UiComponentStyles {
width: Some(status_indicator_icon_diameter),
height: Some(status_indicator_icon_diameter),
font_color: Some(status_indicator.color.to_color(appearance)),
..Default::default()
},
};
avatar = avatar.with_status_element_with_offset(
status_indicator.indicator_type.clone(),
status_style,
-5.,
5.,
);
}
avatar.build().finish()
}
fn render_tool_chips(tools: &[String], appearance: &Appearance) -> Vec<Box<dyn Element>> {
tools
.iter()
.map(|tool| {
Chip::new(
tool.to_string(),
UiComponentStyles {
margin: Some(Coords {
top: 0.,
bottom: 0.,
left: 0.,
right: 6.,
}),
font_family_id: Some(appearance.ui_font_family()),
font_size: Some(style::TOOL_CHIP_TEXT_SIZE),
font_color: Some(blended_colors::text_main(
appearance.theme(),
internal_colors::neutral_4(appearance.theme()),
)),
background: Some(internal_colors::neutral_4(appearance.theme()).into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(5.))),
..Default::default()
},
)
.build()
.finish()
})
.collect()
}
fn render_tools_expandable(
&self,
tools: &[String],
appearance: &Appearance,
) -> Box<dyn Element> {
let text_color =
blended_colors::text_sub(appearance.theme(), appearance.theme().background());
if tools.is_empty() {
return Text::new(
"No tools available".to_string(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(text_color)
.with_selectable(false)
.finish();
}
let chevron_icon = if self.is_tools_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let toggle_mouse_state = self.mouse_handles.tools_expandable_hover.clone();
let chevron_dimensions = 16.;
Hoverable::new(toggle_mouse_state, move |_is_hovered| {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new(
format!("{} tools available", tools.len()),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(text_color)
.with_selectable(false)
.finish(),
)
.with_child(
Container::new(
ConstrainedBox::new(
chevron_icon.to_galaxyui_icon(text_color.into()).finish(),
)
.with_width(chevron_dimensions)
.with_height(chevron_dimensions)
.finish(),
)
.with_margin_right(4.)
.finish(),
)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::ToggleToolsExpanded);
})
.finish()
}
fn render_title_chip(chip: &TitleChip, appearance: &Appearance) -> Box<dyn Element> {
let chip_color = appearance
.theme()
.sub_text_color(appearance.theme().surface_3())
.into_solid();
let text_element = Text::new(
chip.text.clone(),
appearance.ui_font_family(),
style::TITLE_CHIP_FONT_SIZE,
)
.with_color(chip_color)
.finish();
let inner: Box<dyn Element> = if let Some(icon) = chip.leading_icon {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(2.)
.with_child(
ConstrainedBox::new(icon.to_galaxyui_icon(chip_color.into()).finish())
.with_width(style::TITLE_CHIP_FONT_SIZE)
.with_height(style::TITLE_CHIP_FONT_SIZE)
.finish(),
)
.with_child(text_element)
.finish()
} else {
text_element
};
Container::new(inner)
.with_background(appearance.theme().surface_3())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
.with_horizontal_padding(3.)
.with_vertical_padding(1.)
.finish()
}
fn render_title_and_title_chip(
title: String,
title_chips: &[TitleChip],
appearance: &Appearance,
) -> Box<dyn Element> {
let title = Text::new(
title.clone(),
appearance.ui_font_family(),
appearance.ui_builder().ui_font_size(),
)
.with_color(blended_colors::text_main(
appearance.theme(),
appearance.theme().surface_1(),
))
.finish();
if title_chips.is_empty() {
return title;
}
let mut wrap = Wrap::row()
.with_spacing(style::SERVER_CARD_INTERIOR_SPACING)
.with_run_spacing(style::SERVER_CARD_INTERIOR_SPACING)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(title);
for chip in title_chips {
wrap = wrap.with_child(Self::render_title_chip(chip, appearance));
}
wrap.finish()
}
fn render_debug_lines(&self, app: &AppContext, appearance: &Appearance) -> Box<dyn Element> {
let mut lines = vec![format!("{}", self.item_id)];
match self.item_id {
ServerCardItemId::TemplatableMCP(template_uuid) => {
let cloud_server = CloudTemplatableMCPServer::get_by_uuid(&template_uuid, app);
if let Some(cloud_server) = cloud_server {
lines.push(format!("Template sync id: {}", cloud_server.sync_id()));
}
}
ServerCardItemId::TemplatableMCPInstallation(installation_uuid) => {
let installation = TemplatableMCPServerManager::as_ref(app)
.get_installed_server(&installation_uuid);
if let Some(installation) = installation {
let template_uuid = installation.template_uuid();
let gallery_uuid = installation.gallery_uuid();
let gallery_uuid_text = match gallery_uuid {
Some(uuid) => format!("Gallery Id: {uuid}"),
None => "Gallery Id: None".to_string(),
};
let cloud_server = CloudTemplatableMCPServer::get_by_uuid(&template_uuid, app);
let template_sync_id_text = match cloud_server {
Some(cloud_server) => {
format!("Template sync id: {}", cloud_server.sync_id())
}
None => "Could not find cloud template".to_string(),
};
lines.push(format!(
"{}",
ServerCardItemId::TemplatableMCP(template_uuid)
));
lines.push(gallery_uuid_text);
lines.push(template_sync_id_text);
}
}
ServerCardItemId::GalleryMCP(_) => {}
ServerCardItemId::FileBasedMCP(_) => {}
}
FormattedTextElement::new(
FormattedText::new(
lines
.into_iter()
.map(|line| {
FormattedTextLine::Line(vec![FormattedTextFragment::plain_text(line)])
})
.collect::<Vec<_>>(),
),
appearance.ui_builder().ui_font_size(),
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()),
HighlightedHyperlink::default(),
)
.finish()
}
fn add_subtitle_lines(
&self,
mut info_column: Flex,
appearance: &Appearance,
app: &AppContext,
) -> Flex {
if let Some(description) = &self.description {
info_column = info_column.with_child(
FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(description.clone()),
])]),
appearance.ui_builder().ui_font_size(),
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_disabled(
appearance.theme(),
appearance.theme().surface_1(),
),
HighlightedHyperlink::default(),
)
.finish(),
);
}
if let Some(status_line) = &self.render_options.status_line {
info_column = info_column.with_child(
FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(status_line.clone()),
])]),
appearance.ui_builder().ui_font_size(),
appearance.ui_font_family(),
appearance.ui_font_family(),
blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()),
HighlightedHyperlink::default(),
)
.finish(),
);
}
if let Some(error_text) = &self.error_text {
info_column = info_column.with_child(
FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(error_text.clone()),
])]),
appearance.ui_builder().ui_font_size(),
appearance.ui_font_family(),
appearance.ui_font_family(),
appearance.theme().ui_error_color(),
HighlightedHyperlink::default(),
)
.finish(),
);
}
if FeatureFlag::McpDebuggingIds.is_enabled() {
info_column = info_column.with_child(self.render_debug_lines(app, appearance));
}
info_column
}
fn build_icon_button(
&self,
appearance: &Appearance,
icon: Icon,
tooltip_text: String,
mouse_handle: MouseStateHandle,
) -> Hoverable {
let ui_builder = appearance.ui_builder().clone();
icon_button(appearance, icon, false, mouse_handle)
.with_tooltip(move || ui_builder.tool_tip(tooltip_text.clone()).build().finish())
.build()
}
fn render_actions_row(&self, state: &MouseState, appearance: &Appearance) -> Box<dyn Element> {
let item_id = self.item_id;
let mut actions_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_main_axis_size(MainAxisSize::Max)
.with_spacing(style::SERVER_CARD_INTERIOR_SPACING);
if state.is_hovered() {
if self.render_options.show_view_logs_icon_button {
actions_row = actions_row.with_child(
self.build_icon_button(
appearance,
Icon::Code1,
"Show logs".to_string(),
self.mouse_handles.show_logs_icon_button.clone(),
)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::ViewLogs(item_id))
})
.finish(),
);
}
if self.render_options.show_log_out_icon_button {
actions_row = actions_row.with_child(
self.build_icon_button(
appearance,
Icon::LogOut,
"Log out".to_string(),
self.mouse_handles.logout_icon_button.clone(),
)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::LogOut(item_id));
})
.finish(),
);
}
if self.render_options.show_share_icon_button {
actions_row = actions_row.with_child(
self.build_icon_button(
appearance,
Icon::Share,
"Share server".to_string(),
self.mouse_handles.share_icon_button.clone(),
)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::Share(item_id));
})
.finish(),
);
}
if self.render_options.show_edit_config_icon_button {
actions_row = actions_row.with_child(
self.build_icon_button(
appearance,
Icon::Pencil,
"Edit".to_string(),
self.mouse_handles.edit_icon_button.clone(),
)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::Edit(item_id));
})
.finish(),
);
}
}
if self.render_options.show_update_available_icon_button {
actions_row = actions_row.with_child(self.render_update_available_icon(appearance));
}
if self.render_options.show_view_logs_text_button {
let view_logs_button = appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.mouse_handles.view_logs_button.clone(),
)
.with_centered_text_label("View logs".to_string())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::ViewLogs(item_id))
})
.finish();
actions_row = actions_row.with_child(view_logs_button);
}
if self.render_options.show_edit_config_text_button {
let edit_config_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.mouse_handles.edit_config_button.clone(),
)
.with_centered_text_label("Edit config".to_string())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::Edit(item_id));
})
.finish();
actions_row = actions_row.with_child(edit_config_button);
}
if self.render_options.show_setup_text_button {
let setup_button = appearance
.ui_builder()
.button(
ButtonVariant::Accent,
self.mouse_handles.setup_button.clone(),
)
.with_centered_text_label("Set up".to_string())
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::Install(item_id));
})
.finish();
actions_row = actions_row.with_child(setup_button);
}
if self.render_options.show_add_icon {
let add_icon = galaxyui::elements::Icon::new(
Icon::Plus.into(),
blended_colors::text_main(appearance.theme(), appearance.theme().background()),
);
actions_row = actions_row.with_child(
ConstrainedBox::new(add_icon.finish())
.with_width(ICON_DIMENSIONS)
.with_height(ICON_DIMENSIONS)
.finish(),
);
}
if let Some(switch_state) = self.render_options.server_running_switch_state {
let switch = appearance
.ui_builder()
.switch(self.switch_state_handle.clone())
.check(switch_state)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::ToggleRunningSwitch)
})
.finish();
actions_row = actions_row.with_child(switch);
}
ConstrainedBox::new(actions_row.finish())
.with_min_height(ICON_DIMENSIONS)
.with_width(self.get_actions_row_width())
.finish()
}
fn render_update_available_icon(&self, appearance: &Appearance) -> Box<dyn Element> {
let item_id = self.item_id;
let update_available_button = self
.build_icon_button(
appearance,
Icon::Refresh,
"Server update available".to_string(),
self.mouse_handles.update_icon_button.clone(),
)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ServerCardAction::InstallServerUpdate(item_id));
})
.finish();
RedNotificationDot::render_with_offset(
update_available_button,
&UiComponentStyles {
width: Some(style::UPDATE_AVAILABLE_DOT_WIDTH),
height: Some(style::UPDATE_AVAILABLE_DOT_WIDTH),
background: Some(Fill::Solid(
AnsiColorIdentifier::Blue
.to_ansi_color(&appearance.theme().terminal_colors().normal)
.into(),
)),
..RedNotificationDot::default_styles(appearance)
},
(-4., 4.),
)
}
fn get_actions_row_width(&self) -> f32 {
let mut number_of_buttons = 0;
if self.render_options.show_add_icon {
number_of_buttons += 1;
}
if self.render_options.show_view_logs_text_button {
number_of_buttons += 1;
}
if self.render_options.show_edit_config_text_button {
number_of_buttons += 1;
}
if number_of_buttons > 1 {
style::SERVER_CARD_ACTIONS_WIDE_WIDTH
} else {
style::SERVER_CARD_ACTIONS_STANDARD_WIDTH
}
}
}
impl Entity for ServerCardView {
type Event = ServerCardEvent;
}
impl TypedActionView for ServerCardView {
type Action = ServerCardAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ServerCardAction::ToggleToolsExpanded => {
self.is_tools_expanded = !self.is_tools_expanded;
ctx.notify();
}
ServerCardAction::ToggleRunningSwitch => {
if let Some(server_running_state) = self.render_options.server_running_switch_state
{
let new_state = !server_running_state;
self.render_options.server_running_switch_state = Some(new_state);
ctx.emit(ServerCardEvent::ToggleRunningSwitch(
self.item_id,
new_state,
));
} else {
log::error!("Server card: Tried to toggle a switch that does not exist.")
}
ctx.notify();
}
ServerCardAction::Share(item_id) => {
ctx.emit(ServerCardEvent::Share(*item_id));
ctx.notify();
}
ServerCardAction::Edit(item_id) => {
ctx.emit(ServerCardEvent::Edit(*item_id));
ctx.notify();
}
ServerCardAction::Install(item_id) => {
ctx.emit(ServerCardEvent::Install(*item_id));
ctx.notify();
}
ServerCardAction::InstallServerUpdate(item_id) => {
ctx.emit(ServerCardEvent::InstallServerUpdate(*item_id));
ctx.notify();
}
ServerCardAction::ViewLogs(item_id) => {
ctx.emit(ServerCardEvent::ViewLogs(*item_id));
ctx.notify();
}
ServerCardAction::LogOut(item_id) => {
ctx.emit(ServerCardEvent::LogOut(*item_id));
ctx.notify();
}
ServerCardAction::FullCardClick => ctx.emit(ServerCardEvent::Install(self.item_id)),
}
}
fn action_accessibility_contents(
&mut self,
_action: &Self::Action,
_ctx: &mut ViewContext<Self>,
) -> ActionAccessibilityContent {
ActionAccessibilityContent::default()
}
}
impl View for ServerCardView {
fn ui_name() -> &'static str {
"ServerCardView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let mut hoverable = Hoverable::new(self.mouse_handles.card_hover.clone(), |state| {
let server_icon = self.render_server_icon_and_status(appearance);
let title_and_title_chip = ServerCardView::render_title_and_title_chip(
self.title.clone(),
&self.title_chips,
appearance,
);
let mut info_column = Flex::column().with_child(title_and_title_chip);
info_column = self.add_subtitle_lines(info_column, appearance, app);
if let Some(tools) = &self.tools {
let tools_info_row = self.render_tools_expandable(tools, appearance);
info_column = info_column.with_child(tools_info_row)
}
let actions_row = self.render_actions_row(state, appearance);
let mut card_body = Flex::column()
.with_child(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., info_column.finish()).finish())
.with_child(actions_row)
.finish(),
)
.with_spacing(style::SERVER_CARD_INTERIOR_SPACING);
if self.is_tools_expanded {
if let Some(tools) = &self.tools {
let tool_chips = ServerCardView::render_tool_chips(tools, appearance);
let tool_chips_row = Wrap::row()
.with_run_spacing(6.)
.with_children(tool_chips)
.finish();
card_body = card_body.with_child(tool_chips_row);
}
}
let mut card = Container::new(
Flex::row()
.with_child(server_icon)
.with_child(Expanded::new(1., card_body.finish()).finish())
.with_spacing(style::SERVER_CARD_INTERIOR_SPACING)
.finish(),
)
.with_padding(Padding::uniform(12.))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(style::CORNER_RADIUS)))
.with_border(Border::all(1.).with_border_fill(theme.outline()));
if self.render_options.full_card_clickable && (state.is_hovered() || state.is_clicked())
{
card = card.with_background(theme.surface_3());
} else if matches!(self.render_options.background, Background::Filled) {
card = card.with_background(theme.surface_1());
}
card.finish()
});
if self.render_options.full_card_clickable {
hoverable = hoverable
.on_click(|ctx, _, _| ctx.dispatch_typed_action(ServerCardAction::FullCardClick));
}
hoverable.finish()
}
}